Compare commits
5 Commits
b8434fdd36
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b823b4750 | |||
| 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,107 @@
|
|||||||
|
# GoblinPay's part of the Nym → Tor migration
|
||||||
|
|
||||||
|
**Status:** Planning document only — no code or config changed by this document.
|
||||||
|
**Date:** 2026-07-04
|
||||||
|
**Scope:** What, if anything, GoblinPay needs to do as Goblin moves its Nostr transport off the Nym mixnet and onto Tor. The overall decision and the wallet-side work are covered in `goblin/docs/PRIVACY-TRANSPORT-REDESIGN.md` — that document is the source of truth for *why* this move is happening; this one is about GoblinPay specifically.
|
||||||
|
|
||||||
|
The short version, up front: GoblinPay is already in the state this migration is trying to reach. There is no fire here, and most of what follows is either "nothing to do" or "housekeeping, whenever it's convenient."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The framing, carried through
|
||||||
|
|
||||||
|
The wallet plan puts it this way: **Tor hides the user's IP from the relay; the relay + protocol hide everything else.** That framing is worth restating here because it explains why GoblinPay's part of this story is small. GoblinPay is receive-only infrastructure, not the party whose IP privacy is actually at stake in a payment — that's the paying customer, and their privacy rides on their own Goblin Wallet, not on anything GoblinPay does. And the "protocol hides everything else" half of that sentence — NIP-44 encryption inside a NIP-59 gift-wrap — was never Nym's job to begin with, and nothing about this migration touches it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 0 / where we stand today: nothing is broken, nothing is urgent
|
||||||
|
|
||||||
|
GoblinPay has had its own in-process Nym mixnet client since it was built — a direct port of the wallet's, living at `crates/gp-nostr/src/nym/` — gated behind an environment variable, `GP_NYM`, in `/opt/goblin/goblinpay/goblinpay.env` on us-east.
|
||||||
|
|
||||||
|
**`GP_NYM` is already set to `off` in production, and has been for more than a day.** That was confirmed on a live check of the running server. Practically, this means GoblinPay is not caught up in the mixnet's death at all: it is already reaching its relays over clearnet, today, in production, live, taking real payments — which is exactly the end state this whole migration is aiming for. There's no incident to respond to and no clock running out.
|
||||||
|
|
||||||
|
It's worth being precise about what "off" actually means at runtime, because it's a clean switch rather than a degraded state. Reading `crates/gp-nostr/src/service.rs`: when the config's `opts.nym` is false, the code takes a genuinely separate branch — it builds a plain Nostr client with no Nym transport wired in at all (no tunnel start, no warm-up wait, no dial attempt), and logs a calm, expected line on every boot: `GP_NYM=off — this server's relay traffic goes CLEARNET (supported: the payer's wallet still provides sender privacy; the payload stays gift-wrapped)`. That's not a fallback after something failed; it's an intentional, supported posture the code was written to handle. The surrounding comments in the codebase say as much directly: `GP_NYM=off` is "a supported production posture, not just a debugging switch."
|
||||||
|
|
||||||
|
One thing worth flagging so it doesn't read as a discrepancy: the *default* baked into the code, if the environment variable were ever unset, is still `on` (`crates/gp-core/src/config.rs`, and `deploy/.env.example` ships `GP_NYM=on` as its template default). Production has explicitly overridden that default to `off`. That override predates this plan — whoever set it made the right call already, presumably as GoblinPay was riding out the mixnet's earlier flakiness. This document doesn't need to change that decision; it just needs to catch up the paperwork and, eventually, the code, to match it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What doesn't change: encryption and payer privacy are someone else's job
|
||||||
|
|
||||||
|
Two things that are easy to lump in with "the Nym migration" but are actually untouched by any of it:
|
||||||
|
|
||||||
|
**Content encryption.** Every payment that arrives as a Nostr message is sealed with NIP-44 inside a NIP-59 gift-wrap (kind 1059), exactly as it is today. That's a property of the message format, not of the pipe it travels through. Nothing about the wallet moving from Nym to Tor, and nothing about GoblinPay's own `GP_NYM` setting, has ever touched this.
|
||||||
|
|
||||||
|
**Payer privacy.** The privacy a paying customer actually gets — their IP hidden from the relay, and the timing-privacy work the wallet plan describes — comes entirely from the *customer's* own Goblin Wallet and whatever transport it uses. That was true when the wallet used Nym and stays true once it uses Tor. GoblinPay's `GP_NYM` setting never had anything to do with it: that switch only ever controlled whether *GoblinPay's own server* hid its own IP from the relay it talks to — a much narrower, much lower-stakes thing. So the wallet's move to Tor improves the customer's privacy on its own, with no required change on GoblinPay's side at all.
|
||||||
|
|
||||||
|
Worth stating here too, since it's easy to forget when talking about "the Nostr path": GoblinPay also accepts payment by having the customer paste a slatepack (`grin1`) directly into the checkout page, with no Goblin Wallet and no Nostr involved. That path is plain HTTPS from end to end and has never gone anywhere near Nym, and won't go anywhere near Tor either. It's structurally outside this entire migration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cleanup phase: retiring the ported Nym client (do this once the wallet's Tor build is out and settled)
|
||||||
|
|
||||||
|
This is not urgent — it's housekeeping to schedule after the wallet's own Tor migration has shipped and had some time to prove itself, so GoblinPay isn't left as the one place still carrying old ported code for no live reason.
|
||||||
|
|
||||||
|
GoblinPay's copy of the Nym client lives at `crates/gp-nostr/src/nym/`: four files (`mod.rs`, `transport.rs`, `nymproc.rs`, `dns.rs`) totaling 599 lines, ported from the wallet's own `src/nym`. It's linked in-process through a path dependency on `smolmix` (`crates/gp-nostr/Cargo.toml`, pinned to a specific revision of the sibling `nym` checkout), plus `hickory-proto`, which is only in the dependency graph to give `nym/dns.rs` its mix-DNS wire codec — nothing else in `gp-nostr` calls it.
|
||||||
|
|
||||||
|
Because `GP_NYM` is already off in production, deleting all of this is **pure subtraction**: the code path that remains after the deletion is the exact code path already running live today, so there's no behavior to migrate and no new path to prove out — just less code to carry. The concrete list:
|
||||||
|
|
||||||
|
- Delete `crates/gp-nostr/src/nym/` in full (all four files, 599 lines).
|
||||||
|
- Remove the `smolmix` and `hickory-proto` dependency declarations from `crates/gp-nostr/Cargo.toml`, along with the comment block explaining the pinned revision.
|
||||||
|
- Remove the `GP_NYM` (and the debug-only `GP_NYM_IPR` exit-pin override) config plumbing from `crates/gp-core/src/config.rs`, and the `opts.nym` if/else branch in `crates/gp-nostr/src/service.rs`. After this, GoblinPay simply always builds the plain clearnet client — again, exactly what it already does.
|
||||||
|
- Trim the *nym-specific slice* of the build plumbing. This needs one careful distinction, because the Dockerfile and compose file currently vendor two sibling trees together, and only one of them is going away:
|
||||||
|
- `deploy/Dockerfile` vendors both `nip44/` and `nym/` as sibling checkouts (see its header comment and the `COPY nip44` / `COPY nym` lines). Only the `nym` half is Nym-related — `nip44` is the unrelated NIP-44 v3 companion crate and isn't part of this migration. So: drop the `COPY nym ./nym` line and the parts of the header comment naming `nym` as a required sibling, but **keep** the `nip44` vendoring and the workspace-parent build context that supports it.
|
||||||
|
- `deploy/docker-compose.yml` justifies its `context: ../..` build context (instead of building from the repo root) by pointing at both `nip44/` and `nym/` as sibling trees the Dockerfile needs. That context has to stay for `nip44`'s sake regardless of this cleanup — only its comment's mention of `nym/` should come out.
|
||||||
|
- `deploy/install.sh` has the same "build prerequisite" comment naming both sibling trees; trim the `nym/` mention, keep the `nip44/` one.
|
||||||
|
- `.gitea/workflows/ci.yml` and `.github/workflows/ci.yml` (both at line 52) gate the full CI test run on three sibling checkouts being present: `../nip44`, `../nym/smolmix/core`, and `../goblin`. Drop the `nym` leg of that check; keep the other two.
|
||||||
|
|
||||||
|
End state: a smaller dependency tree, one fewer pinned external-repo revision to track, and — because production has already been running without it — no behavior change to test for beyond "GoblinPay still boots and still moves money."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Optional parity phase: hiding GoblinPay's own server-to-relay hop
|
||||||
|
|
||||||
|
If there's ever a reason to go further, GoblinPay could dial its co-located relay over that relay's `.onion` address — the same onion service the wallet will be pinning and dialing — instead of reaching it over clearnet. That would restore the one thing `GP_NYM` used to provide: hiding GoblinPay's own server IP from the relay it talks to.
|
||||||
|
|
||||||
|
This is genuinely optional, not a recommended follow-on. The relays it would apply to — `relay.floonet.dev`, and `nrelay.us-ea.st` once its container is running again — are co-located on the same box as GoblinPay itself. Reaching them is already close to a localhost hop, so there's very little real exposure left to close. Worth doing only if it falls out cheaply once those relays' onion services exist for the wallet's sake anyway — not worth its own dedicated effort.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A decision for the owner: trim the relay list?
|
||||||
|
|
||||||
|
GoblinPay currently runs in `external` relay mode with three relays configured: `relay.floonet.dev` and `nrelay.us-ea.st`, both co-located on the same box as GoblinPay (the latter's backend container is currently crashed — a separate, pre-existing infra issue, addressed below, not part of this migration), and `relay.damus.io`, a genuine public third-party relay.
|
||||||
|
|
||||||
|
Because `relay.damus.io` is a real external party, it sees GoblinPay's server IP on every connection, in the clear — and the optional onion-dialing above wouldn't change that, since damus.io isn't one of the co-located relays it would apply to. Trimming `GP_RELAYS` down to the co-located relay(s) only, done alongside the cleanup phase, would remove that exposure entirely.
|
||||||
|
|
||||||
|
The tradeoff, stated plainly: fewer relays means less redundant reach if a co-located relay goes down — which is exactly the situation with `nrelay.us-ea.st` right now, and with only `relay.floonet.dev` left in that scenario, GoblinPay's inbox would have no relay left to fall back on. Weighed against that is one fewer third party watching the server's connection metadata. This is a real reliability-versus-exposure call, and it belongs to the owner — this plan is flagging it, not making it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Copy fixes
|
||||||
|
|
||||||
|
Three connector-facing strings currently describe a payment as traveling "over Nostr (optionally over the Nym mixnet)":
|
||||||
|
|
||||||
|
- `connectors/woocommerce/goblinpay-woocommerce.php:5` (the plugin's `Description:` header)
|
||||||
|
- `connectors/woocommerce/README.md:6`
|
||||||
|
- `connectors/medusa/README.md:6`
|
||||||
|
|
||||||
|
Worth reading these carefully before editing them, because the parenthetical describes the *payer's* wallet transport (the customer's Goblin Wallet choosing how to reach the relay) — GoblinPay has no say in that hop at all. So once the wallet ships Tor, the honest fix isn't a find-and-replace of "Nym" with "Tor" as if it were GoblinPay's own setting — it's rewording to make clear this is about the payer's wallet, something like "(the payer's wallet may route this over Tor)," or simply dropping the parenthetical if it reads as clutter in a merchant-facing doc.
|
||||||
|
|
||||||
|
Two more mentions turned up while confirming these file details, both describing GoblinPay's *own* transport rather than the payer's, and both outside the three files above: the root `README.md` has a paragraph (around line 44) explaining that "by default all relay traffic rides an in-process Nym mixnet tunnel," and a `GP_NYM` row in its configuration table (around line 84). `deploy/.env.example`'s `GP_NYM=on` line and its surrounding comment describe the same thing. These three shouldn't be reworded to say "Tor" — GoblinPay isn't gaining a Tor transport of its own, it's simply losing the Nym one it had — so they should be rewritten or deleted as part of the cleanup phase above, alongside the code they describe, rather than treated as a standalone copy pass now.
|
||||||
|
|
||||||
|
Net: the three connector-doc fixes are a small, low-risk, copy-only change that can land anytime, independent of everything else here. The `README.md` / `.env.example` mentions should wait and land together with the cleanup phase, since they document code that phase removes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risks and notes
|
||||||
|
|
||||||
|
- **This is a live store.** GoblinPay is processing real payments into the cryptodrip WooCommerce store. Even though the cleanup phase is pure subtraction of already-unused code, build and test it off-prod first (a branch build, or at minimum a staging run of `gp-server`) before it lands on us-east.
|
||||||
|
- **The slatepack (`grin1`) path must keep working exactly as it does today.** It's structurally untouched by everything in this plan (it never used Nostr), but "nothing should have changed" is worth a deliberate regression check after the cleanup phase rather than an assumption.
|
||||||
|
- **`nrelay.us-ea.st`'s crashed backend container is a separate, pre-existing issue.** It's mentioned here only because it happens to be one of the three relays in GoblinPay's list. Fixing it is out of scope for this plan and should be handled on its own.
|
||||||
|
- **None of this is time-pressured.** The thing that made the wallet's migration urgent — Nym's free-bandwidth grant expiring on a schedule and the paid replacement requiring a token the project won't hold — doesn't apply here, because `GP_NYM` is already off. Sequence the cleanup phase whenever convenient, ideally after the wallet's Tor build has shipped and proven itself.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bottom line
|
||||||
|
|
||||||
|
GoblinPay doesn't need to do anything today. `GP_NYM=off` is already the stable, running posture in production and has been for a while — GoblinPay's server traffic already goes over clearnet to its relays, which is exactly where it would end up even if every step below were finished tomorrow. What's left is housekeeping: delete the 599-line ported Nym client and its dependencies once the dust settles, fix three doc strings that describe the *payer's* transport (not GoblinPay's), and let the owner weigh in on whether `relay.damus.io` stays in the relay list. The things that actually matter for a customer's privacy — the gift-wrap encryption, and the customer's own wallet's transport — were never GoblinPay's to change in the first place.
|
||||||
@@ -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