Compare commits

8 Commits

Author SHA1 Message Date
2ro 33c5ee568f chore: retire the dormant Nym client (Nym → Tor migration cleanup)
ci / fmt / clippy / test (push) Waiting to run
Production has run GP_NYM=off for over a day: GoblinPay already reaches its
relays over clearnet, which is the end state the ecosystem's Nym → Tor move
aims for. This removes the now-dead ported Nym client and its plumbing. Pure
subtraction — the code path that remains is exactly the one already running
live, so there is no behavior change beyond "GoblinPay still boots and still
moves money." Per TOR-MIGRATION-PLAN.md.

Removed:
- crates/gp-nostr/src/nym/ (mod, transport, nymproc, dns — 599 lines).
- smolmix + hickory-proto (and the nym-only rand) deps from gp-nostr, and
  their transitive tree from Cargo.lock.
- GP_NYM config plumbing (gp-core config: field, parse, default, summary),
  the opts.nym service branch, the main.rs warm-up wiring, and the admin
  dashboard Nym row.
- The nym leg of the CI sibling-checkout gate (.github + .gitea), the
  `COPY nym` / sibling mentions in Dockerfile/compose/install.sh, and the
  GP_NYM copy in README, .env.example, and the three connector docs.

GoblinPay does not gain a Tor transport of its own: it is receive-only
infrastructure, and the sender privacy that matters rides the paying
customer's own Goblin Wallet. Content encryption (NIP-44 in a NIP-59
gift-wrap) and the slatepack (grin1) path are untouched. The optional
onion-dialing and relay-list trim the plan flags are left for the owner.
2026-07-04 06:31:18 -04:00
2ro 89eb4901bd fix(gp-server): silence clippy field_reassign_with_default in checkout tests
ci / fmt / clippy / test (push) Waiting to run
2026-07-04 06:15:17 -04:00
2ro e9fc2f3d94 chore: gitignore local .claude/ workdir (not project config) 2026-07-04 06:15:15 -04:00
2ro 3b823b4750 docs(internal): Tor migration plan (GP_NYM already off; cleanup scope)
ci / fmt / clippy / test (push) Waiting to run
2026-07-04 03:40:09 -04:00
2ro 3d36117d7b docs: bundled relay, GP_NYM production posture, connectors + deploy in README
ci / fmt / clippy / test (push) Has been cancelled
Document bundled mode and GP_BUNDLED_RELAY_URL, state GP_NYM=off as a supported server-side-clearnet posture (the payer's wallet still provides privacy) rather than debugging-only, and add Connectors (WooCommerce/Medusa/REST) and Deploy sections. The README ends with the AI pair-programming credit line.
2026-07-03 03:22:53 -04:00
2ro 3fdf4a230c M11: reproducible deploy pipeline
Multi-stage non-root Dockerfile (builds -p gp-server against the nip44/nym siblings; excludes the goblin-tree dev crate), a full docker-compose (server + bundled nostr-rs-relay + auto-HTTPS Caddy), a hardened systemd unit (DynamicUser, ProtectSystem=strict, NoNewPrivileges, seed via LoadCredential), an install.sh bare-metal bootstrap, .env.example, and an fmt+clippy+test CI workflow for Gitea and GitHub.
2026-07-03 03:22:43 -04:00
2ro bba1dd5cba M9: Medusa v2 payment provider connector
A minimal receive-only GoblinPay payment provider for Medusa v2 under connectors/medusa (service + module registration + types, README + INSTALL), modeled on connectors/woocommerce and the medusa-plugin-btcpay reference: create-invoice on initiate, an HMAC-verified webhook flips the payment to captured, status polling as the webhook-miss fallback. Refunds throw (receive-only, manual). Also refresh the store.rs docstring now that WooCommerce and Medusa have shipped.
2026-07-03 03:22:36 -04:00
2ro c32ddfa9ff M8: bundled relay — RelayMode::Bundled runs a co-located nostr-rs-relay
Make bundled mode actually self-contained: resolve() now leads the relay set with GP_BUNDLED_RELAY_URL (default ws://127.0.0.1:7777), which the checkout nprofile advertises, so a merchant needs no third-party relay. External mode uses only GP_RELAYS. Ship the relay as a vendored, unmodified nostr-rs-relay config (deploy/relay/nostr-rs-relay.toml); the compose service arrives with the deploy pipeline. Fix the stale "bundled is a later milestone" comment and reconcile the GP_NYM=off wording to a supported server-side-clearnet posture.
2026-07-03 03:22:29 -04:00
37 changed files with 1532 additions and 5902 deletions
+58
View File
@@ -0,0 +1,58 @@
# CI gate for GoblinPay. Mirror of .github/workflows/ci.yml.
#
# What runs where:
# - fmt + the gp-core clippy/test gate run on ANY runner: gp-core is
# self-contained (no out-of-repo deps), and it holds the domain logic
# (config, invoices, matching, webhooks, rates, the connector seam).
# - The FULL gate (gp-wallet + gp-nostr + gp-server, via ./ci.sh) needs the
# sibling checkouts next to the repo: nip44/ (the Nostr 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 ../goblin ]; then
echo "Workspace siblings present — running the full ./ci.sh gate."
./ci.sh
else
echo "nip44/goblin siblings absent on this runner;"
echo "the full gp-server gate runs via ./ci.sh on the deploy host."
fi
+58
View File
@@ -0,0 +1,58 @@
# CI gate for GoblinPay. Mirror of .gitea/workflows/ci.yml.
#
# What runs where:
# - fmt + the gp-core clippy/test gate run on ANY runner: gp-core is
# self-contained (no out-of-repo deps), and it holds the domain logic
# (config, invoices, matching, webhooks, rates, the connector seam).
# - The FULL gate (gp-wallet + gp-nostr + gp-server, via ./ci.sh) needs the
# sibling checkouts next to the repo: nip44/ (the Nostr 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 ../goblin ]; then
echo "Workspace siblings present — running the full ./ci.sh gate."
./ci.sh
else
echo "nip44/goblin siblings absent on this runner;"
echo "the full gp-server gate runs via ./ci.sh on the deploy host."
fi
+1
View File
@@ -1,4 +1,5 @@
/target
.claude/
*.db
*.db-shm
*.db-wal
Generated
+107 -5170
View File
File diff suppressed because it is too large Load Diff
+57 -12
View File
@@ -2,8 +2,8 @@
A self-hostable, receive-only Grin payment server. A merchant runs it, a
customer pays from Goblin Wallet by scanning a QR code, and the payment
travels as a gift-wrapped slatepack over Nostr (optionally over the Nym
mixnet). GoblinPay auto-receives, returns the S2 reply so the payer can
travels as a gift-wrapped slatepack over Nostr. GoblinPay auto-receives,
returns the S2 reply so the payer can
finalize, confirms the transaction on chain, and signals paid.
Beyond the core wallet + transport + on-chain confirmation path, GoblinPay
@@ -41,10 +41,14 @@ carries the full merchant surface:
HTTP webhook (the WooCommerce contract), an authenticated admin dashboard +
JSON API, and NIP-17 DMs to the merchant / payer.
All relay traffic rides an in-process Nym mixnet tunnel (smolmix, auto-selected
exit, mix-dns; `GP_NYM=off` is a debugging escape hatch only). Encryption
negotiates NIP-44 v3 (the NIP-17 extension, via the companion `nip44` crate) per
recipient, with v2 as the mandatory baseline.
GoblinPay reaches its relays over clearnet. That is a supported posture for a
receive-only till: the sender privacy that matters belongs to the paying
customer and rides their own Goblin Wallet's transport, not GoblinPay's, and the
payload stays gift-wrapped end to end regardless of the pipe it travels through.
An operator who wants to hide GoblinPay's own server-to-relay hop as well can
front it with their own network privacy. Encryption negotiates NIP-44 v3 (the
NIP-17 extension, via the companion `nip44` crate) per recipient, with v2 as the
mandatory baseline.
## Workspace
@@ -52,7 +56,7 @@ recipient, with v2 as the mandatory baseline.
|---|---|
| `crates/gp-wallet` | Grin wallet handoff: open from mnemonic, S1 -> `receive_tx` -> S2 (offline) |
| `crates/gp-goblin-sender` | Test-only gate helper: sends and finalizes with Goblin's wallet stack |
| `crates/gp-nostr` | Nostr transport: identity, gift wrap (NIP-44 v2/v3), ingest, Nym mixnet |
| `crates/gp-nostr` | Nostr transport: identity, gift wrap (NIP-44 v2/v3), ingest |
| `crates/gp-core` | Domain core: config, SQLite persistence (sqlx, raw SQL) |
| `crates/gp-server` | Actix-Web binary: routes, Askama templates, rustls TLS |
@@ -73,9 +77,9 @@ Everything is environment variables, defaults are safe for local use.
| `GP_DATA_DIR` | `./gp-data` | Data directory (wallet files, encrypted seed) |
| `GP_NODE_URL` | `https://main.gri.mw` | External Grin node (read only) |
| `GP_CHAIN` | `mainnet` | Grin network: `mainnet` or `testnet` |
| `GP_RELAY_MODE` | `bundled` | `bundled` or `external` |
| `GP_RELAYS` | unset | Comma-separated relay URLs |
| `GP_NYM` | `on` | Route Nostr traffic over the Nym mixnet (`on` or `off`) |
| `GP_RELAY_MODE` | `bundled` | `bundled` (GoblinPay runs its own co-located relay) or `external` |
| `GP_BUNDLED_RELAY_URL` | `ws://127.0.0.1:7777` | In `bundled` mode, the self-contained relay GoblinPay dials AND advertises in the checkout `nprofile`; set to the relay's public `wss://` URL in production |
| `GP_RELAYS` | unset | Extra relay URLs (comma separated): redundancy in `bundled` mode, the whole set in `external` mode |
| `GP_INGEST` | `on` | Nostr ingest service (`off` = HTTP surface only, for debugging) |
| `GP_CHECKOUT_METHODS` | `nostr,slatepack` | Which payment methods the hosted `/pay/<token>` page shows: comma list of `nostr` (Goblin Wallet) and `slatepack` (`grin1`). Unset = both. Unknown tokens are ignored; an empty result falls back to both |
| `GP_MATCH_MODE` | `memo` | Default matching mode: `memo`, `derived`, `amount` |
@@ -113,6 +117,27 @@ ingest, drop `nostr` from `GP_CHECKOUT_METHODS`; if you advertise `nostr`, keep
ingest on. The connector `POST /invoice` JSON response still returns the
`nprofile` regardless of this setting, which affects only the hosted page.
### Bundled relay
`GP_RELAY_MODE=bundled` (the default) means GoblinPay runs against its own
co-located Nostr relay, so a merchant needs no third-party relay. The relay is a
stock, unmodified `nostr-rs-relay` (a small, SQLite-backed Rust relay) vendored
as the `relay` service in `deploy/docker-compose.yml` with a config file at
`deploy/relay/nostr-rs-relay.toml` (config only, no fork). It was chosen over
writing a relay from scratch: it is battle-tested, lightweight enough for a
single-merchant till, and keeps the money path off any third-party
infrastructure.
`GP_BUNDLED_RELAY_URL` is the relay's URL. It is both dialed by the server and
advertised to payers in the checkout `nprofile`, so the payer's Goblin Wallet is
told to deliver the gift-wrapped slatepack straight to the merchant's own relay.
Set it to the relay's public `wss://` URL in production (the compose file and
`deploy/Caddyfile` serve it on `relay.<GP_DOMAIN>`); the default
`ws://127.0.0.1:7777` suits local and same-host development. Any `GP_RELAYS` are
appended for redundancy and advertised alongside the bundled relay.
`GP_RELAY_MODE=external` uses only the `GP_RELAYS` set and runs no bundled relay.
### Conversion rates (optional)
A store that prices in fiat (for example cryptodrip.com prices in USD) sends
@@ -128,8 +153,7 @@ hammer the source. If the source is unreachable or the currency is not enabled,
`create-invoice` fails fast with a clear error rather than creating an
unpriceable invoice; `GP_RATE_STALE_MAX` optionally permits serving the last
cached rate within a bounded window instead. The oracle fetch goes DIRECT over
normal HTTP, never through the Nym mixnet (the mixnet carries only the Nostr
gift-wrap layer, the same ruling as the read-only node client).
normal HTTP, the same as the read-only node client.
The secrets also accept mounted-file variants, `GP_MNEMONIC_FILE`,
`GP_WALLET_PASSWORD_FILE`, `GP_NSEC_FILE`, and `GP_NCRYPTSEC_FILE`
@@ -202,6 +226,27 @@ curl http://127.0.0.1:8080/health
./ci.sh # cargo fmt --check, clippy -D warnings, tests
```
## Connectors
Store integrations live under `connectors/` and all speak the same
create-invoice + signed-webhook contract:
- `connectors/woocommerce` — a WordPress/WooCommerce gateway (classic + Blocks).
- `connectors/medusa` — a Medusa v2 payment-module provider.
- The generic REST connector is built in: `POST /invoice` plus the webhook.
Refunds are unsupported/manual everywhere (GoblinPay is receive-only).
## Deploy
`deploy/` holds a reproducible deployment: a hardened systemd unit
(`gp-server.service`) with `deploy/install.sh` for bare metal, and a
`docker-compose.yml` that brings up the server, the bundled relay, and an
auto-HTTPS Caddy proxy. CI (`.github` / `.gitea` workflows) runs fmt, clippy,
and tests. See `deploy/` for details.
## Credits
GoblinPay is developed with the help of Claude (Anthropic).
Built with AI pair-programming assistance (Claude)
+107
View File
@@ -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.
+94
View File
@@ -0,0 +1,94 @@
# Installing GoblinPay for Medusa
This is a Medusa v2 payment-module provider. There are two ways to add it.
## 1. Add the provider to your Medusa app
### Option A: copy the source (simplest)
Copy this `medusa` directory into your Medusa app as a module, for example
`src/modules/goblinpay`, keeping the `src/` files (`index.ts`, `service.ts`,
`types.ts`). Medusa compiles it with the rest of your app.
### Option B: install as a package
Publish or vendor `medusa-payment-goblinpay` and add it to your app's
dependencies. Build it first with `npm run build` (emits `dist/`).
## 2. Register it in `medusa-config.ts`
Add GoblinPay to the payment module's `providers`. Use `id: "goblinpay"` so the
webhook route is predictable (see step 4):
```ts
module.exports = defineConfig({
// ...
modules: [
{
resolve: "@medusajs/medusa/payment",
options: {
providers: [
{
// Option A: the path to the copied module.
resolve: "./src/modules/goblinpay",
// Option B: the package name, "medusa-payment-goblinpay".
id: "goblinpay",
options: {
baseUrl: process.env.GOBLINPAY_URL,
apiToken: process.env.GOBLINPAY_API_TOKEN,
webhookSecret: process.env.GOBLINPAY_WEBHOOK_SECRET,
matchMode: "derived",
},
},
],
},
},
],
})
```
## 3. Set the environment
In your Medusa app's `.env`:
```
GOBLINPAY_URL=https://pay.example
GOBLINPAY_API_TOKEN=<the same value as GP_API_TOKEN on the server>
GOBLINPAY_WEBHOOK_SECRET=<the same value as GP_WEBHOOK_SECRET on the server>
```
Then enable the `goblinpay` provider in the region(s) that should offer Grin,
via the Medusa admin (Settings, then Regions, then Payment Providers).
## 4. Register the webhook in GoblinPay
Point your GoblinPay server at the Medusa payment webhook route. The route id is
`<provider id>_<identifier>`, both `goblinpay`, so set these on the GoblinPay
side:
- `GP_WEBHOOK_URL` = `https://YOUR-MEDUSA-HOST/hooks/payment/goblinpay_goblinpay`
- `GP_WEBHOOK_SECRET` = the same secret you set as `webhookSecret`.
- `GP_API_TOKEN` = the same token you set as `apiToken`.
GoblinPay signs each delivery with `X-GoblinPay-Signature: sha256=<hmac>` over
the raw body and sends an idempotency key in `X-GoblinPay-Delivery`. The provider
verifies the signature (constant-time) and flips the payment to captured.
Make sure the Medusa host is reachable from the GoblinPay host. If a webhook is
ever missed, Medusa's `getPaymentStatus` polls
`GET {baseUrl}/invoice/{invoice_id}` (with the bearer token) as a fallback.
## 5. Test
Place a test order, choose Grin (GoblinPay), and confirm:
- The storefront shows the GoblinPay QR / redirects to the `/pay/<token>` page
(the checkout details are on the payment session's `data.goblinpay`).
- Paying from a Goblin Wallet moves the order's payment to captured once
GoblinPay delivers the `payment.received` webhook.
## Refund caveat
Refunds are not automated. GoblinPay is receive-only and never sends Grin, so
any refund is a manual Grin send performed by the merchant from a wallet under
their control. `refundPayment` throws to make this explicit.
+90
View File
@@ -0,0 +1,90 @@
# GoblinPay for Medusa
Accept Grin (GRIN / MimbleWimble) payments in a Medusa v2 store through a
self-hosted GoblinPay server. The customer pays from their Goblin Wallet by
scanning an `nprofile` QR code. The payment travels as a gift-wrapped slatepack
over Nostr. GoblinPay receives it, returns the
reply slatepack to the payer, watches the chain to confirm, and notifies Medusa
through a signed webhook.
This provider is a thin client. All of the Grin and Nostr work happens in
GoblinPay; Medusa only talks HTTP to your GoblinPay instance. No BTCPay, no node
exposed to the store, no wallet RPC.
## What it does
- Registers a `goblinpay` payment provider in the Medusa v2 payment module.
- On checkout, calls GoblinPay to create an invoice for the payment session and
stores the checkout details (`pay_url`, `nprofile`, `qr_svg`) on the session
so your storefront can render the QR or redirect to GoblinPay's hosted
`/pay/<token>` page.
- Captures the payment when GoblinPay reports it, via a signed webhook. If a
webhook is missed, `authorizePayment` and `getPaymentStatus` poll GoblinPay
for the invoice status as a fallback.
## Requirements
- Medusa v2 (built against `@medusajs/framework` 2.12; 2.x expected to work).
- Node 20 or newer.
- A running GoblinPay server reachable from the Medusa host.
## Options
Set these per-provider in `medusa-config.ts` (see INSTALL.md):
- `baseUrl`: base URL of your GoblinPay server, no trailing slash, for example
`https://pay.example`.
- `apiToken`: the GoblinPay create-invoice bearer token (`GP_API_TOKEN` on the
server).
- `webhookSecret`: the shared HMAC secret (`GP_WEBHOOK_SECRET` on the server).
- `matchMode` (optional): how GoblinPay ties an incoming payment to the order.
`derived` (per-invoice identity, recommended) gives each order its own QR and
is the most reliable. `memo` and `amount` are also available. Omit to use the
server default.
- `expirySecs` (optional): invoice expiry in seconds from creation.
## Webhook
GoblinPay reports payments to the Medusa payment module's built-in webhook
route. Point your GoblinPay server's `GP_WEBHOOK_URL` at:
```
https://YOUR-MEDUSA-HOST/hooks/payment/goblinpay_goblinpay
```
The provider verifies the `X-GoblinPay-Signature: sha256=<hmac>` header against
the exact raw body (constant-time) before acting.
## Status mapping
| GoblinPay | Medusa payment session |
|---|---|
| invoice `open` | `pending` |
| invoice `paid` | `captured` |
| invoice `expired` | `canceled` |
| webhook `payment.received` | captured (SUCCESSFUL) |
| webhook `payment.confirmed` | captured (SUCCESSFUL, idempotent) |
For a receive-only till, a received payment (the reply slatepack is back and the
funds are in the merchant wallet) is treated as paid, the same as the
WooCommerce connector. The later on-chain confirmation is an idempotent no-op.
## Refunds
Refunds are not automated. GoblinPay is receive-only: it never sends Grin. A
refund is therefore a manual, out-of-band Grin send by the merchant from a
wallet under their control. `refundPayment` throws to make this explicit, the
same caveat the Grin BTCPay connector carries.
## Security notes
- The webhook is authenticated by an HMAC-SHA256 signature over the exact raw
request body, compared in constant time. A bad or missing signature is
rejected and the payment is not flipped.
- The capture amount is read from the Medusa payment session (its own
store-currency amount), not from untrusted webhook fields.
- Secrets live in the provider options / environment, never in code.
## Credit
Built by Claude (Anthropic) for the Goblin project.
+41
View File
@@ -0,0 +1,41 @@
{
"name": "medusa-payment-goblinpay",
"version": "1.0.0",
"description": "GoblinPay payment provider for Medusa v2: accept Grin (GRIN / MimbleWimble) payments through a self-hosted, receive-only GoblinPay server.",
"license": "Apache-2.0",
"keywords": [
"medusa",
"medusa-v2",
"medusa-plugin",
"medusa-plugin-payment",
"payment",
"grin",
"mimblewimble",
"goblinpay"
],
"repository": {
"type": "git",
"url": "https://git.us-ea.st/GRIN/GoblinPay"
},
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist",
"src",
"README.md",
"INSTALL.md"
],
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"@medusajs/framework": "^2.0.0"
},
"devDependencies": {
"@medusajs/framework": "^2.12.0",
"@types/node": "^20.0.0",
"typescript": "^5.4.0"
}
}
+9
View File
@@ -0,0 +1,9 @@
import { ModuleProvider, Modules } from "@medusajs/framework/utils"
import GoblinPayProviderService from "./service"
// Register GoblinPay as a Medusa v2 payment-module provider. Referenced from
// medusa-config.ts under the payment module's `providers` (see INSTALL.md).
export default ModuleProvider(Modules.PAYMENT, {
services: [GoblinPayProviderService],
})
+302
View File
@@ -0,0 +1,302 @@
/**
* GoblinPay payment provider for Medusa v2 (tested against @medusajs 2.12).
*
* Modeled on connectors/woocommerce and on the reference
* github.com/SGFGOV/medusa-payment-plugins (packages/medusa-plugin-btcpay).
*
* Flow: `initiatePayment` creates a GoblinPay invoice for the order and stashes
* the checkout details (pay_url, nprofile, qr_svg) on the session so the
* storefront can render or redirect. The customer pays from their Goblin Wallet;
* GoblinPay receives it, returns the reply slatepack, watches the chain, and
* POSTs a signed webhook. `getWebhookActionAndData` verifies the HMAC and flips
* the Medusa payment to captured. Status polling (`authorizePayment`,
* `getPaymentStatus`) is the webhook-miss fallback.
*
* Refunds are NOT automated: GoblinPay is receive-only (it never sends Grin), so
* `refundPayment` throws. A refund is a manual, out-of-band Grin send by the
* merchant. See README.md.
*/
import crypto from "node:crypto"
import {
AbstractPaymentProvider,
ContainerRegistrationKeys,
MedusaError,
Modules,
PaymentActions,
} from "@medusajs/framework/utils"
import type {
AuthorizePaymentInput,
AuthorizePaymentOutput,
CancelPaymentInput,
CancelPaymentOutput,
CapturePaymentInput,
CapturePaymentOutput,
DeletePaymentInput,
DeletePaymentOutput,
GetPaymentStatusInput,
GetPaymentStatusOutput,
InitiatePaymentInput,
InitiatePaymentOutput,
IPaymentModuleService,
Logger,
ProviderWebhookPayload,
RefundPaymentInput,
RefundPaymentOutput,
RetrievePaymentInput,
RetrievePaymentOutput,
UpdatePaymentInput,
UpdatePaymentOutput,
WebhookActionResult,
} from "@medusajs/framework/types"
import type { GoblinPayInvoice, GoblinPayOptions } from "./types"
class GoblinPayProviderService extends AbstractPaymentProvider<GoblinPayOptions> {
static identifier = "goblinpay"
protected readonly options_: GoblinPayOptions
protected readonly logger_: Logger
protected readonly paymentService_: IPaymentModuleService
constructor(container: Record<string, unknown>, options: GoblinPayOptions) {
super(container as never, options)
this.options_ = options
this.logger_ = container[ContainerRegistrationKeys.LOGGER] as Logger
this.paymentService_ = container[Modules.PAYMENT] as IPaymentModuleService
if (!options?.baseUrl || !options?.apiToken || !options?.webhookSecret) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"GoblinPay provider requires baseUrl, apiToken, and webhookSecret options"
)
}
}
private get base(): string {
return this.options_.baseUrl.replace(/\/+$/, "")
}
/** Call the GoblinPay REST API with the bearer token. */
private async request<T>(
method: "GET" | "POST",
path: string,
body?: unknown
): Promise<T> {
const res = await fetch(`${this.base}${path}`, {
method,
headers: {
Accept: "application/json",
Authorization: `Bearer ${this.options_.apiToken}`,
...(body ? { "Content-Type": "application/json" } : {}),
},
body: body ? JSON.stringify(body) : undefined,
})
const text = await res.text()
const json = text ? JSON.parse(text) : {}
if (!res.ok) {
const err =
(json && (json.error as string)) || `GoblinPay HTTP ${res.status}`
throw new MedusaError(MedusaError.Types.UNEXPECTED_STATE, err)
}
return json as T
}
/** Map a GoblinPay invoice status to a Medusa payment session status. */
private static mapStatus(
status: string
): "captured" | "canceled" | "pending" {
switch (status) {
case "paid":
return "captured"
case "expired":
return "canceled"
default:
return "pending"
}
}
async initiatePayment(
input: InitiatePaymentInput
): Promise<InitiatePaymentOutput> {
const sessionId = input.context?.idempotency_key
if (!sessionId) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Idempotency key (payment session id) is required to initiate payment"
)
}
// GoblinPay prices the fiat order into Grin via its own oracle. The order's
// session id is the order_ref, so the signed webhook echoes it back to us.
const invoice = await this.request<GoblinPayInvoice>("POST", "/invoice", {
order_ref: sessionId,
amount_fiat: input.amount.toString(),
currency: input.currency_code,
memo: `Medusa order ${sessionId}`,
...(this.options_.matchMode ? { match_mode: this.options_.matchMode } : {}),
...(this.options_.expirySecs ? { expiry_secs: this.options_.expirySecs } : {}),
})
return {
id: sessionId,
data: { ...input.data, goblinpay: invoice },
}
}
/** Re-read the current invoice from GoblinPay using the stored invoice_id. */
private async fetchInvoice(
data: Record<string, unknown> | undefined
): Promise<GoblinPayInvoice> {
const stored = (data?.goblinpay ?? {}) as GoblinPayInvoice
if (!stored.invoice_id) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"No GoblinPay invoice_id on the payment session"
)
}
return this.request<GoblinPayInvoice>(
"GET",
`/invoice/${encodeURIComponent(stored.invoice_id)}`
)
}
async authorizePayment(
input: AuthorizePaymentInput
): Promise<AuthorizePaymentOutput> {
const invoice = await this.fetchInvoice(input.data)
return {
status: GoblinPayProviderService.mapStatus(invoice.status),
data: { ...input.data, goblinpay: invoice },
}
}
async getPaymentStatus(
input: GetPaymentStatusInput
): Promise<GetPaymentStatusOutput> {
const invoice = await this.fetchInvoice(input.data)
return {
status: GoblinPayProviderService.mapStatus(invoice.status),
data: { ...input.data, goblinpay: invoice },
}
}
async capturePayment(
input: CapturePaymentInput
): Promise<CapturePaymentOutput> {
// GoblinPay is receive-only: once the payment is received the funds are
// already in the merchant wallet, so capture is a no-op acknowledgement.
return { data: input.data ?? {} }
}
async cancelPayment(
input: CancelPaymentInput
): Promise<CancelPaymentOutput> {
// Nothing to cancel server-side; an unpaid GoblinPay invoice simply expires.
return { data: input.data ?? {} }
}
async deletePayment(
input: DeletePaymentInput
): Promise<DeletePaymentOutput> {
return { data: input.data ?? {} }
}
async refundPayment(
_input: RefundPaymentInput
): Promise<RefundPaymentOutput> {
// Receive-only: GoblinPay never sends Grin, so refunds cannot be automated.
// A refund is a manual, out-of-band Grin send by the merchant.
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"GoblinPay is receive-only; refunds must be issued manually by the merchant (out-of-band Grin send)."
)
}
async retrievePayment(
input: RetrievePaymentInput
): Promise<RetrievePaymentOutput> {
const invoice = await this.fetchInvoice(input.data)
return { data: { ...input.data, goblinpay: invoice } }
}
async updatePayment(
input: UpdatePaymentInput
): Promise<UpdatePaymentOutput> {
return { data: input.data ?? {} }
}
/**
* Verify the HMAC-SHA256 over the EXACT raw body, constant-time. Mirrors the
* WooCommerce connector and GoblinPay's webhook contract:
* X-GoblinPay-Signature: sha256=<hex(HMAC-SHA256(secret, raw_body))>
*/
private verifySignature(payload: ProviderWebhookPayload["payload"]): boolean {
const raw = payload.rawData
if (!raw) {
return false
}
const provided = (payload.headers?.["x-goblinpay-signature"] as string) ?? ""
const expected =
"sha256=" +
crypto
.createHmac("sha256", this.options_.webhookSecret)
.update(raw as string | Buffer)
.digest("hex")
const a = Buffer.from(provided, "utf8")
const b = Buffer.from(expected, "utf8")
return a.length === b.length && crypto.timingSafeEqual(a, b)
}
async getWebhookActionAndData(
payload: ProviderWebhookPayload["payload"]
): Promise<WebhookActionResult> {
if (!this.verifySignature(payload)) {
this.logger_.warn("goblinpay: webhook signature mismatch")
return { action: PaymentActions.FAILED }
}
const data = (payload.data ?? {}) as {
event_type?: string
order_ref?: string
}
const sessionId = data.order_ref
if (!sessionId) {
return { action: PaymentActions.NOT_SUPPORTED }
}
// payment.received (funds in hand, S2 returned) and payment.confirmed
// (on-chain) both mean paid for a receive-only till: flip to captured. The
// capture is idempotent, so a later confirmation after a received event is a
// no-op. Capture the session's own (store-currency) amount.
if (
data.event_type === "payment.received" ||
data.event_type === "payment.confirmed"
) {
const amount = await this.sessionAmount(sessionId)
return {
action: PaymentActions.SUCCESSFUL,
data: { session_id: sessionId, amount },
}
}
return { action: PaymentActions.NOT_SUPPORTED }
}
/** The payment session's authorized amount, for the webhook capture. */
private async sessionAmount(sessionId: string): Promise<number> {
try {
const session = await this.paymentService_.retrievePaymentSession(sessionId)
return Number(session.amount)
} catch (e) {
this.logger_.warn(
`goblinpay: could not read session ${sessionId} amount: ${
(e as Error).message
}`
)
return 0
}
}
}
export default GoblinPayProviderService
+40
View File
@@ -0,0 +1,40 @@
/**
* Options + wire types for the GoblinPay Medusa v2 payment provider.
*
* GoblinPay is a receive-only Grin payment server. This provider is a thin
* client: it calls GoblinPay's REST API to create an invoice and reads back the
* checkout details, then flips the Medusa payment on a signed webhook. All Grin
* and Nostr work happens in GoblinPay; Medusa only speaks HTTP to it.
*/
/** Provider options, set per-provider in `medusa-config.ts`. */
export interface GoblinPayOptions {
/** Base URL of your GoblinPay server, no trailing slash (e.g. https://pay.example). */
baseUrl: string
/** Bearer token for the create-invoice API (`GP_API_TOKEN` on the server). */
apiToken: string
/** Shared HMAC secret for webhook verification (`GP_WEBHOOK_SECRET`). */
webhookSecret: string
/**
* How GoblinPay matches an incoming payment to this order. `derived`
* (per-invoice identity, recommended) gives each order its own QR. Omit to
* use the server default.
*/
matchMode?: "memo" | "derived" | "amount"
/** Optional invoice expiry in seconds from creation. */
expirySecs?: number
}
/** The subset of GoblinPay's `/invoice` response this provider stores/uses. */
export interface GoblinPayInvoice {
invoice_id: string
token?: string
pay_url: string
nprofile?: string
npub?: string
qr_svg?: string
amount?: string
/** GoblinPay invoice lifecycle: `open` | `paid` | `expired`. */
status: string
order_ref?: string
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2021",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2021"],
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"],
"exclude": ["dist", "node_modules"]
}
+2 -2
View File
@@ -2,8 +2,8 @@
Accept Grin (GRIN / MimbleWimble) payments in WooCommerce 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
`nprofile` QR code. The payment travels as a gift-wrapped slatepack over Nostr.
GoblinPay receives it, returns the reply
slatepack to the payer, watches the chain to confirm, and notifies WooCommerce.
This plugin is a thin client. All of the Grin and Nostr work happens in
@@ -2,7 +2,7 @@
/**
* Plugin Name: GoblinPay for WooCommerce
* Plugin URI: https://git.us-ea.st/GRIN/GoblinPay
* Description: Accept Grin (GRIN / MimbleWimble) payments in WooCommerce through a self-hosted GoblinPay server. The customer pays from their Goblin Wallet by scanning an nprofile QR; payment travels as a gift-wrapped slatepack over Nostr (optionally over the Nym mixnet). Works with the classic and the Blocks checkout. HPOS-compatible.
* Description: Accept Grin (GRIN / MimbleWimble) payments in WooCommerce through a self-hosted GoblinPay server. The customer pays from their Goblin Wallet by scanning an nprofile QR; payment travels as a gift-wrapped slatepack over Nostr. Works with the classic and the Blocks checkout. HPOS-compatible.
* Version: 1.0.0
* Author: GoblinPay
* License: GPL-2.0-or-later
+32 -18
View File
@@ -29,6 +29,14 @@ pub const DEFAULT_DATA_DIR: &str = "./gp-data";
/// Nostr gift-wrap layer in gp-nostr).
pub const DEFAULT_NODE_URL: &str = "https://main.gri.mw";
/// Default URL of the bundled relay in `bundled` relay mode: the co-located
/// relay GoblinPay ships in `deploy/docker-compose.yml` (a vendored
/// nostr-rs-relay), so a merchant needs no third-party relay. Override with
/// `GP_BUNDLED_RELAY_URL`. In a public deployment set this to the relay's
/// publicly reachable `wss://<domain>` URL, because the same value is both
/// dialed by the server AND advertised to payers in the checkout `nprofile`.
pub const DEFAULT_BUNDLED_RELAY: &str = "ws://127.0.0.1:7777";
/// TLS mode for the HTTP server.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
@@ -53,7 +61,11 @@ pub enum Chain {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RelayMode {
/// GoblinPay supervises its own relay (default; see module design 3).
/// GoblinPay talks to its own co-located relay (default): the bundled
/// nostr-rs-relay from `deploy/docker-compose.yml`, reached at
/// `GP_BUNDLED_RELAY_URL`. That relay is what the checkout `nprofile`
/// advertises, so a merchant needs no third-party relay. Any `GP_RELAYS`
/// are added alongside it for redundancy.
Bundled,
/// Only external relays from `GP_RELAYS` are used.
External,
@@ -130,9 +142,10 @@ pub struct Config {
pub relay_mode: RelayMode,
/// External relays (`GP_RELAYS`, comma separated).
pub relays: Vec<String>,
/// Route Nostr traffic over the Nym mixnet (`GP_NYM`: `on` or `off`,
/// default on; clearnet is a debugging escape hatch only).
pub nym: bool,
/// 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,
/// Run the Nostr ingest service (`GP_INGEST`: `on` or `off`, default on).
/// When on, the wallet and identity secrets are required at boot.
pub ingest: bool,
@@ -235,7 +248,7 @@ impl Default for Config {
chain: Chain::Mainnet,
relay_mode: RelayMode::Bundled,
relays: Vec::new(),
nym: true,
bundled_relay_url: DEFAULT_BUNDLED_RELAY.into(),
ingest: true,
checkout_nostr: true,
checkout_slatepack: true,
@@ -320,12 +333,10 @@ impl Config {
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect::<Vec<_>>();
let nym = match get("GP_NYM").as_deref().unwrap_or("on") {
"on" => true,
"off" => false,
other => return Err(format!("GP_NYM must be `on` or `off` (got `{other}`)")),
};
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 ingest = match get("GP_INGEST").as_deref().unwrap_or("on") {
"on" => true,
@@ -406,7 +417,7 @@ impl Config {
chain,
relay_mode,
relays,
nym,
bundled_relay_url,
ingest,
checkout_nostr,
checkout_slatepack,
@@ -473,6 +484,9 @@ impl Config {
if self.relay_mode == RelayMode::External && self.relays.is_empty() {
return Err("GP_RELAY_MODE=external requires GP_RELAYS".into());
}
if self.relay_mode == RelayMode::Bundled && self.bundled_relay_url.trim().is_empty() {
return Err("GP_RELAY_MODE=bundled requires a non-empty GP_BUNDLED_RELAY_URL".into());
}
if self.nsec.is_some() && self.ncryptsec.is_some() {
return Err("set only one of GP_NSEC and GP_NCRYPTSEC".into());
}
@@ -507,7 +521,8 @@ impl Config {
let set = |o: bool| if o { "set" } else { "unset" };
format!(
"bind={} tls={} db={} data_dir={} node={} chain={:?} relay_mode={:?} \
relays={:?} nym={} ingest={} checkout_methods={} match_mode={:?} mnemonic={} \
relays={:?} bundled_relay={} ingest={} checkout_methods={} match_mode={:?} \
mnemonic={} \
wallet_password={} \
nsec={} ncryptsec={} public_url={} api_token={} admin_token={} webhook_url={} \
webhook_secret={} qr_logo={} merchant_npub={} notify_merchant_dm={} \
@@ -525,7 +540,7 @@ impl Config {
self.chain,
self.relay_mode,
self.relays,
if self.nym { "on" } else { "off" },
self.bundled_relay_url,
if self.ingest { "on" } else { "off" },
self.checkout_methods_str(),
self.match_mode,
@@ -657,7 +672,7 @@ mod tests {
assert_eq!(cfg.chain, Chain::Mainnet);
assert_eq!(cfg.relay_mode, RelayMode::Bundled);
assert!(cfg.relays.is_empty());
assert!(cfg.nym);
assert_eq!(cfg.bundled_relay_url, DEFAULT_BUNDLED_RELAY);
assert!(cfg.ingest);
assert_eq!(cfg.match_mode, MatchMode::Memo);
assert!(cfg.mnemonic.is_none());
@@ -676,7 +691,7 @@ mod tests {
("GP_CHAIN", "testnet"),
("GP_RELAY_MODE", "external"),
("GP_RELAYS", "wss://relay.example, wss://relay2.example ,"),
("GP_NYM", "off"),
("GP_BUNDLED_RELAY_URL", "wss://relay.mystore.example"),
("GP_INGEST", "off"),
("GP_MATCH_MODE", "derived"),
])
@@ -691,7 +706,7 @@ mod tests {
cfg.relays,
vec!["wss://relay.example", "wss://relay2.example"]
);
assert!(!cfg.nym);
assert_eq!(cfg.bundled_relay_url, "wss://relay.mystore.example");
assert!(!cfg.ingest);
assert_eq!(cfg.match_mode, MatchMode::Derived);
}
@@ -750,7 +765,6 @@ mod tests {
assert!(load(&[("GP_TLS", "acme")]).is_err());
assert!(load(&[("GP_CHAIN", "floonet")]).is_err());
assert!(load(&[("GP_RELAY_MODE", "both")]).is_err());
assert!(load(&[("GP_NYM", "true")]).is_err());
assert!(load(&[("GP_INGEST", "yes")]).is_err());
assert!(load(&[("GP_MATCH_MODE", "exact")]).is_err());
}
+3 -3
View File
@@ -1,8 +1,8 @@
//! The store-connector seam.
//!
//! Every store integration (the built-in generic REST connector, the
//! WooCommerce and Medusa plugins that arrive in a later milestone, and the
//! future pop-up Nostr store) drives GoblinPay through one uniform contract:
//! Every store integration (the built-in generic REST connector, the shipped
//! WooCommerce and Medusa plugins under `connectors/`, and the future pop-up
//! Nostr store) drives GoblinPay through one uniform contract:
//! a create-invoice request in, a hosted checkout + signed webhook out. This
//! trait keeps that mapping in one place so the core never grows per-store
//! branches: a connector only decides how a store's order becomes invoice
+1 -15
View File
@@ -1,6 +1,6 @@
[package]
name = "gp-nostr"
description = "Nostr transport and secure handoff for GoblinPay (identity, gift wrap, ingest, Nym)"
description = "Nostr transport and secure handoff for GoblinPay (identity, gift wrap, ingest)"
version.workspace = true
edition.workspace = true
license.workspace = true
@@ -30,24 +30,10 @@ nip44 = { path = "../../../nip44" }
# Nostr identity).
secp256k1 = { version = "0.31", features = ["global-context", "hashes"] }
# Nym mixnet, linked IN-PROCESS via smolmix (TCP/UDP tunnel over the mixnet
# with an AUTO-SELECTED IPR exit; no sidecar, no SOCKS5 loopback, no
# single-exit SPOF). Path dep into the local nym checkout, PINNED at rev
# f6ed17d949cc19fee0fb51db3cb65771fd510d5b ("http-api-client: preconfigured
# webpki roots on Android" — the Android patch is irrelevant server-side, but
# the pin is what Goblin G14 validated; do not float it silently).
smolmix = { path = "../../../nym/smolmix/core" }
# mix-dns wire codec. Already in the dependency graph via nym-http-api-client
# (smolmix -> nym-sdk), so we reuse it instead of vendoring a DNS
# encode/parse (same justification as Goblin).
hickory-proto = { version = "0.26", default-features = false, features = ["std"] }
tokio = { workspace = true, features = ["rt-multi-thread", "time", "macros", "sync"] }
log = "0.4"
serde = { workspace = true }
serde_json = { workspace = true }
# mix-dns transaction ids.
rand = "0.9"
[dev-dependencies]
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
+4 -7
View File
@@ -1,5 +1,5 @@
//! Nostr transport and secure handoff for GoblinPay, mirroring Goblin's
//! proven `src/nostr` + `src/nym` stack adapted to a headless daemon:
//! proven `src/nostr` stack adapted to a headless daemon:
//!
//! - [`identity`]: a random standalone nsec (or an imported one), NIP-49
//! encrypted at rest, deliberately independent of the Grin seed (the
@@ -13,18 +13,15 @@
//! - [`ingest`]: the guarded ingest pipeline (dedupe, rate limits, the pure
//! `decide()` policy) handing S1 slatepacks to the wallet and building the
//! S2 reply rumor.
//! - [`service`]: the daemon loop — relay pool over the in-process Nym
//! mixnet, kind-10050 publishing, catch-up + live subscription, reply
//! dispatch, boot-time reconcile.
//! - [`nym`]: the smolmix tunnel, mix-dns and the relay websocket transport,
//! ported from Goblin (G14).
//! - [`service`]: the daemon loop — relay pool (clearnet), kind-10050
//! publishing, catch-up + live subscription, reply dispatch, boot-time
//! reconcile.
//!
//! Privacy: log lines carry short event/key prefixes and hosts only — never
//! armor contents, full URLs, or secrets (Goblin's host-only level).
pub mod identity;
pub mod ingest;
pub mod nym;
pub mod protocol;
pub mod receipt;
pub mod relays;
-235
View File
@@ -1,235 +0,0 @@
//! mix-dns: hostname resolution THROUGH the mixnet (ported from
//! `goblin/src/nym/dns.rs`). `Tunnel::tcp_connect` takes a `SocketAddr`, so
//! DNS is our responsibility — and it MUST ride the tunnel: a clearnet lookup
//! would leak exactly which relays the server contacts, defeating the mixnet.
//! Raw A-record queries go as UDP datagrams over
//! [`smolmix::Tunnel::udp_socket`] to public resolvers addressed BY IP.
//! Responses land in a TTL-respecting in-memory cache. IPv4-only, like the
//! Goblin original.
//!
//! Wire codec: hickory-proto — already in the dependency graph via
//! nym-http-api-client, so no vendored encode/parse is needed.
use std::collections::HashMap;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::sync::{LazyLock, RwLock};
use std::time::{Duration, Instant};
use hickory_proto::op::{Message, MessageType, Query, ResponseCode};
use hickory_proto::rr::{Name, RData, RecordType};
use log::{debug, warn};
use smolmix::Tunnel;
/// Public resolvers the tunnel queries, by IP (no bootstrap chicken-and-egg):
/// Cloudflare primary, Quad9 fallback. The exit gateway only ever sees a DNS
/// packet to a public resolver, never who asked.
const RESOLVERS: [SocketAddr; 2] = [
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)), 53),
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(9, 9, 9, 9)), 53),
];
/// Per-resolver answer wait. The mixnet adds multi-second round trips.
const QUERY_TIMEOUT: Duration = Duration::from_secs(15);
/// TTL floor/ceiling for the cache: don't hammer resolvers for zero-TTL
/// records, don't trust a stale record for more than an hour.
const TTL_FLOOR_SECS: u32 = 60;
const TTL_CEILING_SECS: u32 = 3600;
/// Cached answer for one host: addresses plus their expiry.
type CachedAnswer = (Vec<Ipv4Addr>, Instant);
/// host → cached answer.
static CACHE: LazyLock<RwLock<HashMap<String, CachedAnswer>>> =
LazyLock::new(|| RwLock::new(HashMap::new()));
/// Resolve `host` to a socket address for `tcp_connect`, entirely over the
/// mixnet. IP-literal hosts skip DNS; cached answers are honored until their
/// (clamped) TTL lapses. Returns `None` when every resolver fails.
pub async fn resolve(tunnel: &Tunnel, host: &str, port: u16) -> Option<SocketAddr> {
// IP literals (v4 or v6) need no lookup at all.
if let Ok(ip) = host.parse::<IpAddr>() {
return Some(SocketAddr::new(ip, port));
}
if let Some(ip) = cached(host) {
return Some(SocketAddr::new(IpAddr::V4(ip), port));
}
for resolver in RESOLVERS {
match query_a(tunnel, host, resolver).await {
Some((ips, ttl)) if !ips.is_empty() => {
let ttl = ttl.clamp(TTL_FLOOR_SECS, TTL_CEILING_SECS);
debug!(
"mix-dns: resolved {host} -> {} (ttl {ttl}s, via {resolver}, {} record(s))",
ips[0],
ips.len()
);
let expiry = Instant::now() + Duration::from_secs(ttl as u64);
CACHE
.write()
.expect("dns cache lock")
.insert(host.to_string(), (ips.clone(), expiry));
return Some(SocketAddr::new(IpAddr::V4(ips[0]), port));
}
_ => {
warn!("mix-dns: no answer for {host} from {resolver}, trying next resolver");
}
}
}
warn!("mix-dns: resolution failed for {host} (all resolvers)");
None
}
/// A cached, unexpired address for `host`.
fn cached(host: &str) -> Option<Ipv4Addr> {
let cache = CACHE.read().expect("dns cache lock");
let (ips, expiry) = cache.get(host)?;
if Instant::now() < *expiry {
ips.first().copied()
} else {
None
}
}
/// Cheap end-to-end liveness probe: one uncached A query for a stable name
/// against the primary resolver. Used by the tunnel keepalive/watchdog — it
/// exercises the full path (mixnet → IPR exit → internet and back) and, as a
/// side effect, keeps the gateway connection and IPR session from idling out.
pub async fn probe(tunnel: &Tunnel) -> bool {
query_a(tunnel, "example.com", RESOLVERS[0]).await.is_some()
}
/// One A query/response round trip over the tunnel against `resolver`.
async fn query_a(
tunnel: &Tunnel,
host: &str,
resolver: SocketAddr,
) -> Option<(Vec<Ipv4Addr>, u32)> {
let udp = match tunnel.udp_socket().await {
Ok(s) => s,
Err(e) => {
warn!("mix-dns: udp socket failed: {e}");
return None;
}
};
let id = rand::random::<u16>();
let query = encode_query(id, host)?;
if let Err(e) = udp.send_to(&query, resolver).await {
warn!("mix-dns: send to {resolver} failed: {e}");
return None;
}
let mut buf = vec![0u8; 1500];
let (n, from) = match tokio::time::timeout(QUERY_TIMEOUT, udp.recv_from(&mut buf)).await {
Ok(Ok(r)) => r,
Ok(Err(e)) => {
warn!("mix-dns: recv from {resolver} failed: {e}");
return None;
}
Err(_) => {
warn!("mix-dns: query to {resolver} timed out");
return None;
}
};
if from != resolver {
warn!("mix-dns: dropping answer from unexpected source {from}");
return None;
}
parse_response(id, &buf[..n])
}
/// Encode a recursive A query for `host` with transaction id `id`.
fn encode_query(id: u16, host: &str) -> Option<Vec<u8>> {
let name = Name::from_ascii(host).ok()?;
let mut msg = Message::query();
msg.metadata.id = id;
msg.metadata.recursion_desired = true;
msg.add_query(Query::query(name, RecordType::A));
msg.to_vec().ok()
}
/// Parse a response to transaction `id`: all A records in the answer section
/// plus the smallest TTL among them. `None` on id mismatch, non-response,
/// error rcode or no A records (CNAMEs and other types are skipped).
fn parse_response(id: u16, raw: &[u8]) -> Option<(Vec<Ipv4Addr>, u32)> {
let msg = Message::from_vec(raw).ok()?;
if msg.metadata.id != id
|| msg.metadata.message_type != MessageType::Response
|| msg.metadata.response_code != ResponseCode::NoError
{
return None;
}
let mut ips = Vec::new();
let mut ttl = u32::MAX;
for record in &msg.answers {
if let RData::A(a) = record.data {
ips.push(a.0);
ttl = ttl.min(record.ttl);
}
}
if ips.is_empty() {
None
} else {
Some((ips, ttl))
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Query for `example.com` A/IN, id 0x1234, RD set — the canonical fixture
/// (same bytes smolmix's own docs use).
const QUERY_FIXTURE: &[u8] = b"\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00\
\x07example\x03com\x00\x00\x01\x00\x01";
/// Response to `QUERY_FIXTURE`: flags 0x8180 (QR, RD, RA, NOERROR), one
/// question, two answers — a CNAME (ttl 3600, rdata = compression pointer
/// back to the qname) that must be skipped, then an A record for
/// 93.184.216.34 with ttl 300.
const RESPONSE_FIXTURE: &[u8] = b"\x12\x34\x81\x80\x00\x01\x00\x02\x00\x00\x00\x00\
\x07example\x03com\x00\x00\x01\x00\x01\
\xc0\x0c\x00\x05\x00\x01\x00\x00\x0e\x10\x00\x02\xc0\x0c\
\xc0\x0c\x00\x01\x00\x01\x00\x00\x01\x2c\x00\x04\x5d\xb8\xd8\x22";
#[test]
fn encode_query_matches_fixture() {
let bytes = encode_query(0x1234, "example.com").unwrap();
assert_eq!(bytes, QUERY_FIXTURE);
}
#[test]
fn parse_response_extracts_a_records_and_min_ttl() {
let (ips, ttl) = parse_response(0x1234, RESPONSE_FIXTURE).unwrap();
assert_eq!(ips, vec![Ipv4Addr::new(93, 184, 216, 34)]);
// The CNAME's larger ttl (3600) must not win: only A records count.
assert_eq!(ttl, 300);
}
#[test]
fn parse_response_rejects_wrong_id() {
assert!(parse_response(0x5678, RESPONSE_FIXTURE).is_none());
}
#[test]
fn parse_response_rejects_query_and_garbage() {
// A query (QR=0) is not an answer.
assert!(parse_response(0x1234, QUERY_FIXTURE).is_none());
// Truncated/garbage input parses to nothing.
assert!(parse_response(0x1234, &RESPONSE_FIXTURE[..7]).is_none());
assert!(parse_response(0x1234, b"\x00").is_none());
}
#[test]
fn parse_response_rejects_error_rcode() {
// Same fixture with rcode NXDOMAIN (flags 0x8183) and no answers.
let nx: &[u8] = b"\x12\x34\x81\x83\x00\x01\x00\x00\x00\x00\x00\x00\
\x07example\x03com\x00\x00\x01\x00\x01";
assert!(parse_response(0x1234, nx).is_none());
}
#[test]
fn ttl_clamp_bounds() {
assert_eq!(5u32.clamp(TTL_FLOOR_SECS, TTL_CEILING_SECS), 60);
assert_eq!(999_999u32.clamp(TTL_FLOOR_SECS, TTL_CEILING_SECS), 3600);
assert_eq!(300u32.clamp(TTL_FLOOR_SECS, TTL_CEILING_SECS), 300);
}
}
-22
View File
@@ -1,22 +0,0 @@
//! Nym mixnet transport, ported from Goblin's proven `src/nym` (G14).
//! Every relay websocket rides one in-process smolmix
//! [`Tunnel`](smolmix::Tunnel) over the 5-hop mixnet to an auto-selected IPR
//! exit. Hostnames resolve through the same tunnel ([`dns`], mix-dns), so
//! neither payload nor destination ever touches the clearnet. For a payment
//! server this is default-on: returning the S2 means outbound connections to
//! the payer's relays, which over clearnet would link the merchant identity
//! to a host IP.
//!
//! This tunnel carries ONLY the Nostr gift-wrap layer. The milestone-4
//! node-confirmation reads (wallet -> node get_kernel/get_tip) deliberately do
//! NOT ride it: node traffic is a server concern that goes DIRECT over normal
//! HTTP (owner ruling), exactly like Goblin's own wallet -> node reads never
//! ride the mixnet. Those reads live in `gp-wallet`, which has no Nym linkage,
//! so the direct path is structural. Do not route node reads through here.
pub mod dns;
pub mod nymproc;
pub mod transport;
pub use nymproc::{is_ready, warm_up};
pub use transport::NymWebSocketTransport;
-192
View File
@@ -1,192 +0,0 @@
//! In-process Nym mixnet tunnel (ported from `goblin/src/nym/nymproc.rs`).
//! smolmix is linked directly — no sidecar subprocess, no loopback SOCKS5
//! seam. One process-lifetime [`Tunnel`] carries every relay websocket as raw
//! TCP over the mixnet to an AUTO-SELECTED IPR exit gateway: losing any one
//! exit just re-selects, so there is no single-exit SPOF. Hostnames are
//! resolved through the same tunnel by [`super::dns`] (mix-dns); nothing goes
//! clearnet.
//!
//! Same liveness posture as Goblin: a fresh tunnel must pass an end-to-end
//! probe before it is published (some exits accept the IPR handshake but
//! never deliver data), and a keepalive watchdog rebuilds on sustained
//! failure.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::RwLock;
use std::thread;
use std::time::{Duration, Instant};
use log::{error, info, warn};
use smolmix::Tunnel;
/// The shared process-lifetime tunnel, set once the mixnet bootstrap finishes.
static TUNNEL: RwLock<Option<Tunnel>> = RwLock::new(None);
/// Set once the tunnel is up (mirrors `TUNNEL`, but cheap to poll).
static MIXNET_READY: AtomicBool = AtomicBool::new(false);
/// Guards the background bootstrap thread so `warm_up()` is idempotent.
static STARTED: AtomicBool = AtomicBool::new(false);
/// Pre-warm the mixnet tunnel in the background so relays are ready by first
/// use. Idempotent — later calls (including the lazy-init path in
/// [`wait_for_tunnel`]) are no-ops.
pub fn warm_up() {
if STARTED.swap(true, Ordering::SeqCst) {
return;
}
thread::spawn(run_tunnel);
}
/// Whether the mixnet tunnel is warm. Cheap and cached. Distinct from a
/// relay being connected.
pub fn is_ready() -> bool {
MIXNET_READY.load(Ordering::Relaxed)
}
/// The shared tunnel, if it is up. Cloning is a cheap `Arc` bump.
pub fn tunnel() -> Option<Tunnel> {
TUNNEL.read().expect("tunnel lock").clone()
}
/// Wait until the shared tunnel is up, starting the bootstrap if nothing has
/// yet (lazy init on first use). Returns `None` once `timeout` lapses.
pub async fn wait_for_tunnel(timeout: Duration) -> Option<Tunnel> {
warm_up();
let deadline = Instant::now() + timeout;
loop {
if let Some(t) = tunnel() {
return Some(t);
}
if Instant::now() >= deadline {
return None;
}
tokio::time::sleep(Duration::from_millis(250)).await;
}
}
/// Build the mixnet tunnel on a dedicated multi-thread tokio runtime, then
/// keep the tunnel (its bridge + smoltcp reactor tasks) AND the runtime alive
/// for the lifetime of the process. Retries with backoff on bootstrap failure
/// (a dead gateway pick just re-selects on the next attempt). Blocks the
/// calling thread.
fn run_tunnel() {
let rt = match tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => {
error!("nym: could not build mixnet runtime: {e}");
return;
}
};
rt.block_on(async move {
let mut delay = Duration::from_secs(5);
loop {
let started = Instant::now();
info!("nym: starting in-process mixnet tunnel (smolmix, auto-selected exit)");
match build_tunnel().await {
Ok(tunnel) => {
// Gate readiness on one end-to-end probe: some exits accept
// the IPR handshake but never deliver data (seen live);
// publishing such a tunnel would blackhole every consumer
// until the watchdog caught it minutes later. Re-select
// immediately instead.
if !probe_fresh(&tunnel).await {
error!(
"nym: fresh tunnel failed its liveness probe (dead exit); re-selecting"
);
tunnel.shutdown().await;
delay = (delay * 2).min(Duration::from_secs(60));
continue;
}
info!(
"nym: tunnel ready in ~{}ms (allocated ip {}, probe ok)",
started.elapsed().as_millis(),
tunnel.allocated_ips().ipv4
);
*TUNNEL.write().expect("tunnel lock") = Some(tunnel.clone());
MIXNET_READY.store(true, Ordering::Relaxed);
delay = Duration::from_secs(5);
// Hold the tunnel warm for the whole process lifetime with
// a cheap keepalive: the probe keeps the gateway
// connection + IPR session from idling out while the relay
// subscription rides it — and verifies the path end to
// end. When the tunnel dies anyway (exit gateway gone),
// rebuild with a freshly auto-selected exit: losing any
// one exit must never take the server down.
watch_tunnel(&tunnel).await;
error!("nym: tunnel unresponsive; rebuilding with a fresh exit");
MIXNET_READY.store(false, Ordering::Relaxed);
*TUNNEL.write().expect("tunnel lock") = None;
tunnel.shutdown().await;
}
Err(e) => {
error!(
"nym: mixnet tunnel failed to start: {e}; retrying in {}s",
delay.as_secs()
);
tokio::time::sleep(delay).await;
delay = (delay * 2).min(Duration::from_secs(60));
}
}
}
});
}
/// Two probe attempts before rejecting a fresh tunnel: mixnet UDP does lose
/// the odd datagram, and one lost packet must not condemn a healthy exit.
async fn probe_fresh(tunnel: &Tunnel) -> bool {
for _ in 0..2 {
if super::dns::probe(tunnel).await {
return true;
}
}
false
}
/// Keepalive period and the consecutive probe failures that declare death.
const KEEPALIVE_PERIOD: Duration = Duration::from_secs(60);
const KEEPALIVE_MAX_FAILS: u32 = 3;
/// Probe the tunnel every [`KEEPALIVE_PERIOD`] (one tiny DNS round trip over
/// the mixnet); returns once [`KEEPALIVE_MAX_FAILS`] probes fail in a row.
async fn watch_tunnel(tunnel: &Tunnel) {
let mut fails = 0u32;
loop {
tokio::time::sleep(KEEPALIVE_PERIOD).await;
if super::dns::probe(tunnel).await {
fails = 0;
} else {
fails += 1;
warn!("nym: tunnel keepalive probe failed ({fails}/{KEEPALIVE_MAX_FAILS})");
if fails >= KEEPALIVE_MAX_FAILS {
return;
}
}
}
}
/// Build the tunnel with an auto-selected IPR exit. Ephemeral in-memory keys
/// (a fresh mixnet identity per run — no sqlite, no persisted gateway).
///
/// NEVER pin an exit here in shipped code: pinning turns off auto-selection
/// and re-introduces the single-exit SPOF. `GP_NYM_IPR` exists for DEBUGGING
/// only and defaults to unset.
async fn build_tunnel() -> Result<Tunnel, smolmix::SmolmixError> {
let mut builder = Tunnel::builder();
if let Ok(pin) = std::env::var("GP_NYM_IPR") {
if !pin.is_empty() {
match pin.parse() {
Ok(recipient) => {
warn!("nym: GP_NYM_IPR set — pinning IPR exit (debug only, SPOF!)");
builder = builder.ipr_address(recipient);
}
Err(e) => warn!("nym: ignoring invalid GP_NYM_IPR: {e}"),
}
}
}
builder.build().await
}
-150
View File
@@ -1,150 +0,0 @@
//! WebSocket transport for the Nostr relay pool routed through the
//! in-process smolmix tunnel (ported from `goblin/src/nym/transport.rs`), so
//! every relay connection traverses the 5-hop Nym mixnet. The relay host is
//! resolved through the tunnel (mix-dns — the destination is never resolved
//! on the clear), the TCP stream is opened via `tunnel.tcp_connect`, then the
//! TLS (rustls, webpki roots) + websocket handshake runs over that tunneled
//! stream. Nothing goes clearnet.
use std::fmt;
use std::pin::Pin;
use std::task::{Context, Poll};
use std::time::Duration;
use async_wsocket::futures_util::{Sink, SinkExt, StreamExt};
use async_wsocket::{ConnectionMode, Message};
use nostr_relay_pool::transport::error::TransportError;
use nostr_relay_pool::transport::websocket::{WebSocketSink, WebSocketStream, WebSocketTransport};
use nostr_sdk::util::BoxedFuture;
use nostr_sdk::Url;
use tokio_tungstenite::tungstenite::Message as TgMessage;
/// Error type for transport failures outside the websocket layer.
#[derive(Debug)]
struct NymTransportError(String);
impl fmt::Display for NymTransportError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl std::error::Error for NymTransportError {}
fn terr(msg: impl Into<String>) -> TransportError {
TransportError::backend(NymTransportError(msg.into()))
}
/// Nostr websocket transport over the in-process Nym mixnet tunnel.
#[derive(Debug, Clone, Copy, Default)]
pub struct NymWebSocketTransport;
impl WebSocketTransport for NymWebSocketTransport {
fn support_ping(&self) -> bool {
true
}
fn connect<'a>(
&'a self,
url: &'a Url,
_mode: &'a ConnectionMode,
timeout: Duration,
) -> BoxedFuture<'a, Result<(WebSocketSink, WebSocketStream), TransportError>> {
Box::pin(async move {
let host = url
.host_str()
.ok_or_else(|| terr("relay url has no host"))?
.to_string();
let port = url.port().unwrap_or(match url.scheme() {
"ws" => 80,
_ => 443,
});
// The shared mixnet tunnel (lazy-started at server boot).
let tunnel = super::nymproc::wait_for_tunnel(timeout)
.await
.ok_or_else(|| terr("nym tunnel not ready"))?;
// Resolve the relay host through the mixnet (mix-dns), so no
// clearnet DNS leak, then dial through the same tunnel.
let addr = tokio::time::timeout(timeout, super::dns::resolve(&tunnel, &host, port))
.await
.map_err(|_| terr("mix-dns resolve timeout"))?
.ok_or_else(|| terr(format!("mix-dns could not resolve relay host {host}")))?;
let stream = tokio::time::timeout(timeout, tunnel.tcp_connect(addr))
.await
.map_err(|_| terr("nym tunnel connect timeout"))?
.map_err(|e| terr(format!("nym tunnel connect failed: {e}")))?;
// Perform TLS (for wss) + websocket handshake over the mixnet
// stream (rustls webpki roots; the ring provider is installed
// once at gp-server startup — the Build 65/66 rule).
let (ws, _response) = tokio::time::timeout(
timeout,
tokio_tungstenite::client_async_tls(url.as_str(), stream),
)
.await
.map_err(|_| terr("websocket handshake timeout"))?
.map_err(|e| terr(format!("websocket handshake failed: {e}")))?;
let (tx, rx) = ws.split();
let sink: WebSocketSink = Box::new(NymSink(tx)) as WebSocketSink;
let stream: WebSocketStream = Box::pin(rx.filter_map(|msg| async move {
match msg {
Ok(tg) => tg_to_message(tg).map(Ok),
Err(e) => Some(Err(TransportError::backend(e))),
}
})) as WebSocketStream;
Ok((sink, stream))
})
}
}
/// Convert a tungstenite message into an async-wsocket pool message.
/// Returns `None` for raw frames (never surfaced while reading).
fn tg_to_message(msg: TgMessage) -> Option<Message> {
match msg {
TgMessage::Text(text) => Some(Message::Text(text.to_string())),
TgMessage::Binary(data) => Some(Message::Binary(data.to_vec())),
TgMessage::Ping(data) => Some(Message::Ping(data.to_vec())),
TgMessage::Pong(data) => Some(Message::Pong(data.to_vec())),
TgMessage::Close(_) => Some(Message::Close(None)),
TgMessage::Frame(_) => None,
}
}
/// Sink adapter converting pool messages into tungstenite messages.
struct NymSink<S>(S);
impl<S> Sink<Message> for NymSink<S>
where
S: Sink<TgMessage, Error = tokio_tungstenite::tungstenite::Error> + Send + Unpin,
{
type Error = TransportError;
fn poll_ready(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Pin::new(&mut self.0)
.poll_ready_unpin(cx)
.map_err(TransportError::backend)
}
fn start_send(mut self: Pin<&mut Self>, item: Message) -> Result<(), Self::Error> {
Pin::new(&mut self.0)
.start_send_unpin(TgMessage::from(item))
.map_err(TransportError::backend)
}
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Pin::new(&mut self.0)
.poll_flush_unpin(cx)
.map_err(TransportError::backend)
}
fn poll_close(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Pin::new(&mut self.0)
.poll_close_unpin(cx)
.map_err(TransportError::backend)
}
}
+75 -18
View File
@@ -1,24 +1,48 @@
//! Default relay set and helpers (mirrors `goblin/src/nostr/relays.rs`).
//! Relay set resolution.
//!
//! GoblinPay runs in one of two relay modes (`GP_RELAY_MODE`, see
//! [`gp_core::config::RelayMode`]):
//!
//! - `bundled` (default): GoblinPay talks to its own co-located relay, the
//! nostr-rs-relay shipped as the `relay` service in
//! `deploy/docker-compose.yml`. Its URL is `GP_BUNDLED_RELAY_URL` (default
//! `ws://127.0.0.1:7777`). Because the resolved set is exactly what the
//! checkout `nprofile` advertises to payers, a merchant needs no third-party
//! relay: the payer's Goblin Wallet is told to deliver the gift-wrapped
//! slatepack to the merchant's own relay. Extra relays listed in `GP_RELAYS`
//! are appended for redundancy (and advertised alongside the bundled one).
//! - `external`: only the relays listed in `GP_RELAYS` are used (no bundled
//! relay); config validation requires at least one.
//!
//! The bundled relay is a vendored, unmodified nostr-rs-relay (config only, no
//! fork) rather than a relay written from scratch: it is a small, SQLite-backed
//! Rust relay that fits a single-merchant till, and reusing it keeps the money
//! path off any third-party infrastructure.
/// Default DM relays: the Goblin relay plus large public relays for
/// redundancy. Used when `GP_RELAYS` is unset (the bundled relay is a later
/// milestone; until then `bundled` mode serves this set too).
pub const DEFAULT_RELAYS: &[&str] = &[
"wss://relay.goblin.st",
"wss://relay.damus.io",
"wss://nos.lol",
];
use gp_core::config::RelayMode;
/// Maximum relays published in the kind 10050 DM relay list (NIP-17
/// guidance) and read from a payer's list.
pub const MAX_DM_RELAYS: usize = 3;
/// The relay set to run with: the configured external list, else defaults.
pub fn resolve(configured: &[String]) -> Vec<String> {
if configured.is_empty() {
DEFAULT_RELAYS.iter().map(|s| s.to_string()).collect()
} else {
configured.to_vec()
/// The relay set to listen on, publish to, and advertise in the `nprofile`.
///
/// In `bundled` mode the co-located `bundled_url` comes first (so it heads the
/// advertised kind 10050 / `nprofile` hints), followed by any `configured`
/// redundancy relays, de-duplicated. In `external` mode only the `configured`
/// relays are used.
pub fn resolve(mode: RelayMode, bundled_url: &str, configured: &[String]) -> Vec<String> {
match mode {
RelayMode::Bundled => {
let mut relays = vec![bundled_url.to_string()];
for relay in configured {
if !relays.iter().any(|r| r == relay) {
relays.push(relay.clone());
}
}
relays
}
RelayMode::External => configured.to_vec(),
}
}
@@ -27,9 +51,42 @@ mod tests {
use super::*;
#[test]
fn resolves_defaults_and_overrides() {
assert_eq!(resolve(&[]), DEFAULT_RELAYS.to_vec());
fn bundled_leads_with_the_bundled_relay() {
// No extras: just the bundled relay, so the nprofile advertises it and
// nothing third-party is involved.
assert_eq!(
resolve(RelayMode::Bundled, "ws://127.0.0.1:7777", &[]),
vec!["ws://127.0.0.1:7777".to_string()]
);
// Extras are appended for redundancy; the bundled relay stays first.
let extras = vec!["wss://relay.damus.io".to_string()];
assert_eq!(
resolve(RelayMode::Bundled, "ws://127.0.0.1:7777", &extras),
vec![
"ws://127.0.0.1:7777".to_string(),
"wss://relay.damus.io".to_string(),
]
);
// A configured relay equal to the bundled one is not added twice.
let dup = vec![
"ws://127.0.0.1:7777".to_string(),
"wss://r.example".to_string(),
];
assert_eq!(
resolve(RelayMode::Bundled, "ws://127.0.0.1:7777", &dup),
vec![
"ws://127.0.0.1:7777".to_string(),
"wss://r.example".to_string(),
]
);
}
#[test]
fn external_uses_only_configured() {
let own = vec!["wss://relay.example".to_string()];
assert_eq!(resolve(&own), own);
assert_eq!(
resolve(RelayMode::External, "ws://127.0.0.1:7777", &own),
own
);
}
}
+8 -34
View File
@@ -1,6 +1,6 @@
//! The daemon service loop, adapted from `goblin/src/nostr/client.rs`
//! (`run_service`): connect the relay pool over the in-process Nym mixnet,
//! publish the kind 10050 inbox (with the NIP-17 `encryption` capability
//! (`run_service`): connect the relay pool, publish the kind 10050 inbox
//! (with the NIP-17 `encryption` capability
//! tag) and its kind 10002 mirror, catch up on missed gift wraps, subscribe
//! live, and for every received payment dispatch the S2 reply to the payer's
//! advertised relays (their 10050; our own set as the fallback), encrypted
@@ -19,7 +19,6 @@ use nostr_sdk::{
};
use crate::ingest::{Ingest, IngestOutcome, PendingReply};
use crate::nym::NymWebSocketTransport;
use crate::relays::MAX_DM_RELAYS;
use crate::unix_time;
use crate::wrap;
@@ -33,17 +32,12 @@ const LOOKBACK_SECS: i64 = 3 * 86_400;
const FETCH_TIMEOUT: Duration = Duration::from_secs(30);
/// Send dispatch timeout.
const SEND_TIMEOUT: Duration = Duration::from_secs(40);
/// How long to wait for the mixnet tunnel before dialing relays anyway.
const NYM_WARM_WAIT: Duration = Duration::from_secs(30);
/// Service configuration (already resolved from the environment).
#[derive(Debug, Clone)]
pub struct ServiceOptions {
/// Relay set to listen on and publish to.
pub relays: Vec<String>,
/// Route everything over the Nym mixnet (default on; clearnet is a
/// debugging escape hatch only).
pub nym: bool,
/// Optional NIP-17 payment DMs (milestone 6, all off by default).
pub notify: NotifyOptions,
}
@@ -120,30 +114,10 @@ pub async fn run<R: SlatepackReceiver>(
receiver: R,
directory: Arc<dyn KeyDirectory>,
) {
let client = if opts.nym {
// Wait for the in-process Nym mixnet tunnel before any network work:
// dialing before it is up drops every relay into the pool's
// backing-off reconnect (Goblin's wallet-open ordering lesson).
crate::nym::warm_up();
let waited = std::time::Instant::now();
while !crate::nym::is_ready() && waited.elapsed() < NYM_WARM_WAIT {
tokio::time::sleep(Duration::from_millis(500)).await;
}
if crate::nym::is_ready() {
info!(
"nostr: Nym tunnel ready after ~{}ms",
waited.elapsed().as_millis()
);
} else {
warn!("nostr: Nym tunnel still warming; relays will retry through it");
}
Client::builder()
.websocket_transport(NymWebSocketTransport)
.build()
} else {
warn!("nostr: GP_NYM=off — relay traffic goes CLEARNET (debugging only)");
Client::builder().build()
};
// This server's relay traffic goes over clearnet. The payer's Goblin Wallet
// still provides sender privacy, and the payload stays gift-wrapped end to
// end regardless of the pipe it travels through.
let client = Client::builder().build();
let ingest = Ingest::with_directory(keys.clone(), receiver, directory);
let npub_prefix: String = keys.public_key().to_hex().chars().take(8).collect();
@@ -465,8 +439,8 @@ async fn connect_relays(client: &Client, urls: &[String]) {
let url = url.clone();
async move {
let _ = client.add_relay(&url).await;
// Short cap: a reachable relay connects in ~2-4s over the mixnet;
// one dead relay in the list must not stall the whole send.
// Short cap: a reachable relay connects in ~2-4s; one dead relay in
// the list must not stall the whole send.
let _ = client.try_connect_relay(&url, Duration::from_secs(6)).await;
}
});
+1 -1
View File
@@ -34,7 +34,7 @@ log = "0.4"
# ring crypto provider (no aws-lc-rs build) and brings platform-verifier roots;
# no JSON feature (we send a pre-signed body).
reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider"] }
# Stderr logger for the gp-nostr/nym `log` output; no regex filtering needed.
# Stderr logger for the gp-nostr `log` output; no regex filtering needed.
env_logger = { version = "0.11", default-features = false, features = ["humantime"] }
[dev-dependencies]
+3 -4
View File
@@ -74,7 +74,6 @@ struct AdminPage {
balances: Vec<BalanceRow>,
node_url: String,
match_mode: String,
nym: bool,
ingest: bool,
relay_count: usize,
webhook_configured: bool,
@@ -103,9 +102,9 @@ async fn dashboard(
balances,
node_url: cfg.node_url.clone(),
match_mode: format!("{:?}", cfg.match_mode).to_lowercase(),
nym: cfg.nym,
ingest: cfg.ingest,
relay_count: gp_nostr::relays::resolve(&cfg.relays).len(),
relay_count: gp_nostr::relays::resolve(cfg.relay_mode, &cfg.bundled_relay_url, &cfg.relays)
.len(),
webhook_configured: cfg.webhook_url.is_some(),
pending_webhooks,
rotate_interval: cfg.endpub_rotate_interval,
@@ -206,7 +205,7 @@ struct CreateUserBody {
}
fn endpub_json(cfg: &Config, user_id: &str, epoch: i64, pubkey: &str) -> serde_json::Value {
let relays = gp_nostr::relays::resolve(&cfg.relays);
let relays = gp_nostr::relays::resolve(cfg.relay_mode, &cfg.bundled_relay_url, &cfg.relays);
let (npub, nprofile, qr) = match PublicKey::from_hex(pubkey) {
Ok(pk) => (
gp_nostr::npub_of(pk),
+11 -7
View File
@@ -56,7 +56,7 @@ pub struct CheckoutInfo {
/// caller does not surface the Slatepack option (e.g. the JSON connector API),
/// in which case no Slatepack address or QR is produced.
pub fn build_info(inv: &Invoice, cfg: &Config, slatepack_addr: Option<&str>) -> CheckoutInfo {
let relays = gp_nostr::relays::resolve(&cfg.relays);
let relays = gp_nostr::relays::resolve(cfg.relay_mode, &cfg.bundled_relay_url, &cfg.relays);
let recipient_pubkey = inv.recipient_pubkey.clone().unwrap_or_default();
// The Nostr (Goblin Wallet) method is only surfaced when the operator has it
// enabled (`GP_CHECKOUT_METHODS`). Disabled, the nprofile/npub/QR are left
@@ -400,9 +400,11 @@ mod tests {
// leaves the nprofile/npub empty and the page omits the Nostr section
// while still showing the Slatepack one.
let inv = invoice(Some(1_500_000_000), None);
let mut cfg = Config::default();
cfg.checkout_nostr = false;
cfg.checkout_slatepack = true;
let cfg = Config {
checkout_nostr: false,
checkout_slatepack: true,
..Config::default()
};
let info = build_info(&inv, &cfg, Some("grin1qtestaddress"));
assert!(info.nprofile.is_empty(), "nprofile empty when nostr off");
assert!(info.npub.is_empty(), "npub empty when nostr off");
@@ -431,9 +433,11 @@ mod tests {
// wallet address available, build_info drops it and the page omits the
// Slatepack section while still showing the Nostr one.
let inv = invoice(Some(1_500_000_000), None);
let mut cfg = Config::default();
cfg.checkout_nostr = true;
cfg.checkout_slatepack = false;
let cfg = Config {
checkout_nostr: true,
checkout_slatepack: false,
..Config::default()
};
let info = build_info(&inv, &cfg, Some("grin1qtestaddress"));
assert!(
info.slatepack_address.is_none(),
+4 -10
View File
@@ -1,7 +1,6 @@
//! GoblinPay HTTP server: Actix-Web with in-process rustls TLS (off by
//! default), a zero-JS Askama frontend, the SQLite-backed domain core, and
//! (config-gated) the Nostr ingest service receiving payments over the Nym
//! mixnet.
//! (config-gated) the Nostr ingest service receiving payments over Nostr.
use std::io;
use std::sync::Arc;
@@ -89,7 +88,7 @@ fn routes(cfg: &mut web::ServiceConfig) {
/// Boot the Nostr ingest service (M3): open the wallet, resolve the payment
/// identity, build the multi-identity key directory (M5b), seed the initial
/// watch set, and start the relay listener over Nym on its own thread. Fails
/// watch set, and start the relay listener on its own thread. Fails
/// fast on misconfiguration. Returns the identity keys (for receipts + invoice
/// derivation) and a clone of the wallet (for the manual-slatepack handler).
async fn start_ingest(cfg: &Config, pool: sqlx::SqlitePool) -> (Keys, GpWallet) {
@@ -121,9 +120,6 @@ async fn start_ingest(cfg: &Config, pool: sqlx::SqlitePool) -> (Keys, GpWallet)
gp_nostr::wrap::ENCRYPTION_CAPABILITIES
);
if cfg.nym {
gp_nostr::nym::warm_up();
}
let merchant = cfg
.merchant_npub
.as_deref()
@@ -132,8 +128,7 @@ async fn start_ingest(cfg: &Config, pool: sqlx::SqlitePool) -> (Keys, GpWallet)
eprintln!("warning: GP_NOTIFY_MERCHANT_DM=on but GP_MERCHANT_NPUB is unset/invalid");
}
let opts = gp_nostr::service::ServiceOptions {
relays: gp_nostr::relays::resolve(&cfg.relays),
nym: cfg.nym,
relays: gp_nostr::relays::resolve(cfg.relay_mode, &cfg.bundled_relay_url, &cfg.relays),
notify: gp_nostr::service::NotifyOptions {
merchant,
merchant_dm: cfg.notify_merchant_dm,
@@ -203,8 +198,7 @@ fn tls_server_config(cert_path: &str, key_path: &str) -> Result<rustls::ServerCo
#[actix_web::main]
async fn main() -> io::Result<()> {
// Install the rustls ring provider exactly once, before anything else
// touches rustls. Shared by sqlx, nostr-sdk, tungstenite, reqwest, and the
// Nym stack (the Build 65/66 gotcha).
// touches rustls. Shared by sqlx, nostr-sdk, tungstenite, and reqwest.
rustls::crypto::ring::default_provider()
.install_default()
.expect("install rustls ring crypto provider");
+33
View File
@@ -0,0 +1,33 @@
# 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
# --- API / admin tokens (bearer capabilities; use strong random values) ---
GP_API_TOKEN=change-me-api-token
GP_ADMIN_TOKEN=change-me-admin-token
# --- webhook to your store (optional; the URL requires the secret) ---
#GP_WEBHOOK_URL=https://your-store/hook
#GP_WEBHOOK_SECRET=change-me-webhook-secret
# --- default payment-matching mode: memo | derived | amount ---
GP_MATCH_MODE=derived
+23
View File
@@ -0,0 +1,23 @@
# Caddy reverse proxy for a GoblinPay till, with automatic HTTPS.
#
# Two names on one host (point both A/AAAA records at this server before
# `docker compose up`, so Caddy can obtain certificates):
# {$GP_DOMAIN} -> the GoblinPay checkout pages + REST API (gp-server)
# relay.{$GP_DOMAIN} -> the bundled nostr-rs-relay (payers connect here; it
# is what the checkout nprofile advertises)
#
# The relay gets its OWN subdomain rather than a path on the main domain so
# there is no path rewriting: nostr-rs-relay serves both the WebSocket relay
# protocol and the NIP-11 relay-info document at the root.
#
# GP_DOMAIN is injected from the environment by docker-compose.
{$GP_DOMAIN} {
encode gzip
reverse_proxy gp-server:8080
}
relay.{$GP_DOMAIN} {
# WebSocket upgrades and the NIP-11 document both go straight through.
reverse_proxy relay:7777
}
+65
View File
@@ -0,0 +1,65 @@
# 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 money path depends on a crate that lives next to this repo, not
# inside it (see crates/gp-nostr/Cargo.toml):
# nip44 -> ../nip44 (the NIP-44 v3 companion crate)
# So the image must be built from the directory that contains GoblinPay/ and
# nip44/ side by side. docker-compose.yml already sets
# `build.context: ../..` for this; to build by hand:
#
# cd "<workspace parent containing GoblinPay, nip44>"
# 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 trees the gp-server dependency graph needs, in the same relative layout
# the path deps expect (nip44 is a sibling of GoblinPay).
COPY GoblinPay ./GoblinPay
COPY nip44 ./nip44
WORKDIR /build/GoblinPay
# Build ONLY gp-server (and its deps); never the goblin-tree dev crate.
RUN cargo build --release --locked -p gp-server
# ---- runtime ----
FROM debian:bookworm-slim AS runtime
# ca-certificates for outbound TLS (node reads, CoinGecko, relays); curl for the
# healthcheck.
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
# Non-root user; wallet files, seed-at-rest, and the SQLite db live under /data.
RUN useradd --system --uid 10001 --home-dir /data --shell /usr/sbin/nologin goblinpay \
&& mkdir -p /data \
&& chown -R goblinpay:goblinpay /data
COPY --from=builder /build/GoblinPay/target/release/gp-server /usr/local/bin/gp-server
USER goblinpay
WORKDIR /data
VOLUME ["/data"]
# Bind on all interfaces inside the container (Caddy is the only thing in front);
# keep state under the /data volume. Money/identity secrets are injected at run
# time via the *_FILE mounted-secret variants, never baked into the image.
ENV GP_BIND=0.0.0.0:8080 \
GP_DB_PATH=/data/goblinpay.db \
GP_DATA_DIR=/data/gp-data
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD curl -fsS http://127.0.0.1:8080/health || exit 1
ENTRYPOINT ["/usr/local/bin/gp-server"]
+89
View File
@@ -0,0 +1,89 @@
# A full, self-contained GoblinPay till: the server, its BUNDLED relay, and an
# auto-HTTPS reverse proxy.
#
# cd deploy
# cp .env.example .env # then edit it (domain, tokens)
# mkdir -p secrets # drop the mounted-secret files in here
# docker compose up -d
#
# gives you:
# - gp-server : the GoblinPay payment server (this repo)
# - relay : a stock nostr-rs-relay, the bundled relay GP_RELAY_MODE=bundled
# points at (so no third-party relay is needed)
# - caddy : auto-TLS reverse proxy terminating HTTPS for both
#
# Set GP_DOMAIN in .env to your own domain BEFORE bringing it up: Caddy obtains
# a certificate for it, so DNS must already point at this host.
#
# NOTE on the build context: gp-server's Nostr path depends on the sibling
# crate nip44/ (see deploy/Dockerfile), so the build context is the workspace
# parent (`../..`) that holds GoblinPay and nip44.
services:
gp-server:
build:
context: ../..
dockerfile: GoblinPay/deploy/Dockerfile
image: goblinpay:latest
restart: unless-stopped
env_file: .env
environment:
# Bundled relay (default mode). GP_BUNDLED_RELAY_URL is BOTH dialed by the
# server and advertised to payers in the nprofile, so it must be the
# relay's PUBLIC url (payers connect here); the server reaches it back
# through Caddy.
GP_RELAY_MODE: bundled
GP_BUNDLED_RELAY_URL: ${GP_BUNDLED_RELAY_URL:-wss://relay.${GP_DOMAIN}}
GP_PUBLIC_URL: ${GP_PUBLIC_URL:-https://${GP_DOMAIN}}
GP_BIND: 0.0.0.0:8080
GP_DB_PATH: /data/goblinpay.db
GP_DATA_DIR: /data/gp-data
# Money/identity secrets come from mounted files (never the image/env):
GP_MNEMONIC_FILE: /run/secrets/gp_mnemonic
GP_WALLET_PASSWORD_FILE: /run/secrets/gp_wallet_password
GP_NCRYPTSEC_FILE: /run/secrets/gp_ncryptsec
volumes:
- gp-data:/data
- ./secrets:/run/secrets:ro
expose:
- "8080"
depends_on:
- relay
relay:
image: scsibug/nostr-rs-relay:latest
restart: unless-stopped
volumes:
- ./relay/nostr-rs-relay.toml:/usr/src/app/config.toml:ro
- relay-data:/usr/src/app/db
expose:
- "7777"
# Bound the relay's footprint so an unauthenticated flood cannot starve the
# till or proxy on the same host.
deploy:
resources:
limits:
memory: 512M
cpus: "1.0"
caddy:
image: caddy:2
restart: unless-stopped
depends_on:
- gp-server
- relay
environment:
GP_DOMAIN: ${GP_DOMAIN:-pay.example}
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
volumes:
gp-data:
relay-data:
caddy-data:
caddy-config:
+80
View File
@@ -0,0 +1,80 @@
# Hardened systemd unit for the GoblinPay server on bare metal.
#
# Install (or just run deploy/install.sh):
# sudo install -m0755 target/release/gp-server /usr/local/bin/
# sudo install -m0640 deploy/.env.example /etc/goblinpay.env # then EDIT it
# sudo install -m0644 deploy/gp-server.service /etc/systemd/system/
# sudo mkdir -p /etc/goblinpay/secrets # 0400 secret files
# sudo systemctl daemon-reload && sudo systemctl enable --now gp-server
#
# Unlike goblin-nip05d, this service holds MONEY secrets (the Grin seed and the
# wallet password) and a wallet data directory. The seed and password are passed
# as systemd credentials (read by PID1 as root, exposed read-only to the dynamic
# service user) rather than left world-readable, and the config supports the
# `*_FILE` mounted-secret variants for exactly this.
[Unit]
Description=GoblinPay — self-hostable, receive-only Grin payment server
After=network-online.target
Wants=network-online.target
[Service]
Type=exec
# Throwaway unprivileged user allocated at runtime. For a stable data owner,
# comment this out and set `User=goblinpay` (create the user first).
DynamicUser=yes
# Non-secret config (domain, node, tokens, webhook, relay URL). Read by systemd
# as root, so a 0640 root:root file is fine even under DynamicUser.
EnvironmentFile=/etc/goblinpay.env
# Money/identity secrets as credentials: the source files stay root-owned 0400;
# systemd exposes copies under $CREDENTIALS_DIRECTORY (%d), readable by the
# dynamic service user. Point the wallet at them via the *_FILE variants.
LoadCredential=gp_mnemonic:/etc/goblinpay/secrets/mnemonic
LoadCredential=gp_wallet_password:/etc/goblinpay/secrets/wallet_password
Environment=GP_MNEMONIC_FILE=%d/gp_mnemonic
Environment=GP_WALLET_PASSWORD_FILE=%d/gp_wallet_password
# Optional: a NIP-49 encrypted Nostr identity (else a random one is generated
# and persisted under the data dir on first start). Uncomment with its file:
#LoadCredential=gp_ncryptsec:/etc/goblinpay/secrets/ncryptsec
#Environment=GP_NCRYPTSEC_FILE=%d/gp_ncryptsec
# Managed state at /var/lib/goblinpay: the SQLite db, the wallet files, and the
# encrypted seed at rest. 0700 — only the service user may read it.
StateDirectory=goblinpay
StateDirectoryMode=0700
Environment=GP_DB_PATH=/var/lib/goblinpay/goblinpay.db
Environment=GP_DATA_DIR=/var/lib/goblinpay/gp-data
ExecStart=/usr/local/bin/gp-server
Restart=on-failure
RestartSec=2
# --- hardening ---
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
# If the Nym mixnet stack ever fails to start with a W^X error, comment this out.
MemoryDenyWriteExecute=yes
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources
# Only the state directory is writable.
ReadWritePaths=/var/lib/goblinpay
# No raw sockets; only IP + unix.
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
[Install]
WantedBy=multi-user.target
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
# One-command bare-metal bootstrap for the GoblinPay server:
# - builds the release binary (gp-server only; never the goblin-tree dev crate)
# - installs it to /usr/local/bin
# - creates the managed state dir and the 0700 secrets dir
# - installs an env file from deploy/.env.example (if absent)
# - installs and enables the hardened systemd unit
#
# Re-runnable: it never overwrites an existing /etc/goblinpay.env.
# Requires: a Rust toolchain (cargo) and root (sudo) for the install steps.
#
# BUILD PREREQUISITE: gp-server's Nostr path depends on the sibling crate
# nip44/ (see crates/gp-nostr/Cargo.toml). It must sit next to this
# repo, exactly as on the deploy host. `-p gp-server` deliberately excludes the
# gp-goblin-sender dev crate, which needs the (absent) goblin wallet tree.
#
# After it finishes, edit /etc/goblinpay.env and drop the secret files into
# /etc/goblinpay/secrets (mnemonic, wallet_password), then:
# sudo systemctl restart gp-server
set -euo pipefail
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BIN=/usr/local/bin/gp-server
ENV_FILE=/etc/goblinpay.env
UNIT=/etc/systemd/system/gp-server.service
STATE_DIR=/var/lib/goblinpay
SECRETS_DIR=/etc/goblinpay/secrets
say() { printf '\033[1;33m==>\033[0m %s\n' "$1"; }
if [[ $EUID -ne 0 ]]; then
SUDO=sudo
else
SUDO=""
fi
say "Building release binary (cargo build --release --locked -p gp-server)"
( cd "$REPO_DIR" && cargo build --release --locked -p gp-server )
say "Installing binary to $BIN"
$SUDO install -m0755 "$REPO_DIR/target/release/gp-server" "$BIN"
say "Creating state directory $STATE_DIR (0700)"
$SUDO install -d -m0700 "$STATE_DIR"
say "Creating secrets directory $SECRETS_DIR (0700)"
$SUDO install -d -m0700 "$SECRETS_DIR"
if [[ -f "$ENV_FILE" ]]; then
say "Env file $ENV_FILE already exists — leaving it untouched"
else
say "Installing env file to $ENV_FILE (EDIT IT: domain, node, tokens)"
$SUDO install -m0640 "$REPO_DIR/deploy/.env.example" "$ENV_FILE"
fi
say "Installing systemd unit to $UNIT"
$SUDO install -m0644 "$REPO_DIR/deploy/gp-server.service" "$UNIT"
say "Reloading systemd and enabling the service"
$SUDO systemctl daemon-reload
$SUDO systemctl enable gp-server
cat <<EOF
Done. Next steps:
1. Edit $ENV_FILE — set GP_PUBLIC_URL, GP_NODE_URL, GP_BUNDLED_RELAY_URL,
GP_API_TOKEN, GP_ADMIN_TOKEN (and GP_WEBHOOK_URL/GP_WEBHOOK_SECRET if used).
2. Write the wallet secrets (root-owned, mode 0400):
sudo install -m0400 /dev/stdin $SECRETS_DIR/mnemonic <<<'your 24 words'
sudo install -m0400 /dev/stdin $SECRETS_DIR/wallet_password <<<'your password'
3. Run the bundled relay (deploy/docker-compose.yml) or point
GP_BUNDLED_RELAY_URL at a relay you control, and put a TLS reverse proxy
in front (see deploy/Caddyfile).
4. Start it: $SUDO systemctl start gp-server
5. Check it: curl -s http://127.0.0.1:8080/health
EOF
+39
View File
@@ -0,0 +1,39 @@
# Configuration for the BUNDLED GoblinPay relay: a stock, unmodified
# nostr-rs-relay (https://github.com/scsibug/nostr-rs-relay) run as the `relay`
# service in docker-compose.yml. This is the self-contained relay that
# `GP_RELAY_MODE=bundled` (the default) points at, so a merchant needs no
# third-party relay: GoblinPay dials it, and the checkout `nprofile` advertises
# it to payers, who deliver their gift-wrapped slatepack straight to the
# merchant's own relay.
#
# nostr-rs-relay is a small, SQLite-backed Rust relay: a good fit for a
# single-merchant till, and vendored as-is (config only, no fork).
[info]
# Set this to the relay's PUBLIC wss URL (the same value you put in
# GP_BUNDLED_RELAY_URL). Payers connect here.
relay_url = "wss://pay.example/"
name = "GoblinPay bundled relay"
description = "Co-located Nostr relay for a GoblinPay merchant till."
[database]
data_directory = "/usr/src/app/db"
[network]
# Inside the container. Caddy terminates TLS and proxies wss -> here.
address = "0.0.0.0"
port = 7777
[limits]
# Bound the footprint so an unauthenticated ingest/subscription flood cannot
# starve the till (mirrors the reasoning behind the bundled strfry limits in
# goblin-nip05d). Payers publish NIP-59 gift wraps from random ephemeral keys,
# so a pubkey allowlist is intentionally NOT used (it would block payments).
messages_per_sec = 10
subscriptions_per_min = 60
max_event_bytes = 131072
max_ws_message_bytes = 262144
max_subscriptions = 20
[options]
reject_future_seconds = 1800
-1
View File
@@ -15,7 +15,6 @@
<ul>
<li>Node: <code>{{ node_url }}</code></li>
<li>Default match mode: <code>{{ match_mode }}</code></li>
<li>Nym: <code>{% if nym %}on{% else %}off{% endif %}</code></li>
<li>Ingest: <code>{% if ingest %}on{% else %}off{% endif %}</code></li>
<li>Relays watched: <code>{{ relay_count }}</code></li>
<li>Webhook: <code>{% if webhook_configured %}configured{% else %}off{% endif %}</code>