Compare commits
9 Commits
3f5b1fe49b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b823b4750 | |||
| 3d36117d7b | |||
| 3fdf4a230c | |||
| bba1dd5cba | |||
| c32ddfa9ff | |||
| b8434fdd36 | |||
| 94d0c0edba | |||
| 3a80f7d505 | |||
| c362a9af21 |
@@ -0,0 +1,58 @@
|
||||
# CI gate for GoblinPay. Mirror of .github/workflows/ci.yml.
|
||||
#
|
||||
# What runs where:
|
||||
# - fmt + the gp-core clippy/test gate run on ANY runner: gp-core is
|
||||
# self-contained (no out-of-repo deps), and it holds the domain logic
|
||||
# (config, invoices, matching, webhooks, rates, the connector seam).
|
||||
# - The FULL gate (gp-wallet + gp-nostr + gp-server, via ./ci.sh) needs the
|
||||
# sibling checkouts next to the repo: nip44/ and nym/ (the Nostr/Nym path)
|
||||
# and goblin/ (the gp-goblin-sender round-trip gate). Where the workspace is
|
||||
# laid out like the deploy host, it runs too; otherwise it is skipped with a
|
||||
# note. `-p` scoping always keeps the goblin-tree dev crate off the money
|
||||
# path build.
|
||||
name: ci
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
pull_request:
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
jobs:
|
||||
rust:
|
||||
name: fmt / clippy / test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
|
||||
- name: Cache cargo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: Format check (whole workspace)
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: Clippy (gp-core, deny warnings)
|
||||
run: cargo clippy -p gp-core --all-targets -- -D warnings
|
||||
|
||||
- name: Test (gp-core)
|
||||
run: cargo test -p gp-core --locked
|
||||
|
||||
- name: Full gate (when sibling checkouts are present)
|
||||
run: |
|
||||
if [ -d ../nip44 ] && [ -d ../nym/smolmix/core ] && [ -d ../goblin ]; then
|
||||
echo "Workspace siblings present — running the full ./ci.sh gate."
|
||||
./ci.sh
|
||||
else
|
||||
echo "nip44/nym/goblin siblings absent on this runner;"
|
||||
echo "the full gp-server gate runs via ./ci.sh on the deploy host."
|
||||
fi
|
||||
@@ -0,0 +1,58 @@
|
||||
# CI gate for GoblinPay. Mirror of .gitea/workflows/ci.yml.
|
||||
#
|
||||
# What runs where:
|
||||
# - fmt + the gp-core clippy/test gate run on ANY runner: gp-core is
|
||||
# self-contained (no out-of-repo deps), and it holds the domain logic
|
||||
# (config, invoices, matching, webhooks, rates, the connector seam).
|
||||
# - The FULL gate (gp-wallet + gp-nostr + gp-server, via ./ci.sh) needs the
|
||||
# sibling checkouts next to the repo: nip44/ and nym/ (the Nostr/Nym path)
|
||||
# and goblin/ (the gp-goblin-sender round-trip gate). Where the workspace is
|
||||
# laid out like the deploy host, it runs too; otherwise it is skipped with a
|
||||
# note. `-p` scoping always keeps the goblin-tree dev crate off the money
|
||||
# path build.
|
||||
name: ci
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
pull_request:
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
jobs:
|
||||
rust:
|
||||
name: fmt / clippy / test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
|
||||
- name: Cache cargo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: Format check (whole workspace)
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: Clippy (gp-core, deny warnings)
|
||||
run: cargo clippy -p gp-core --all-targets -- -D warnings
|
||||
|
||||
- name: Test (gp-core)
|
||||
run: cargo test -p gp-core --locked
|
||||
|
||||
- name: Full gate (when sibling checkouts are present)
|
||||
run: |
|
||||
if [ -d ../nip44 ] && [ -d ../nym/smolmix/core ] && [ -d ../goblin ]; then
|
||||
echo "Workspace siblings present — running the full ./ci.sh gate."
|
||||
./ci.sh
|
||||
else
|
||||
echo "nip44/nym/goblin siblings absent on this runner;"
|
||||
echo "the full gp-server gate runs via ./ci.sh on the deploy host."
|
||||
fi
|
||||
Generated
+1
-1
@@ -5733,7 +5733,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nip44"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chacha20 0.9.1",
|
||||
|
||||
@@ -17,9 +17,21 @@ carries the full merchant surface:
|
||||
invoice automatically.
|
||||
- **Hosted checkout:** a zero-JS `/pay/<token>` page (server-rendered
|
||||
Askama + one CSS file + a server-generated QR SVG at ECC level H with an
|
||||
optional Goblin-mark center logo), live status via `<meta http-equiv=refresh>`,
|
||||
and a manual slatepack fallback (paste S1 -> offline `receive_tx` -> copy the
|
||||
S2 back) on every page. The same renderer serves embedded and hosted use.
|
||||
optional GoblinPay-mark center logo) with live status via
|
||||
`<meta http-equiv=refresh>`. It offers two first-class ways to pay:
|
||||
- **Goblin Wallet (Nostr):** scan the `nprofile` QR (or copy it) and the
|
||||
payment auto-receives over Nostr.
|
||||
- **Slatepack (`grin1`):** pay from any Grin wallet, no Nostr needed. The
|
||||
page shows the wallet's stable index-0 Slatepack address (`grin1...`) plus
|
||||
its QR and the exact amount to send. The payer sends that amount to the
|
||||
address using their wallet's Slatepack/file method, pastes the resulting S1
|
||||
into the page (offline `receive_tx`), then copies the returned S2 back into
|
||||
their wallet to finalize and broadcast it. There is no Tor listener; the
|
||||
`grin1` address is stable and reused across invoices, and the existing
|
||||
invoice matcher and on-chain confirmation handle the received payment like
|
||||
any other. The Slatepack option only appears when a wallet is loaded.
|
||||
|
||||
The same renderer serves embedded and hosted use.
|
||||
- **Per-user endpubs:** an admin assigns one receiving identity per user
|
||||
(a derived child keyed by `(user_id, epoch)`; only public keys and the
|
||||
rotation clock are stored, never private keys), with optional rolling rotation
|
||||
@@ -29,10 +41,15 @@ carries the full merchant surface:
|
||||
HTTP webhook (the WooCommerce contract), an authenticated admin dashboard +
|
||||
JSON API, and NIP-17 DMs to the merchant / payer.
|
||||
|
||||
All relay traffic rides an in-process Nym mixnet tunnel (smolmix, auto-selected
|
||||
exit, mix-dns; `GP_NYM=off` is a debugging escape hatch only). Encryption
|
||||
negotiates NIP-44 v3 (the NIP-17 extension, via the companion `nip44` crate) per
|
||||
recipient, with v2 as the mandatory baseline.
|
||||
By default all relay traffic rides an in-process Nym mixnet tunnel (smolmix,
|
||||
auto-selected exit, mix-dns). `GP_NYM=off` is also a supported production
|
||||
posture, not just a debugging switch: the server then reaches relays over
|
||||
clearnet, but the payer's Goblin Wallet still provides sender privacy over its
|
||||
own mixnet and the payload stays gift-wrapped end to end. An operator who fronts
|
||||
GoblinPay with their own network privacy, or who accepts server-side clearnet for
|
||||
a receive-only till, can run it that way. Encryption negotiates NIP-44 v3 (the
|
||||
NIP-17 extension, via the companion `nip44` crate) per recipient, with v2 as the
|
||||
mandatory baseline.
|
||||
|
||||
## Workspace
|
||||
|
||||
@@ -61,10 +78,12 @@ Everything is environment variables, defaults are safe for local use.
|
||||
| `GP_DATA_DIR` | `./gp-data` | Data directory (wallet files, encrypted seed) |
|
||||
| `GP_NODE_URL` | `https://main.gri.mw` | External Grin node (read only) |
|
||||
| `GP_CHAIN` | `mainnet` | Grin network: `mainnet` or `testnet` |
|
||||
| `GP_RELAY_MODE` | `bundled` | `bundled` or `external` |
|
||||
| `GP_RELAYS` | unset | Comma-separated relay URLs |
|
||||
| `GP_NYM` | `on` | Route Nostr traffic over the Nym mixnet (`on` or `off`) |
|
||||
| `GP_RELAY_MODE` | `bundled` | `bundled` (GoblinPay runs its own co-located relay) or `external` |
|
||||
| `GP_BUNDLED_RELAY_URL` | `ws://127.0.0.1:7777` | In `bundled` mode, the self-contained relay GoblinPay dials AND advertises in the checkout `nprofile`; set to the relay's public `wss://` URL in production |
|
||||
| `GP_RELAYS` | unset | Extra relay URLs (comma separated): redundancy in `bundled` mode, the whole set in `external` mode |
|
||||
| `GP_NYM` | `on` | Route this server's Nostr traffic over the Nym mixnet (`on`, or `off` for supported server-side clearnet) |
|
||||
| `GP_INGEST` | `on` | Nostr ingest service (`off` = HTTP surface only, for debugging) |
|
||||
| `GP_CHECKOUT_METHODS` | `nostr,slatepack` | Which payment methods the hosted `/pay/<token>` page shows: comma list of `nostr` (Goblin Wallet) and `slatepack` (`grin1`). Unset = both. Unknown tokens are ignored; an empty result falls back to both |
|
||||
| `GP_MATCH_MODE` | `memo` | Default matching mode: `memo`, `derived`, `amount` |
|
||||
| `GP_MNEMONIC` | unset | Grin seed mnemonic (money secret) |
|
||||
| `GP_WALLET_PASSWORD` | unset | Password encrypting the wallet seed and the Nostr identity at rest |
|
||||
@@ -75,7 +94,7 @@ Everything is environment variables, defaults are safe for local use.
|
||||
| `GP_ADMIN_TOKEN` | unset | Bearer token for the admin dashboard + endpub/webhook API |
|
||||
| `GP_WEBHOOK_URL` | unset | Webhook endpoint for payment events (requires `GP_WEBHOOK_SECRET`) |
|
||||
| `GP_WEBHOOK_SECRET` | unset | HMAC-SHA256 secret for signing webhooks |
|
||||
| `GP_QR_LOGO` | Goblin mark | Checkout QR center logo: unset = Goblin mark, `off`/`none` = plain, else a URL/path |
|
||||
| `GP_QR_LOGO` | GoblinPay mark | Checkout QR center logo: unset = GoblinPay mark, `off`/`none` = plain, else a URL/path |
|
||||
| `GP_MERCHANT_NPUB` | unset | Merchant npub for the NIP-17 confirmed-payment DM |
|
||||
| `GP_NOTIFY_MERCHANT_DM` | `off` | Send a NIP-17 DM to the merchant on a received payment |
|
||||
| `GP_NOTIFY_PAYER_RECEIPT` | `off` | Send a NIP-17 receipt DM to the payer |
|
||||
@@ -87,6 +106,40 @@ Everything is environment variables, defaults are safe for local use.
|
||||
| `GP_QUOTE_TTL` | `900` | Seconds a created fiat invoice locks its Grin quote (its expiry window) |
|
||||
| `GP_RATE_STALE_MAX` | `0` | Bounded stale-rate fallback in seconds if a live fetch fails (0 = off) |
|
||||
|
||||
### Checkout methods
|
||||
|
||||
`GP_CHECKOUT_METHODS` only controls what the hosted `/pay/<token>` page
|
||||
advertises to a payer; it does not turn any payment processing on or off. The
|
||||
Slatepack (`grin1`) method also needs a loaded wallet to appear, so an enabled
|
||||
method that cannot work is simply hidden. Keep this consistent with `GP_INGEST`:
|
||||
`GP_INGEST` runs the Nostr ingest service that actually receives and matches
|
||||
Goblin Wallet payments, so `GP_INGEST=off` with `GP_CHECKOUT_METHODS=nostr`
|
||||
would advertise a Nostr method that nothing is listening for. If you disable
|
||||
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
|
||||
@@ -176,6 +229,27 @@ curl http://127.0.0.1:8080/health
|
||||
./ci.sh # cargo fmt --check, clippy -D warnings, tests
|
||||
```
|
||||
|
||||
## Connectors
|
||||
|
||||
Store integrations live under `connectors/` and all speak the same
|
||||
create-invoice + signed-webhook contract:
|
||||
|
||||
- `connectors/woocommerce` — a WordPress/WooCommerce gateway (classic + Blocks).
|
||||
- `connectors/medusa` — a Medusa v2 payment-module provider.
|
||||
- The generic REST connector is built in: `POST /invoice` plus the webhook.
|
||||
|
||||
Refunds are unsupported/manual everywhere (GoblinPay is receive-only).
|
||||
|
||||
## Deploy
|
||||
|
||||
`deploy/` holds a reproducible deployment: a hardened systemd unit
|
||||
(`gp-server.service`) with `deploy/install.sh` for bare metal, and a
|
||||
`docker-compose.yml` that brings up the server, the bundled relay, and an
|
||||
auto-HTTPS Caddy proxy. CI (`.github` / `.gitea` workflows) runs fmt, clippy,
|
||||
and tests. See `deploy/` for details.
|
||||
|
||||
## Credits
|
||||
|
||||
GoblinPay is developed with the help of Claude (Anthropic).
|
||||
|
||||
Built with AI pair-programming assistance (Claude)
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
# GoblinPay's part of the Nym → Tor migration
|
||||
|
||||
**Status:** Planning document only — no code or config changed by this document.
|
||||
**Date:** 2026-07-04
|
||||
**Scope:** What, if anything, GoblinPay needs to do as Goblin moves its Nostr transport off the Nym mixnet and onto Tor. The overall decision and the wallet-side work are covered in `goblin/docs/PRIVACY-TRANSPORT-REDESIGN.md` — that document is the source of truth for *why* this move is happening; this one is about GoblinPay specifically.
|
||||
|
||||
The short version, up front: GoblinPay is already in the state this migration is trying to reach. There is no fire here, and most of what follows is either "nothing to do" or "housekeeping, whenever it's convenient."
|
||||
|
||||
---
|
||||
|
||||
## The framing, carried through
|
||||
|
||||
The wallet plan puts it this way: **Tor hides the user's IP from the relay; the relay + protocol hide everything else.** That framing is worth restating here because it explains why GoblinPay's part of this story is small. GoblinPay is receive-only infrastructure, not the party whose IP privacy is actually at stake in a payment — that's the paying customer, and their privacy rides on their own Goblin Wallet, not on anything GoblinPay does. And the "protocol hides everything else" half of that sentence — NIP-44 encryption inside a NIP-59 gift-wrap — was never Nym's job to begin with, and nothing about this migration touches it.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 / where we stand today: nothing is broken, nothing is urgent
|
||||
|
||||
GoblinPay has had its own in-process Nym mixnet client since it was built — a direct port of the wallet's, living at `crates/gp-nostr/src/nym/` — gated behind an environment variable, `GP_NYM`, in `/opt/goblin/goblinpay/goblinpay.env` on us-east.
|
||||
|
||||
**`GP_NYM` is already set to `off` in production, and has been for more than a day.** That was confirmed on a live check of the running server. Practically, this means GoblinPay is not caught up in the mixnet's death at all: it is already reaching its relays over clearnet, today, in production, live, taking real payments — which is exactly the end state this whole migration is aiming for. There's no incident to respond to and no clock running out.
|
||||
|
||||
It's worth being precise about what "off" actually means at runtime, because it's a clean switch rather than a degraded state. Reading `crates/gp-nostr/src/service.rs`: when the config's `opts.nym` is false, the code takes a genuinely separate branch — it builds a plain Nostr client with no Nym transport wired in at all (no tunnel start, no warm-up wait, no dial attempt), and logs a calm, expected line on every boot: `GP_NYM=off — this server's relay traffic goes CLEARNET (supported: the payer's wallet still provides sender privacy; the payload stays gift-wrapped)`. That's not a fallback after something failed; it's an intentional, supported posture the code was written to handle. The surrounding comments in the codebase say as much directly: `GP_NYM=off` is "a supported production posture, not just a debugging switch."
|
||||
|
||||
One thing worth flagging so it doesn't read as a discrepancy: the *default* baked into the code, if the environment variable were ever unset, is still `on` (`crates/gp-core/src/config.rs`, and `deploy/.env.example` ships `GP_NYM=on` as its template default). Production has explicitly overridden that default to `off`. That override predates this plan — whoever set it made the right call already, presumably as GoblinPay was riding out the mixnet's earlier flakiness. This document doesn't need to change that decision; it just needs to catch up the paperwork and, eventually, the code, to match it.
|
||||
|
||||
---
|
||||
|
||||
## What doesn't change: encryption and payer privacy are someone else's job
|
||||
|
||||
Two things that are easy to lump in with "the Nym migration" but are actually untouched by any of it:
|
||||
|
||||
**Content encryption.** Every payment that arrives as a Nostr message is sealed with NIP-44 inside a NIP-59 gift-wrap (kind 1059), exactly as it is today. That's a property of the message format, not of the pipe it travels through. Nothing about the wallet moving from Nym to Tor, and nothing about GoblinPay's own `GP_NYM` setting, has ever touched this.
|
||||
|
||||
**Payer privacy.** The privacy a paying customer actually gets — their IP hidden from the relay, and the timing-privacy work the wallet plan describes — comes entirely from the *customer's* own Goblin Wallet and whatever transport it uses. That was true when the wallet used Nym and stays true once it uses Tor. GoblinPay's `GP_NYM` setting never had anything to do with it: that switch only ever controlled whether *GoblinPay's own server* hid its own IP from the relay it talks to — a much narrower, much lower-stakes thing. So the wallet's move to Tor improves the customer's privacy on its own, with no required change on GoblinPay's side at all.
|
||||
|
||||
Worth stating here too, since it's easy to forget when talking about "the Nostr path": GoblinPay also accepts payment by having the customer paste a slatepack (`grin1`) directly into the checkout page, with no Goblin Wallet and no Nostr involved. That path is plain HTTPS from end to end and has never gone anywhere near Nym, and won't go anywhere near Tor either. It's structurally outside this entire migration.
|
||||
|
||||
---
|
||||
|
||||
## Cleanup phase: retiring the ported Nym client (do this once the wallet's Tor build is out and settled)
|
||||
|
||||
This is not urgent — it's housekeeping to schedule after the wallet's own Tor migration has shipped and had some time to prove itself, so GoblinPay isn't left as the one place still carrying old ported code for no live reason.
|
||||
|
||||
GoblinPay's copy of the Nym client lives at `crates/gp-nostr/src/nym/`: four files (`mod.rs`, `transport.rs`, `nymproc.rs`, `dns.rs`) totaling 599 lines, ported from the wallet's own `src/nym`. It's linked in-process through a path dependency on `smolmix` (`crates/gp-nostr/Cargo.toml`, pinned to a specific revision of the sibling `nym` checkout), plus `hickory-proto`, which is only in the dependency graph to give `nym/dns.rs` its mix-DNS wire codec — nothing else in `gp-nostr` calls it.
|
||||
|
||||
Because `GP_NYM` is already off in production, deleting all of this is **pure subtraction**: the code path that remains after the deletion is the exact code path already running live today, so there's no behavior to migrate and no new path to prove out — just less code to carry. The concrete list:
|
||||
|
||||
- Delete `crates/gp-nostr/src/nym/` in full (all four files, 599 lines).
|
||||
- Remove the `smolmix` and `hickory-proto` dependency declarations from `crates/gp-nostr/Cargo.toml`, along with the comment block explaining the pinned revision.
|
||||
- Remove the `GP_NYM` (and the debug-only `GP_NYM_IPR` exit-pin override) config plumbing from `crates/gp-core/src/config.rs`, and the `opts.nym` if/else branch in `crates/gp-nostr/src/service.rs`. After this, GoblinPay simply always builds the plain clearnet client — again, exactly what it already does.
|
||||
- Trim the *nym-specific slice* of the build plumbing. This needs one careful distinction, because the Dockerfile and compose file currently vendor two sibling trees together, and only one of them is going away:
|
||||
- `deploy/Dockerfile` vendors both `nip44/` and `nym/` as sibling checkouts (see its header comment and the `COPY nip44` / `COPY nym` lines). Only the `nym` half is Nym-related — `nip44` is the unrelated NIP-44 v3 companion crate and isn't part of this migration. So: drop the `COPY nym ./nym` line and the parts of the header comment naming `nym` as a required sibling, but **keep** the `nip44` vendoring and the workspace-parent build context that supports it.
|
||||
- `deploy/docker-compose.yml` justifies its `context: ../..` build context (instead of building from the repo root) by pointing at both `nip44/` and `nym/` as sibling trees the Dockerfile needs. That context has to stay for `nip44`'s sake regardless of this cleanup — only its comment's mention of `nym/` should come out.
|
||||
- `deploy/install.sh` has the same "build prerequisite" comment naming both sibling trees; trim the `nym/` mention, keep the `nip44/` one.
|
||||
- `.gitea/workflows/ci.yml` and `.github/workflows/ci.yml` (both at line 52) gate the full CI test run on three sibling checkouts being present: `../nip44`, `../nym/smolmix/core`, and `../goblin`. Drop the `nym` leg of that check; keep the other two.
|
||||
|
||||
End state: a smaller dependency tree, one fewer pinned external-repo revision to track, and — because production has already been running without it — no behavior change to test for beyond "GoblinPay still boots and still moves money."
|
||||
|
||||
---
|
||||
|
||||
## Optional parity phase: hiding GoblinPay's own server-to-relay hop
|
||||
|
||||
If there's ever a reason to go further, GoblinPay could dial its co-located relay over that relay's `.onion` address — the same onion service the wallet will be pinning and dialing — instead of reaching it over clearnet. That would restore the one thing `GP_NYM` used to provide: hiding GoblinPay's own server IP from the relay it talks to.
|
||||
|
||||
This is genuinely optional, not a recommended follow-on. The relays it would apply to — `relay.floonet.dev`, and `nrelay.us-ea.st` once its container is running again — are co-located on the same box as GoblinPay itself. Reaching them is already close to a localhost hop, so there's very little real exposure left to close. Worth doing only if it falls out cheaply once those relays' onion services exist for the wallet's sake anyway — not worth its own dedicated effort.
|
||||
|
||||
---
|
||||
|
||||
## A decision for the owner: trim the relay list?
|
||||
|
||||
GoblinPay currently runs in `external` relay mode with three relays configured: `relay.floonet.dev` and `nrelay.us-ea.st`, both co-located on the same box as GoblinPay (the latter's backend container is currently crashed — a separate, pre-existing infra issue, addressed below, not part of this migration), and `relay.damus.io`, a genuine public third-party relay.
|
||||
|
||||
Because `relay.damus.io` is a real external party, it sees GoblinPay's server IP on every connection, in the clear — and the optional onion-dialing above wouldn't change that, since damus.io isn't one of the co-located relays it would apply to. Trimming `GP_RELAYS` down to the co-located relay(s) only, done alongside the cleanup phase, would remove that exposure entirely.
|
||||
|
||||
The tradeoff, stated plainly: fewer relays means less redundant reach if a co-located relay goes down — which is exactly the situation with `nrelay.us-ea.st` right now, and with only `relay.floonet.dev` left in that scenario, GoblinPay's inbox would have no relay left to fall back on. Weighed against that is one fewer third party watching the server's connection metadata. This is a real reliability-versus-exposure call, and it belongs to the owner — this plan is flagging it, not making it.
|
||||
|
||||
---
|
||||
|
||||
## Copy fixes
|
||||
|
||||
Three connector-facing strings currently describe a payment as traveling "over Nostr (optionally over the Nym mixnet)":
|
||||
|
||||
- `connectors/woocommerce/goblinpay-woocommerce.php:5` (the plugin's `Description:` header)
|
||||
- `connectors/woocommerce/README.md:6`
|
||||
- `connectors/medusa/README.md:6`
|
||||
|
||||
Worth reading these carefully before editing them, because the parenthetical describes the *payer's* wallet transport (the customer's Goblin Wallet choosing how to reach the relay) — GoblinPay has no say in that hop at all. So once the wallet ships Tor, the honest fix isn't a find-and-replace of "Nym" with "Tor" as if it were GoblinPay's own setting — it's rewording to make clear this is about the payer's wallet, something like "(the payer's wallet may route this over Tor)," or simply dropping the parenthetical if it reads as clutter in a merchant-facing doc.
|
||||
|
||||
Two more mentions turned up while confirming these file details, both describing GoblinPay's *own* transport rather than the payer's, and both outside the three files above: the root `README.md` has a paragraph (around line 44) explaining that "by default all relay traffic rides an in-process Nym mixnet tunnel," and a `GP_NYM` row in its configuration table (around line 84). `deploy/.env.example`'s `GP_NYM=on` line and its surrounding comment describe the same thing. These three shouldn't be reworded to say "Tor" — GoblinPay isn't gaining a Tor transport of its own, it's simply losing the Nym one it had — so they should be rewritten or deleted as part of the cleanup phase above, alongside the code they describe, rather than treated as a standalone copy pass now.
|
||||
|
||||
Net: the three connector-doc fixes are a small, low-risk, copy-only change that can land anytime, independent of everything else here. The `README.md` / `.env.example` mentions should wait and land together with the cleanup phase, since they document code that phase removes.
|
||||
|
||||
---
|
||||
|
||||
## Risks and notes
|
||||
|
||||
- **This is a live store.** GoblinPay is processing real payments into the cryptodrip WooCommerce store. Even though the cleanup phase is pure subtraction of already-unused code, build and test it off-prod first (a branch build, or at minimum a staging run of `gp-server`) before it lands on us-east.
|
||||
- **The slatepack (`grin1`) path must keep working exactly as it does today.** It's structurally untouched by everything in this plan (it never used Nostr), but "nothing should have changed" is worth a deliberate regression check after the cleanup phase rather than an assumption.
|
||||
- **`nrelay.us-ea.st`'s crashed backend container is a separate, pre-existing issue.** It's mentioned here only because it happens to be one of the three relays in GoblinPay's list. Fixing it is out of scope for this plan and should be handled on its own.
|
||||
- **None of this is time-pressured.** The thing that made the wallet's migration urgent — Nym's free-bandwidth grant expiring on a schedule and the paid replacement requiring a token the project won't hold — doesn't apply here, because `GP_NYM` is already off. Sequence the cleanup phase whenever convenient, ideally after the wallet's Tor build has shipped and proven itself.
|
||||
|
||||
---
|
||||
|
||||
## Bottom line
|
||||
|
||||
GoblinPay doesn't need to do anything today. `GP_NYM=off` is already the stable, running posture in production and has been for a while — GoblinPay's server traffic already goes over clearnet to its relays, which is exactly where it would end up even if every step below were finished tomorrow. What's left is housekeeping: delete the 599-line ported Nym client and its dependencies once the dust settles, fix three doc strings that describe the *payer's* transport (not GoblinPay's), and let the owner weigh in on whether `relay.damus.io` stays in the relay list. The things that actually matter for a customer's privacy — the gift-wrap encryption, and the customer's own wallet's transport — were never GoblinPay's to change in the first place.
|
||||
@@ -0,0 +1,94 @@
|
||||
# Installing GoblinPay for Medusa
|
||||
|
||||
This is a Medusa v2 payment-module provider. There are two ways to add it.
|
||||
|
||||
## 1. Add the provider to your Medusa app
|
||||
|
||||
### Option A: copy the source (simplest)
|
||||
|
||||
Copy this `medusa` directory into your Medusa app as a module, for example
|
||||
`src/modules/goblinpay`, keeping the `src/` files (`index.ts`, `service.ts`,
|
||||
`types.ts`). Medusa compiles it with the rest of your app.
|
||||
|
||||
### Option B: install as a package
|
||||
|
||||
Publish or vendor `medusa-payment-goblinpay` and add it to your app's
|
||||
dependencies. Build it first with `npm run build` (emits `dist/`).
|
||||
|
||||
## 2. Register it in `medusa-config.ts`
|
||||
|
||||
Add GoblinPay to the payment module's `providers`. Use `id: "goblinpay"` so the
|
||||
webhook route is predictable (see step 4):
|
||||
|
||||
```ts
|
||||
module.exports = defineConfig({
|
||||
// ...
|
||||
modules: [
|
||||
{
|
||||
resolve: "@medusajs/medusa/payment",
|
||||
options: {
|
||||
providers: [
|
||||
{
|
||||
// Option A: the path to the copied module.
|
||||
resolve: "./src/modules/goblinpay",
|
||||
// Option B: the package name, "medusa-payment-goblinpay".
|
||||
id: "goblinpay",
|
||||
options: {
|
||||
baseUrl: process.env.GOBLINPAY_URL,
|
||||
apiToken: process.env.GOBLINPAY_API_TOKEN,
|
||||
webhookSecret: process.env.GOBLINPAY_WEBHOOK_SECRET,
|
||||
matchMode: "derived",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
## 3. Set the environment
|
||||
|
||||
In your Medusa app's `.env`:
|
||||
|
||||
```
|
||||
GOBLINPAY_URL=https://pay.example
|
||||
GOBLINPAY_API_TOKEN=<the same value as GP_API_TOKEN on the server>
|
||||
GOBLINPAY_WEBHOOK_SECRET=<the same value as GP_WEBHOOK_SECRET on the server>
|
||||
```
|
||||
|
||||
Then enable the `goblinpay` provider in the region(s) that should offer Grin,
|
||||
via the Medusa admin (Settings, then Regions, then Payment Providers).
|
||||
|
||||
## 4. Register the webhook in GoblinPay
|
||||
|
||||
Point your GoblinPay server at the Medusa payment webhook route. The route id is
|
||||
`<provider id>_<identifier>`, both `goblinpay`, so set these on the GoblinPay
|
||||
side:
|
||||
|
||||
- `GP_WEBHOOK_URL` = `https://YOUR-MEDUSA-HOST/hooks/payment/goblinpay_goblinpay`
|
||||
- `GP_WEBHOOK_SECRET` = the same secret you set as `webhookSecret`.
|
||||
- `GP_API_TOKEN` = the same token you set as `apiToken`.
|
||||
|
||||
GoblinPay signs each delivery with `X-GoblinPay-Signature: sha256=<hmac>` over
|
||||
the raw body and sends an idempotency key in `X-GoblinPay-Delivery`. The provider
|
||||
verifies the signature (constant-time) and flips the payment to captured.
|
||||
|
||||
Make sure the Medusa host is reachable from the GoblinPay host. If a webhook is
|
||||
ever missed, Medusa's `getPaymentStatus` polls
|
||||
`GET {baseUrl}/invoice/{invoice_id}` (with the bearer token) as a fallback.
|
||||
|
||||
## 5. Test
|
||||
|
||||
Place a test order, choose Grin (GoblinPay), and confirm:
|
||||
|
||||
- The storefront shows the GoblinPay QR / redirects to the `/pay/<token>` page
|
||||
(the checkout details are on the payment session's `data.goblinpay`).
|
||||
- Paying from a Goblin Wallet moves the order's payment to captured once
|
||||
GoblinPay delivers the `payment.received` webhook.
|
||||
|
||||
## Refund caveat
|
||||
|
||||
Refunds are not automated. GoblinPay is receive-only and never sends Grin, so
|
||||
any refund is a manual Grin send performed by the merchant from a wallet under
|
||||
their control. `refundPayment` throws to make this explicit.
|
||||
@@ -0,0 +1,90 @@
|
||||
# GoblinPay for Medusa
|
||||
|
||||
Accept Grin (GRIN / MimbleWimble) payments in a Medusa v2 store through a
|
||||
self-hosted GoblinPay server. The customer pays from their Goblin Wallet by
|
||||
scanning an `nprofile` QR code. The payment travels as a gift-wrapped slatepack
|
||||
over Nostr (optionally over the Nym mixnet). GoblinPay receives it, returns the
|
||||
reply slatepack to the payer, watches the chain to confirm, and notifies Medusa
|
||||
through a signed webhook.
|
||||
|
||||
This provider is a thin client. All of the Grin and Nostr work happens in
|
||||
GoblinPay; Medusa only talks HTTP to your GoblinPay instance. No BTCPay, no node
|
||||
exposed to the store, no wallet RPC.
|
||||
|
||||
## What it does
|
||||
|
||||
- Registers a `goblinpay` payment provider in the Medusa v2 payment module.
|
||||
- On checkout, calls GoblinPay to create an invoice for the payment session and
|
||||
stores the checkout details (`pay_url`, `nprofile`, `qr_svg`) on the session
|
||||
so your storefront can render the QR or redirect to GoblinPay's hosted
|
||||
`/pay/<token>` page.
|
||||
- Captures the payment when GoblinPay reports it, via a signed webhook. If a
|
||||
webhook is missed, `authorizePayment` and `getPaymentStatus` poll GoblinPay
|
||||
for the invoice status as a fallback.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Medusa v2 (built against `@medusajs/framework` 2.12; 2.x expected to work).
|
||||
- Node 20 or newer.
|
||||
- A running GoblinPay server reachable from the Medusa host.
|
||||
|
||||
## Options
|
||||
|
||||
Set these per-provider in `medusa-config.ts` (see INSTALL.md):
|
||||
|
||||
- `baseUrl`: base URL of your GoblinPay server, no trailing slash, for example
|
||||
`https://pay.example`.
|
||||
- `apiToken`: the GoblinPay create-invoice bearer token (`GP_API_TOKEN` on the
|
||||
server).
|
||||
- `webhookSecret`: the shared HMAC secret (`GP_WEBHOOK_SECRET` on the server).
|
||||
- `matchMode` (optional): how GoblinPay ties an incoming payment to the order.
|
||||
`derived` (per-invoice identity, recommended) gives each order its own QR and
|
||||
is the most reliable. `memo` and `amount` are also available. Omit to use the
|
||||
server default.
|
||||
- `expirySecs` (optional): invoice expiry in seconds from creation.
|
||||
|
||||
## Webhook
|
||||
|
||||
GoblinPay reports payments to the Medusa payment module's built-in webhook
|
||||
route. Point your GoblinPay server's `GP_WEBHOOK_URL` at:
|
||||
|
||||
```
|
||||
https://YOUR-MEDUSA-HOST/hooks/payment/goblinpay_goblinpay
|
||||
```
|
||||
|
||||
The provider verifies the `X-GoblinPay-Signature: sha256=<hmac>` header against
|
||||
the exact raw body (constant-time) before acting.
|
||||
|
||||
## Status mapping
|
||||
|
||||
| GoblinPay | Medusa payment session |
|
||||
|---|---|
|
||||
| invoice `open` | `pending` |
|
||||
| invoice `paid` | `captured` |
|
||||
| invoice `expired` | `canceled` |
|
||||
| webhook `payment.received` | captured (SUCCESSFUL) |
|
||||
| webhook `payment.confirmed` | captured (SUCCESSFUL, idempotent) |
|
||||
|
||||
For a receive-only till, a received payment (the reply slatepack is back and the
|
||||
funds are in the merchant wallet) is treated as paid, the same as the
|
||||
WooCommerce connector. The later on-chain confirmation is an idempotent no-op.
|
||||
|
||||
## Refunds
|
||||
|
||||
Refunds are not automated. GoblinPay is receive-only: it never sends Grin. A
|
||||
refund is therefore a manual, out-of-band Grin send by the merchant from a
|
||||
wallet under their control. `refundPayment` throws to make this explicit, the
|
||||
same caveat the Grin BTCPay connector carries.
|
||||
|
||||
## Security notes
|
||||
|
||||
- The webhook is authenticated by an HMAC-SHA256 signature over the exact raw
|
||||
request body, compared in constant time. A bad or missing signature is
|
||||
rejected and the payment is not flipped.
|
||||
- The capture amount is read from the Medusa payment session (its own
|
||||
store-currency amount), not from untrusted webhook fields.
|
||||
- Secrets live in the provider options / environment, never in code.
|
||||
|
||||
## Credit
|
||||
|
||||
Built by Claude (Anthropic) for the Goblin project.
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "medusa-payment-goblinpay",
|
||||
"version": "1.0.0",
|
||||
"description": "GoblinPay payment provider for Medusa v2: accept Grin (GRIN / MimbleWimble) payments through a self-hosted, receive-only GoblinPay server.",
|
||||
"license": "Apache-2.0",
|
||||
"keywords": [
|
||||
"medusa",
|
||||
"medusa-v2",
|
||||
"medusa-plugin",
|
||||
"medusa-plugin-payment",
|
||||
"payment",
|
||||
"grin",
|
||||
"mimblewimble",
|
||||
"goblinpay"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.us-ea.st/GRIN/GoblinPay"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md",
|
||||
"INSTALL.md"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@medusajs/framework": "^2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@medusajs/framework": "^2.12.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"typescript": "^5.4.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ModuleProvider, Modules } from "@medusajs/framework/utils"
|
||||
|
||||
import GoblinPayProviderService from "./service"
|
||||
|
||||
// Register GoblinPay as a Medusa v2 payment-module provider. Referenced from
|
||||
// medusa-config.ts under the payment module's `providers` (see INSTALL.md).
|
||||
export default ModuleProvider(Modules.PAYMENT, {
|
||||
services: [GoblinPayProviderService],
|
||||
})
|
||||
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* GoblinPay payment provider for Medusa v2 (tested against @medusajs 2.12).
|
||||
*
|
||||
* Modeled on connectors/woocommerce and on the reference
|
||||
* github.com/SGFGOV/medusa-payment-plugins (packages/medusa-plugin-btcpay).
|
||||
*
|
||||
* Flow: `initiatePayment` creates a GoblinPay invoice for the order and stashes
|
||||
* the checkout details (pay_url, nprofile, qr_svg) on the session so the
|
||||
* storefront can render or redirect. The customer pays from their Goblin Wallet;
|
||||
* GoblinPay receives it, returns the reply slatepack, watches the chain, and
|
||||
* POSTs a signed webhook. `getWebhookActionAndData` verifies the HMAC and flips
|
||||
* the Medusa payment to captured. Status polling (`authorizePayment`,
|
||||
* `getPaymentStatus`) is the webhook-miss fallback.
|
||||
*
|
||||
* Refunds are NOT automated: GoblinPay is receive-only (it never sends Grin), so
|
||||
* `refundPayment` throws. A refund is a manual, out-of-band Grin send by the
|
||||
* merchant. See README.md.
|
||||
*/
|
||||
import crypto from "node:crypto"
|
||||
|
||||
import {
|
||||
AbstractPaymentProvider,
|
||||
ContainerRegistrationKeys,
|
||||
MedusaError,
|
||||
Modules,
|
||||
PaymentActions,
|
||||
} from "@medusajs/framework/utils"
|
||||
import type {
|
||||
AuthorizePaymentInput,
|
||||
AuthorizePaymentOutput,
|
||||
CancelPaymentInput,
|
||||
CancelPaymentOutput,
|
||||
CapturePaymentInput,
|
||||
CapturePaymentOutput,
|
||||
DeletePaymentInput,
|
||||
DeletePaymentOutput,
|
||||
GetPaymentStatusInput,
|
||||
GetPaymentStatusOutput,
|
||||
InitiatePaymentInput,
|
||||
InitiatePaymentOutput,
|
||||
IPaymentModuleService,
|
||||
Logger,
|
||||
ProviderWebhookPayload,
|
||||
RefundPaymentInput,
|
||||
RefundPaymentOutput,
|
||||
RetrievePaymentInput,
|
||||
RetrievePaymentOutput,
|
||||
UpdatePaymentInput,
|
||||
UpdatePaymentOutput,
|
||||
WebhookActionResult,
|
||||
} from "@medusajs/framework/types"
|
||||
|
||||
import type { GoblinPayInvoice, GoblinPayOptions } from "./types"
|
||||
|
||||
class GoblinPayProviderService extends AbstractPaymentProvider<GoblinPayOptions> {
|
||||
static identifier = "goblinpay"
|
||||
|
||||
protected readonly options_: GoblinPayOptions
|
||||
protected readonly logger_: Logger
|
||||
protected readonly paymentService_: IPaymentModuleService
|
||||
|
||||
constructor(container: Record<string, unknown>, options: GoblinPayOptions) {
|
||||
super(container as never, options)
|
||||
this.options_ = options
|
||||
this.logger_ = container[ContainerRegistrationKeys.LOGGER] as Logger
|
||||
this.paymentService_ = container[Modules.PAYMENT] as IPaymentModuleService
|
||||
|
||||
if (!options?.baseUrl || !options?.apiToken || !options?.webhookSecret) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
"GoblinPay provider requires baseUrl, apiToken, and webhookSecret options"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private get base(): string {
|
||||
return this.options_.baseUrl.replace(/\/+$/, "")
|
||||
}
|
||||
|
||||
/** Call the GoblinPay REST API with the bearer token. */
|
||||
private async request<T>(
|
||||
method: "GET" | "POST",
|
||||
path: string,
|
||||
body?: unknown
|
||||
): Promise<T> {
|
||||
const res = await fetch(`${this.base}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${this.options_.apiToken}`,
|
||||
...(body ? { "Content-Type": "application/json" } : {}),
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
const text = await res.text()
|
||||
const json = text ? JSON.parse(text) : {}
|
||||
if (!res.ok) {
|
||||
const err =
|
||||
(json && (json.error as string)) || `GoblinPay HTTP ${res.status}`
|
||||
throw new MedusaError(MedusaError.Types.UNEXPECTED_STATE, err)
|
||||
}
|
||||
return json as T
|
||||
}
|
||||
|
||||
/** Map a GoblinPay invoice status to a Medusa payment session status. */
|
||||
private static mapStatus(
|
||||
status: string
|
||||
): "captured" | "canceled" | "pending" {
|
||||
switch (status) {
|
||||
case "paid":
|
||||
return "captured"
|
||||
case "expired":
|
||||
return "canceled"
|
||||
default:
|
||||
return "pending"
|
||||
}
|
||||
}
|
||||
|
||||
async initiatePayment(
|
||||
input: InitiatePaymentInput
|
||||
): Promise<InitiatePaymentOutput> {
|
||||
const sessionId = input.context?.idempotency_key
|
||||
if (!sessionId) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
"Idempotency key (payment session id) is required to initiate payment"
|
||||
)
|
||||
}
|
||||
|
||||
// GoblinPay prices the fiat order into Grin via its own oracle. The order's
|
||||
// session id is the order_ref, so the signed webhook echoes it back to us.
|
||||
const invoice = await this.request<GoblinPayInvoice>("POST", "/invoice", {
|
||||
order_ref: sessionId,
|
||||
amount_fiat: input.amount.toString(),
|
||||
currency: input.currency_code,
|
||||
memo: `Medusa order ${sessionId}`,
|
||||
...(this.options_.matchMode ? { match_mode: this.options_.matchMode } : {}),
|
||||
...(this.options_.expirySecs ? { expiry_secs: this.options_.expirySecs } : {}),
|
||||
})
|
||||
|
||||
return {
|
||||
id: sessionId,
|
||||
data: { ...input.data, goblinpay: invoice },
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-read the current invoice from GoblinPay using the stored invoice_id. */
|
||||
private async fetchInvoice(
|
||||
data: Record<string, unknown> | undefined
|
||||
): Promise<GoblinPayInvoice> {
|
||||
const stored = (data?.goblinpay ?? {}) as GoblinPayInvoice
|
||||
if (!stored.invoice_id) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
"No GoblinPay invoice_id on the payment session"
|
||||
)
|
||||
}
|
||||
return this.request<GoblinPayInvoice>(
|
||||
"GET",
|
||||
`/invoice/${encodeURIComponent(stored.invoice_id)}`
|
||||
)
|
||||
}
|
||||
|
||||
async authorizePayment(
|
||||
input: AuthorizePaymentInput
|
||||
): Promise<AuthorizePaymentOutput> {
|
||||
const invoice = await this.fetchInvoice(input.data)
|
||||
return {
|
||||
status: GoblinPayProviderService.mapStatus(invoice.status),
|
||||
data: { ...input.data, goblinpay: invoice },
|
||||
}
|
||||
}
|
||||
|
||||
async getPaymentStatus(
|
||||
input: GetPaymentStatusInput
|
||||
): Promise<GetPaymentStatusOutput> {
|
||||
const invoice = await this.fetchInvoice(input.data)
|
||||
return {
|
||||
status: GoblinPayProviderService.mapStatus(invoice.status),
|
||||
data: { ...input.data, goblinpay: invoice },
|
||||
}
|
||||
}
|
||||
|
||||
async capturePayment(
|
||||
input: CapturePaymentInput
|
||||
): Promise<CapturePaymentOutput> {
|
||||
// GoblinPay is receive-only: once the payment is received the funds are
|
||||
// already in the merchant wallet, so capture is a no-op acknowledgement.
|
||||
return { data: input.data ?? {} }
|
||||
}
|
||||
|
||||
async cancelPayment(
|
||||
input: CancelPaymentInput
|
||||
): Promise<CancelPaymentOutput> {
|
||||
// Nothing to cancel server-side; an unpaid GoblinPay invoice simply expires.
|
||||
return { data: input.data ?? {} }
|
||||
}
|
||||
|
||||
async deletePayment(
|
||||
input: DeletePaymentInput
|
||||
): Promise<DeletePaymentOutput> {
|
||||
return { data: input.data ?? {} }
|
||||
}
|
||||
|
||||
async refundPayment(
|
||||
_input: RefundPaymentInput
|
||||
): Promise<RefundPaymentOutput> {
|
||||
// Receive-only: GoblinPay never sends Grin, so refunds cannot be automated.
|
||||
// A refund is a manual, out-of-band Grin send by the merchant.
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_ALLOWED,
|
||||
"GoblinPay is receive-only; refunds must be issued manually by the merchant (out-of-band Grin send)."
|
||||
)
|
||||
}
|
||||
|
||||
async retrievePayment(
|
||||
input: RetrievePaymentInput
|
||||
): Promise<RetrievePaymentOutput> {
|
||||
const invoice = await this.fetchInvoice(input.data)
|
||||
return { data: { ...input.data, goblinpay: invoice } }
|
||||
}
|
||||
|
||||
async updatePayment(
|
||||
input: UpdatePaymentInput
|
||||
): Promise<UpdatePaymentOutput> {
|
||||
return { data: input.data ?? {} }
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the HMAC-SHA256 over the EXACT raw body, constant-time. Mirrors the
|
||||
* WooCommerce connector and GoblinPay's webhook contract:
|
||||
* X-GoblinPay-Signature: sha256=<hex(HMAC-SHA256(secret, raw_body))>
|
||||
*/
|
||||
private verifySignature(payload: ProviderWebhookPayload["payload"]): boolean {
|
||||
const raw = payload.rawData
|
||||
if (!raw) {
|
||||
return false
|
||||
}
|
||||
const provided = (payload.headers?.["x-goblinpay-signature"] as string) ?? ""
|
||||
const expected =
|
||||
"sha256=" +
|
||||
crypto
|
||||
.createHmac("sha256", this.options_.webhookSecret)
|
||||
.update(raw as string | Buffer)
|
||||
.digest("hex")
|
||||
const a = Buffer.from(provided, "utf8")
|
||||
const b = Buffer.from(expected, "utf8")
|
||||
return a.length === b.length && crypto.timingSafeEqual(a, b)
|
||||
}
|
||||
|
||||
async getWebhookActionAndData(
|
||||
payload: ProviderWebhookPayload["payload"]
|
||||
): Promise<WebhookActionResult> {
|
||||
if (!this.verifySignature(payload)) {
|
||||
this.logger_.warn("goblinpay: webhook signature mismatch")
|
||||
return { action: PaymentActions.FAILED }
|
||||
}
|
||||
|
||||
const data = (payload.data ?? {}) as {
|
||||
event_type?: string
|
||||
order_ref?: string
|
||||
}
|
||||
const sessionId = data.order_ref
|
||||
if (!sessionId) {
|
||||
return { action: PaymentActions.NOT_SUPPORTED }
|
||||
}
|
||||
|
||||
// payment.received (funds in hand, S2 returned) and payment.confirmed
|
||||
// (on-chain) both mean paid for a receive-only till: flip to captured. The
|
||||
// capture is idempotent, so a later confirmation after a received event is a
|
||||
// no-op. Capture the session's own (store-currency) amount.
|
||||
if (
|
||||
data.event_type === "payment.received" ||
|
||||
data.event_type === "payment.confirmed"
|
||||
) {
|
||||
const amount = await this.sessionAmount(sessionId)
|
||||
return {
|
||||
action: PaymentActions.SUCCESSFUL,
|
||||
data: { session_id: sessionId, amount },
|
||||
}
|
||||
}
|
||||
|
||||
return { action: PaymentActions.NOT_SUPPORTED }
|
||||
}
|
||||
|
||||
/** The payment session's authorized amount, for the webhook capture. */
|
||||
private async sessionAmount(sessionId: string): Promise<number> {
|
||||
try {
|
||||
const session = await this.paymentService_.retrievePaymentSession(sessionId)
|
||||
return Number(session.amount)
|
||||
} catch (e) {
|
||||
this.logger_.warn(
|
||||
`goblinpay: could not read session ${sessionId} amount: ${
|
||||
(e as Error).message
|
||||
}`
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default GoblinPayProviderService
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Options + wire types for the GoblinPay Medusa v2 payment provider.
|
||||
*
|
||||
* GoblinPay is a receive-only Grin payment server. This provider is a thin
|
||||
* client: it calls GoblinPay's REST API to create an invoice and reads back the
|
||||
* checkout details, then flips the Medusa payment on a signed webhook. All Grin
|
||||
* and Nostr work happens in GoblinPay; Medusa only speaks HTTP to it.
|
||||
*/
|
||||
|
||||
/** Provider options, set per-provider in `medusa-config.ts`. */
|
||||
export interface GoblinPayOptions {
|
||||
/** Base URL of your GoblinPay server, no trailing slash (e.g. https://pay.example). */
|
||||
baseUrl: string
|
||||
/** Bearer token for the create-invoice API (`GP_API_TOKEN` on the server). */
|
||||
apiToken: string
|
||||
/** Shared HMAC secret for webhook verification (`GP_WEBHOOK_SECRET`). */
|
||||
webhookSecret: string
|
||||
/**
|
||||
* How GoblinPay matches an incoming payment to this order. `derived`
|
||||
* (per-invoice identity, recommended) gives each order its own QR. Omit to
|
||||
* use the server default.
|
||||
*/
|
||||
matchMode?: "memo" | "derived" | "amount"
|
||||
/** Optional invoice expiry in seconds from creation. */
|
||||
expirySecs?: number
|
||||
}
|
||||
|
||||
/** The subset of GoblinPay's `/invoice` response this provider stores/uses. */
|
||||
export interface GoblinPayInvoice {
|
||||
invoice_id: string
|
||||
token?: string
|
||||
pay_url: string
|
||||
nprofile?: string
|
||||
npub?: string
|
||||
qr_svg?: string
|
||||
amount?: string
|
||||
/** GoblinPay invoice lifecycle: `open` | `paid` | `expired`. */
|
||||
status: string
|
||||
order_ref?: string
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2021"],
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
@@ -29,6 +29,14 @@ pub const DEFAULT_DATA_DIR: &str = "./gp-data";
|
||||
/// Nostr gift-wrap layer in gp-nostr).
|
||||
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,12 +142,27 @@ pub struct Config {
|
||||
pub relay_mode: RelayMode,
|
||||
/// External relays (`GP_RELAYS`, comma separated).
|
||||
pub relays: Vec<String>,
|
||||
/// URL of the bundled relay used in `bundled` relay mode
|
||||
/// (`GP_BUNDLED_RELAY_URL`, default `ws://127.0.0.1:7777`). Both dialed by
|
||||
/// the ingest service and advertised to payers in the checkout `nprofile`.
|
||||
pub bundled_relay_url: String,
|
||||
/// Route Nostr traffic over the Nym mixnet (`GP_NYM`: `on` or `off`,
|
||||
/// default on; clearnet is a debugging escape hatch only).
|
||||
/// default on). Production may deliberately run `off` (server-side
|
||||
/// clearnet): the payer's Goblin Wallet still provides sender privacy over
|
||||
/// its own mixnet, and the payload is gift-wrapped end to end regardless.
|
||||
pub nym: bool,
|
||||
/// Run the Nostr ingest service (`GP_INGEST`: `on` or `off`, default on).
|
||||
/// When on, the wallet and identity secrets are required at boot.
|
||||
pub ingest: bool,
|
||||
/// Show the Nostr (Goblin Wallet, `nprofile`) method on the hosted checkout
|
||||
/// page. Part of `GP_CHECKOUT_METHODS` (comma list of `nostr`/`slatepack`;
|
||||
/// unset = both). This gates only the hosted PAGE display, not the connector
|
||||
/// API or the ingest service.
|
||||
pub checkout_nostr: bool,
|
||||
/// Show the Slatepack (`grin1`) method on the hosted checkout page. Part of
|
||||
/// `GP_CHECKOUT_METHODS`. Still requires a loaded wallet to actually appear:
|
||||
/// an enabled method that cannot work is simply hidden.
|
||||
pub checkout_slatepack: bool,
|
||||
/// Global default matching mode (`GP_MATCH_MODE`).
|
||||
pub match_mode: MatchMode,
|
||||
/// Grin seed mnemonic (`GP_MNEMONIC` or `GP_MNEMONIC_FILE`). Money secret.
|
||||
@@ -171,7 +198,7 @@ pub struct Config {
|
||||
#[serde(skip)]
|
||||
pub webhook_secret: Option<Secret>,
|
||||
/// Center-logo source for checkout QR codes (`GP_QR_LOGO`): unset = the
|
||||
/// bundled Goblin mark, `off`/`none` = no logo, else a URL or static path.
|
||||
/// bundled GoblinPay mark, `off`/`none` = no logo, else a URL or static path.
|
||||
pub qr_logo: Option<String>,
|
||||
/// Merchant npub for confirmed-payment DMs (`GP_MERCHANT_NPUB`).
|
||||
pub merchant_npub: Option<String>,
|
||||
@@ -213,7 +240,7 @@ pub const DEFAULT_RATE_CACHE_TTL: i64 = 60;
|
||||
pub const DEFAULT_QUOTE_TTL: i64 = 900;
|
||||
|
||||
/// Default center-logo path served by gp-server when `GP_QR_LOGO` is unset.
|
||||
pub const DEFAULT_QR_LOGO: &str = "/static/goblin-mark.svg";
|
||||
pub const DEFAULT_QR_LOGO: &str = "/static/goblinpay-mark.svg";
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
@@ -226,8 +253,11 @@ impl Default for Config {
|
||||
chain: Chain::Mainnet,
|
||||
relay_mode: RelayMode::Bundled,
|
||||
relays: Vec::new(),
|
||||
bundled_relay_url: DEFAULT_BUNDLED_RELAY.into(),
|
||||
nym: true,
|
||||
ingest: true,
|
||||
checkout_nostr: true,
|
||||
checkout_slatepack: true,
|
||||
match_mode: MatchMode::Memo,
|
||||
mnemonic: None,
|
||||
wallet_password: None,
|
||||
@@ -309,6 +339,10 @@ impl Config {
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
let bundled_relay_url = get("GP_BUNDLED_RELAY_URL")
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or(defaults.bundled_relay_url);
|
||||
|
||||
let nym = match get("GP_NYM").as_deref().unwrap_or("on") {
|
||||
"on" => true,
|
||||
@@ -322,6 +356,9 @@ impl Config {
|
||||
other => return Err(format!("GP_INGEST must be `on` or `off` (got `{other}`)")),
|
||||
};
|
||||
|
||||
let (checkout_nostr, checkout_slatepack) =
|
||||
parse_checkout_methods(get("GP_CHECKOUT_METHODS"));
|
||||
|
||||
let match_mode = match get("GP_MATCH_MODE").as_deref().unwrap_or("memo") {
|
||||
"memo" => MatchMode::Memo,
|
||||
"derived" => MatchMode::Derived,
|
||||
@@ -392,8 +429,11 @@ impl Config {
|
||||
chain,
|
||||
relay_mode,
|
||||
relays,
|
||||
bundled_relay_url,
|
||||
nym,
|
||||
ingest,
|
||||
checkout_nostr,
|
||||
checkout_slatepack,
|
||||
match_mode,
|
||||
mnemonic,
|
||||
wallet_password,
|
||||
@@ -425,6 +465,18 @@ impl Config {
|
||||
self.qr_logo.as_deref()
|
||||
}
|
||||
|
||||
/// The enabled checkout methods as a stable comma list, for the startup log.
|
||||
fn checkout_methods_str(&self) -> String {
|
||||
let mut methods = Vec::new();
|
||||
if self.checkout_nostr {
|
||||
methods.push("nostr");
|
||||
}
|
||||
if self.checkout_slatepack {
|
||||
methods.push("slatepack");
|
||||
}
|
||||
methods.join(",")
|
||||
}
|
||||
|
||||
/// Fail-fast consistency checks.
|
||||
fn validate(&self) -> Result<(), String> {
|
||||
if self.bind.is_empty() {
|
||||
@@ -445,6 +497,9 @@ impl Config {
|
||||
if self.relay_mode == RelayMode::External && self.relays.is_empty() {
|
||||
return Err("GP_RELAY_MODE=external requires GP_RELAYS".into());
|
||||
}
|
||||
if self.relay_mode == RelayMode::Bundled && self.bundled_relay_url.trim().is_empty() {
|
||||
return Err("GP_RELAY_MODE=bundled requires a non-empty GP_BUNDLED_RELAY_URL".into());
|
||||
}
|
||||
if self.nsec.is_some() && self.ncryptsec.is_some() {
|
||||
return Err("set only one of GP_NSEC and GP_NCRYPTSEC".into());
|
||||
}
|
||||
@@ -479,7 +534,9 @@ impl Config {
|
||||
let set = |o: bool| if o { "set" } else { "unset" };
|
||||
format!(
|
||||
"bind={} tls={} db={} data_dir={} node={} chain={:?} relay_mode={:?} \
|
||||
relays={:?} nym={} ingest={} match_mode={:?} mnemonic={} wallet_password={} \
|
||||
relays={:?} bundled_relay={} nym={} ingest={} checkout_methods={} match_mode={:?} \
|
||||
mnemonic={} \
|
||||
wallet_password={} \
|
||||
nsec={} ncryptsec={} public_url={} api_token={} admin_token={} webhook_url={} \
|
||||
webhook_secret={} qr_logo={} merchant_npub={} notify_merchant_dm={} \
|
||||
notify_payer_receipt={} endpub_rotate_interval={} endpub_overlap_epochs={} \
|
||||
@@ -496,8 +553,10 @@ impl Config {
|
||||
self.chain,
|
||||
self.relay_mode,
|
||||
self.relays,
|
||||
self.bundled_relay_url,
|
||||
if self.nym { "on" } else { "off" },
|
||||
if self.ingest { "on" } else { "off" },
|
||||
self.checkout_methods_str(),
|
||||
self.match_mode,
|
||||
set(self.mnemonic.is_some()),
|
||||
set(self.wallet_password.is_some()),
|
||||
@@ -527,6 +586,35 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `GP_CHECKOUT_METHODS` (comma list of `nostr`/`slatepack`) into the two
|
||||
/// display flags. Parsing is lenient: tokens are trimmed, lowercased, and
|
||||
/// unknown tokens are ignored with a warning. Unset (`None`) enables both, which
|
||||
/// preserves the historical default of showing every available method. If a set
|
||||
/// value parses to no known methods, both are enabled (a checkout must offer at
|
||||
/// least one way to pay) and a warning is logged.
|
||||
fn parse_checkout_methods(raw: Option<String>) -> (bool, bool) {
|
||||
let Some(raw) = raw else {
|
||||
return (true, true);
|
||||
};
|
||||
let mut nostr = false;
|
||||
let mut slatepack = false;
|
||||
for tok in raw.split(',') {
|
||||
match tok.trim().to_lowercase().as_str() {
|
||||
"" => {}
|
||||
"nostr" => nostr = true,
|
||||
"slatepack" => slatepack = true,
|
||||
other => log::warn!("GP_CHECKOUT_METHODS: ignoring unknown method `{other}`"),
|
||||
}
|
||||
}
|
||||
if !nostr && !slatepack {
|
||||
log::warn!(
|
||||
"GP_CHECKOUT_METHODS enabled no known methods; defaulting to both (nostr,slatepack)"
|
||||
);
|
||||
return (true, true);
|
||||
}
|
||||
(nostr, slatepack)
|
||||
}
|
||||
|
||||
/// Parse an `on`/`off` flag with a default.
|
||||
fn parse_bool(
|
||||
get: &dyn Fn(&str) -> Option<String>,
|
||||
@@ -598,6 +686,7 @@ mod tests {
|
||||
assert_eq!(cfg.chain, Chain::Mainnet);
|
||||
assert_eq!(cfg.relay_mode, RelayMode::Bundled);
|
||||
assert!(cfg.relays.is_empty());
|
||||
assert_eq!(cfg.bundled_relay_url, DEFAULT_BUNDLED_RELAY);
|
||||
assert!(cfg.nym);
|
||||
assert!(cfg.ingest);
|
||||
assert_eq!(cfg.match_mode, MatchMode::Memo);
|
||||
@@ -617,6 +706,7 @@ mod tests {
|
||||
("GP_CHAIN", "testnet"),
|
||||
("GP_RELAY_MODE", "external"),
|
||||
("GP_RELAYS", "wss://relay.example, wss://relay2.example ,"),
|
||||
("GP_BUNDLED_RELAY_URL", "wss://relay.mystore.example"),
|
||||
("GP_NYM", "off"),
|
||||
("GP_INGEST", "off"),
|
||||
("GP_MATCH_MODE", "derived"),
|
||||
@@ -632,11 +722,42 @@ mod tests {
|
||||
cfg.relays,
|
||||
vec!["wss://relay.example", "wss://relay2.example"]
|
||||
);
|
||||
assert_eq!(cfg.bundled_relay_url, "wss://relay.mystore.example");
|
||||
assert!(!cfg.nym);
|
||||
assert!(!cfg.ingest);
|
||||
assert_eq!(cfg.match_mode, MatchMode::Derived);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkout_methods_default_and_parsing() {
|
||||
// Unset: both methods on (unchanged historical behavior).
|
||||
let cfg = load(&[]).unwrap();
|
||||
assert!(cfg.checkout_nostr);
|
||||
assert!(cfg.checkout_slatepack);
|
||||
|
||||
// Single method selects only that method.
|
||||
let cfg = load(&[("GP_CHECKOUT_METHODS", "nostr")]).unwrap();
|
||||
assert!(cfg.checkout_nostr);
|
||||
assert!(!cfg.checkout_slatepack);
|
||||
|
||||
let cfg = load(&[("GP_CHECKOUT_METHODS", "slatepack")]).unwrap();
|
||||
assert!(!cfg.checkout_nostr);
|
||||
assert!(cfg.checkout_slatepack);
|
||||
|
||||
// Both, order/whitespace/case insensitive, unknown tokens ignored.
|
||||
let cfg = load(&[("GP_CHECKOUT_METHODS", " Slatepack , NOSTR ,bogus,")]).unwrap();
|
||||
assert!(cfg.checkout_nostr);
|
||||
assert!(cfg.checkout_slatepack);
|
||||
|
||||
// Empty or all-garbage parses to no methods -> defaults to both.
|
||||
let cfg = load(&[("GP_CHECKOUT_METHODS", "")]).unwrap();
|
||||
assert!(cfg.checkout_nostr);
|
||||
assert!(cfg.checkout_slatepack);
|
||||
let cfg = load(&[("GP_CHECKOUT_METHODS", "lightning, bitcoin")]).unwrap();
|
||||
assert!(cfg.checkout_nostr);
|
||||
assert!(cfg.checkout_slatepack);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tls_rustls_requires_cert_and_key() {
|
||||
assert!(load(&[("GP_TLS", "rustls")]).is_err());
|
||||
|
||||
@@ -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,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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,8 +41,9 @@ const NYM_WARM_WAIT: Duration = Duration::from_secs(30);
|
||||
pub struct ServiceOptions {
|
||||
/// Relay set to listen on and publish to.
|
||||
pub relays: Vec<String>,
|
||||
/// Route everything over the Nym mixnet (default on; clearnet is a
|
||||
/// debugging escape hatch only).
|
||||
/// Route everything over the Nym mixnet (default on). `off` is a supported
|
||||
/// production posture (server-side clearnet): the payer's Goblin Wallet
|
||||
/// still rides its own mixnet, and the payload is gift-wrapped end to end.
|
||||
pub nym: bool,
|
||||
/// Optional NIP-17 payment DMs (milestone 6, all off by default).
|
||||
pub notify: NotifyOptions,
|
||||
@@ -141,7 +142,10 @@ pub async fn run<R: SlatepackReceiver>(
|
||||
.websocket_transport(NymWebSocketTransport)
|
||||
.build()
|
||||
} else {
|
||||
warn!("nostr: GP_NYM=off — relay traffic goes CLEARNET (debugging only)");
|
||||
warn!(
|
||||
"nostr: GP_NYM=off — this server's relay traffic goes CLEARNET (supported: the \
|
||||
payer's wallet still provides sender privacy; the payload stays gift-wrapped)"
|
||||
);
|
||||
Client::builder().build()
|
||||
};
|
||||
|
||||
|
||||
@@ -105,7 +105,8 @@ async fn dashboard(
|
||||
match_mode: format!("{:?}", cfg.match_mode).to_lowercase(),
|
||||
nym: cfg.nym,
|
||||
ingest: cfg.ingest,
|
||||
relay_count: gp_nostr::relays::resolve(&cfg.relays).len(),
|
||||
relay_count: gp_nostr::relays::resolve(cfg.relay_mode, &cfg.bundled_relay_url, &cfg.relays)
|
||||
.len(),
|
||||
webhook_configured: cfg.webhook_url.is_some(),
|
||||
pending_webhooks,
|
||||
rotate_interval: cfg.endpub_rotate_interval,
|
||||
@@ -206,7 +207,7 @@ struct CreateUserBody {
|
||||
}
|
||||
|
||||
fn endpub_json(cfg: &Config, user_id: &str, epoch: i64, pubkey: &str) -> serde_json::Value {
|
||||
let relays = gp_nostr::relays::resolve(&cfg.relays);
|
||||
let relays = gp_nostr::relays::resolve(cfg.relay_mode, &cfg.bundled_relay_url, &cfg.relays);
|
||||
let (npub, nprofile, qr) = match PublicKey::from_hex(pubkey) {
|
||||
Ok(pk) => (
|
||||
gp_nostr::npub_of(pk),
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
//! The hosted, zero-JS checkout: the `/pay/<token>` page (shared renderer for
|
||||
//! embedded and hosted use), its live status, and the manual-slatepack
|
||||
//! fallback.
|
||||
//! embedded and hosted use), its live status, and the Slatepack receive flow.
|
||||
//!
|
||||
//! The page shows the amount, a server-generated QR SVG of the recipient
|
||||
//! `nprofile`, the `nprofile`/`npub` strings, live status via a
|
||||
//! `<meta http-equiv="refresh">` while open, and a `<textarea>` POST form to
|
||||
//! paste an S1 slatepack when the automatic Nostr flow cannot be used. On
|
||||
//! submit, the same offline `receive_tx` runs and the S2 reply renders back for
|
||||
//! the payer to copy and finalize. No JavaScript anywhere.
|
||||
//! The page offers two first-class ways to pay: the Goblin/Nostr path (a QR
|
||||
//! SVG of the recipient `nprofile` plus the `nprofile`/`npub` strings) and a
|
||||
//! Slatepack (`grin1`) path for any Grin wallet (the wallet's stable index-0
|
||||
//! Slatepack address, its QR, and a `<textarea>` POST form to paste the S1 the
|
||||
//! payer's wallet produces). It also shows the amount and live status via a
|
||||
//! `<meta http-equiv="refresh">` while open. On submit, the same offline
|
||||
//! `receive_tx` runs and the S2 reply renders back for the payer to finalize
|
||||
//! and broadcast. The Slatepack path only appears when a wallet is loaded. No
|
||||
//! JavaScript anywhere.
|
||||
|
||||
use actix_web::{web, HttpResponse, Responder};
|
||||
use askama::Template;
|
||||
@@ -32,6 +34,12 @@ pub struct CheckoutInfo {
|
||||
pub npub: String,
|
||||
pub nprofile: String,
|
||||
pub qr_svg: String,
|
||||
/// The wallet's stable `grin1` Slatepack address, when a wallet is loaded.
|
||||
/// `None` (and no Slatepack option shown) when the instance runs with no
|
||||
/// wallet, mirroring how the manual receive handler degrades.
|
||||
pub slatepack_address: Option<String>,
|
||||
/// QR SVG of the `grin1` Slatepack address (present with the address).
|
||||
pub slatepack_qr_svg: Option<String>,
|
||||
pub amount_display: String,
|
||||
pub status: String,
|
||||
pub memo: Option<String>,
|
||||
@@ -41,14 +49,50 @@ pub struct CheckoutInfo {
|
||||
/// Build the presentation for an invoice: the nprofile, its QR, the pay URL,
|
||||
/// and a human amount. Shared by the hosted page and the connector API so both
|
||||
/// render identically.
|
||||
pub fn build_info(inv: &Invoice, cfg: &Config) -> CheckoutInfo {
|
||||
let relays = gp_nostr::relays::resolve(&cfg.relays);
|
||||
///
|
||||
/// `slatepack_addr` is the wallet's stable `grin1` Slatepack address when a
|
||||
/// wallet is loaded (the hosted page passes it so a payer can pay from any Grin
|
||||
/// wallet without Nostr); pass `None` when no wallet is available or the
|
||||
/// 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.relay_mode, &cfg.bundled_relay_url, &cfg.relays);
|
||||
let recipient_pubkey = inv.recipient_pubkey.clone().unwrap_or_default();
|
||||
let (npub, nprofile) = match PublicKey::from_hex(&recipient_pubkey) {
|
||||
Ok(pk) => (gp_nostr::npub_of(pk), gp_nostr::nprofile(pk, &relays)),
|
||||
Err(_) => (String::new(), String::new()),
|
||||
// The Nostr (Goblin Wallet) method is only surfaced when the operator has it
|
||||
// enabled (`GP_CHECKOUT_METHODS`). Disabled, the nprofile/npub/QR are left
|
||||
// empty and the template omits the whole section. This gates only the hosted
|
||||
// PAGE display; the connector API and ingest are unaffected.
|
||||
let (npub, nprofile) = if cfg.checkout_nostr {
|
||||
match PublicKey::from_hex(&recipient_pubkey) {
|
||||
Ok(pk) => (gp_nostr::npub_of(pk), gp_nostr::nprofile(pk, &relays)),
|
||||
Err(_) => (String::new(), String::new()),
|
||||
}
|
||||
} else {
|
||||
(String::new(), String::new())
|
||||
};
|
||||
// The QR carries a pay-URI so a scanning wallet can auto-fill the amount
|
||||
// (and memo). The human-readable nprofile/npub strings on the page are
|
||||
// unchanged — only the QR payload gains the query. An invalid pubkey yields
|
||||
// an empty nprofile; keep that empty (no useless `nostr:` QR).
|
||||
let qr_payload = if nprofile.is_empty() {
|
||||
nprofile.clone()
|
||||
} else {
|
||||
pay_uri(&nprofile, inv)
|
||||
};
|
||||
let qr_svg = qr::svg(&qr_payload, cfg.qr_logo_href()).unwrap_or_default();
|
||||
// The Slatepack (grin1) address is stable and reused across invoices; its
|
||||
// QR carries the bare address (a Grin wallet reads no amount from it, so
|
||||
// the page states the amount to send in text next to it). No address means
|
||||
// no wallet loaded: the page simply omits the Slatepack option.
|
||||
// The Slatepack method needs both operator opt-in (`GP_CHECKOUT_METHODS`)
|
||||
// and a loaded wallet: an enabled method that cannot work is simply hidden.
|
||||
let (slatepack_address, slatepack_qr_svg) = match slatepack_addr {
|
||||
Some(addr) if cfg.checkout_slatepack && !addr.is_empty() => {
|
||||
let qr = qr::svg(addr, cfg.qr_logo_href()).unwrap_or_default();
|
||||
(Some(addr.to_string()), Some(qr))
|
||||
}
|
||||
_ => (None, None),
|
||||
};
|
||||
let qr_svg = qr::svg(&nprofile, cfg.qr_logo_href()).unwrap_or_default();
|
||||
let amount_display = amount_display(inv);
|
||||
let token = inv.token.clone().unwrap_or_default();
|
||||
CheckoutInfo {
|
||||
@@ -59,6 +103,8 @@ pub fn build_info(inv: &Invoice, cfg: &Config) -> CheckoutInfo {
|
||||
npub,
|
||||
nprofile,
|
||||
qr_svg,
|
||||
slatepack_address,
|
||||
slatepack_qr_svg,
|
||||
amount_display,
|
||||
status: inv.status.clone(),
|
||||
memo: inv.memo.clone(),
|
||||
@@ -86,6 +132,53 @@ fn amount_display(inv: &Invoice) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the QR pay-URI for an invoice: `nostr:<nprofile>`, plus `?amount=`
|
||||
/// when the invoice has an exact expected amount, plus `&memo=` when it carries
|
||||
/// a human memo. A scanning Goblin wallet auto-fills the amount (and note) from
|
||||
/// this; open-amount invoices stay a bare `nostr:<nprofile>`.
|
||||
///
|
||||
/// The URI never carries the invoice token or any key — only the already-public
|
||||
/// recipient nprofile, relay hints, the amount, and the human memo shown on the
|
||||
/// page. `expected_amount` is a locked nanogrin quote (i64 in the DB, always
|
||||
/// non-negative here); only strictly positive amounts are emitted.
|
||||
fn pay_uri(nprofile: &str, inv: &Invoice) -> String {
|
||||
let mut uri = format!("nostr:{nprofile}");
|
||||
let mut sep = '?';
|
||||
if let Some(nano) = inv.expected_amount {
|
||||
if nano > 0 {
|
||||
uri.push(sep);
|
||||
uri.push_str("amount=");
|
||||
uri.push_str(&nanogrin_to_grin(nano as u64));
|
||||
sep = '&';
|
||||
}
|
||||
}
|
||||
if let Some(memo) = inv.memo.as_deref() {
|
||||
let memo = memo.trim();
|
||||
if !memo.is_empty() {
|
||||
uri.push(sep);
|
||||
uri.push_str("memo=");
|
||||
uri.push_str(&percent_encode(memo));
|
||||
}
|
||||
}
|
||||
uri
|
||||
}
|
||||
|
||||
/// Minimal RFC-3986 percent-encoding for a query value: keep the unreserved set
|
||||
/// (`A-Z a-z 0-9 - . _ ~`), percent-escape every other byte. Small and
|
||||
/// dependency-free (gp-core has no percent-encoding crate).
|
||||
fn percent_encode(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for &b in s.as_bytes() {
|
||||
match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
|
||||
out.push(b as char)
|
||||
}
|
||||
_ => out.push_str(&format!("%{b:02X}")),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The checkout page template.
|
||||
#[derive(Template)]
|
||||
#[template(path = "pay.html")]
|
||||
@@ -94,7 +187,6 @@ struct PayPage {
|
||||
is_open: bool,
|
||||
is_paid: bool,
|
||||
is_expired: bool,
|
||||
wallet_available: bool,
|
||||
}
|
||||
|
||||
/// The manual-slatepack result template (S2 to copy back).
|
||||
@@ -131,12 +223,18 @@ async fn pay_page(
|
||||
}
|
||||
};
|
||||
let status = inv.status();
|
||||
// Surface the wallet's stable grin1 Slatepack address (same wallet handle
|
||||
// the manual receive uses). No wallet loaded, or the address cannot be
|
||||
// derived, means no Slatepack option is shown.
|
||||
let slatepack_addr = wallet
|
||||
.get_ref()
|
||||
.as_ref()
|
||||
.and_then(|w| w.slatepack_address().ok());
|
||||
let page = PayPage {
|
||||
info: build_info(&inv, cfg.get_ref()),
|
||||
info: build_info(&inv, cfg.get_ref(), slatepack_addr.as_deref()),
|
||||
is_open: status == InvoiceStatus::Open,
|
||||
is_paid: status == InvoiceStatus::Paid,
|
||||
is_expired: status == InvoiceStatus::Expired,
|
||||
wallet_available: wallet.get_ref().is_some(),
|
||||
};
|
||||
render(page)
|
||||
}
|
||||
@@ -243,3 +341,184 @@ fn render<T: Template>(page: T) -> HttpResponse {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A minimal invoice fixture: only the fields the QR pay-URI reads matter.
|
||||
fn invoice(expected_amount: Option<i64>, memo: Option<&str>) -> Invoice {
|
||||
Invoice {
|
||||
id: "inv_1".into(),
|
||||
order_ref: None,
|
||||
expected_amount,
|
||||
expiry: None,
|
||||
status: "open".into(),
|
||||
created_at: "2026-01-01T00:00:00Z".into(),
|
||||
token: Some("secret-token-should-never-leak".into()),
|
||||
memo: memo.map(str::to_string),
|
||||
recipient_pubkey: Some("aa".repeat(32)),
|
||||
fiat_amount: None,
|
||||
fiat_currency: None,
|
||||
match_mode: None,
|
||||
paid_payment_id: None,
|
||||
paid_at: None,
|
||||
quote_rate: None,
|
||||
quote_source: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_info_surfaces_slatepack_address_when_wallet_loaded() {
|
||||
// A loaded wallet passes its grin1 address: build_info exposes it plus
|
||||
// a QR for it, so the hosted page can show the Slatepack option.
|
||||
let inv = invoice(Some(1_500_000_000), None);
|
||||
let cfg = Config::default();
|
||||
let info = build_info(&inv, &cfg, Some("grin1qtestaddress"));
|
||||
assert_eq!(info.slatepack_address.as_deref(), Some("grin1qtestaddress"));
|
||||
let qr = info.slatepack_qr_svg.expect("slatepack QR present");
|
||||
assert!(qr.contains("<svg"), "grin1 QR is an SVG");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_info_omits_slatepack_when_no_wallet() {
|
||||
// No wallet (None) or a blank address: no Slatepack address or QR, so
|
||||
// the page simply does not show the Slatepack option.
|
||||
let inv = invoice(Some(1_500_000_000), None);
|
||||
let cfg = Config::default();
|
||||
let info = build_info(&inv, &cfg, None);
|
||||
assert!(info.slatepack_address.is_none());
|
||||
assert!(info.slatepack_qr_svg.is_none());
|
||||
let blank = build_info(&inv, &cfg, Some(""));
|
||||
assert!(blank.slatepack_address.is_none());
|
||||
assert!(blank.slatepack_qr_svg.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkout_nostr_disabled_hides_nostr_section() {
|
||||
// GP_CHECKOUT_METHODS=slatepack: the Nostr method is off, so build_info
|
||||
// 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 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");
|
||||
assert_eq!(info.slatepack_address.as_deref(), Some("grin1qtestaddress"));
|
||||
|
||||
let page = PayPage {
|
||||
info,
|
||||
is_open: true,
|
||||
is_paid: false,
|
||||
is_expired: false,
|
||||
};
|
||||
let html = page.render().unwrap();
|
||||
assert!(
|
||||
!html.contains("Pay with Goblin Wallet"),
|
||||
"Nostr section absent when checkout_nostr=false"
|
||||
);
|
||||
assert!(
|
||||
html.contains("Pay by Slatepack"),
|
||||
"Slatepack section still present"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkout_slatepack_disabled_hides_slatepack_section() {
|
||||
// GP_CHECKOUT_METHODS=nostr: the Slatepack method is off, so even with a
|
||||
// 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 info = build_info(&inv, &cfg, Some("grin1qtestaddress"));
|
||||
assert!(
|
||||
info.slatepack_address.is_none(),
|
||||
"slatepack dropped when method off"
|
||||
);
|
||||
assert!(info.slatepack_qr_svg.is_none());
|
||||
assert!(!info.nprofile.is_empty(), "nprofile present when nostr on");
|
||||
|
||||
let page = PayPage {
|
||||
info,
|
||||
is_open: true,
|
||||
is_paid: false,
|
||||
is_expired: false,
|
||||
};
|
||||
let html = page.render().unwrap();
|
||||
assert!(
|
||||
html.contains("Pay with Goblin Wallet"),
|
||||
"Nostr section present"
|
||||
);
|
||||
assert!(
|
||||
!html.contains("Pay by Slatepack"),
|
||||
"Slatepack section absent when checkout_slatepack=false"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn amount_invoice_encodes_amount() {
|
||||
// 1.5 GRIN → nostr:<nprofile>?amount=1.5
|
||||
let inv = invoice(Some(1_500_000_000), None);
|
||||
assert_eq!(
|
||||
pay_uri("nprofile1abc", &inv),
|
||||
"nostr:nprofile1abc?amount=1.5"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_amount_invoice_stays_bare() {
|
||||
// Open amount (no expected_amount, no memo) → bare nostr:<nprofile>.
|
||||
let inv = invoice(None, None);
|
||||
assert_eq!(pay_uri("nprofile1abc", &inv), "nostr:nprofile1abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn amount_and_memo_encoded() {
|
||||
let inv = invoice(Some(1_000_000_000), Some("Coffee & cake"));
|
||||
assert_eq!(
|
||||
pay_uri("nprofile1abc", &inv),
|
||||
"nostr:nprofile1abc?amount=1&memo=Coffee%20%26%20cake"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memo_only_uses_question_mark() {
|
||||
// No amount but a memo → the memo is the first (and only) query param.
|
||||
let inv = invoice(None, Some("hi"));
|
||||
assert_eq!(pay_uri("nprofile1abc", &inv), "nostr:nprofile1abc?memo=hi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_and_blank_are_treated_as_open() {
|
||||
assert_eq!(
|
||||
pay_uri("nprofile1abc", &invoice(Some(0), None)),
|
||||
"nostr:nprofile1abc"
|
||||
);
|
||||
// A whitespace-only memo is dropped.
|
||||
assert_eq!(
|
||||
pay_uri("nprofile1abc", &invoice(None, Some(" "))),
|
||||
"nostr:nprofile1abc"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uri_never_leaks_token_or_key() {
|
||||
// The token and recipient private material must never appear in the QR.
|
||||
let inv = invoice(Some(2_000_000_000), Some("order 42"));
|
||||
let uri = pay_uri("nprofile1abc", &inv);
|
||||
assert!(!uri.contains("secret-token-should-never-leak"));
|
||||
assert!(!uri.contains("token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percent_encode_covers_reserved_and_unicode() {
|
||||
assert_eq!(percent_encode("a-b_c.d~e"), "a-b_c.d~e");
|
||||
assert_eq!(percent_encode("a b&c=d"), "a%20b%26c%3Dd");
|
||||
// Multi-byte UTF-8 is percent-encoded byte-by-byte.
|
||||
assert_eq!(percent_encode("é"), "%C3%A9");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,7 +145,9 @@ async fn create_invoice(
|
||||
.json(serde_json::json!({"error": "internal error"}));
|
||||
}
|
||||
};
|
||||
let info = build_info(&inv, cfg.get_ref());
|
||||
// The JSON connector API surfaces the Nostr checkout fields only; the
|
||||
// grin1 Slatepack option is presented on the hosted /pay page.
|
||||
let info = build_info(&inv, cfg.get_ref(), None);
|
||||
HttpResponse::Ok().json(checkout_json(&info))
|
||||
}
|
||||
|
||||
@@ -161,7 +163,7 @@ async fn get_invoice(
|
||||
}
|
||||
match invoice::get(pool.get_ref(), &path.into_inner()).await {
|
||||
Ok(Some(inv)) => {
|
||||
let info = build_info(&inv, cfg.get_ref());
|
||||
let info = build_info(&inv, cfg.get_ref(), None);
|
||||
HttpResponse::Ok().json(checkout_json(&info))
|
||||
}
|
||||
Ok(None) => HttpResponse::NotFound().json(serde_json::json!({"error": "not found"})),
|
||||
|
||||
@@ -44,19 +44,40 @@ async fn style() -> impl Responder {
|
||||
.body(include_str!("../../../static/style.css"))
|
||||
}
|
||||
|
||||
/// The bundled Goblin mark, the default QR center logo.
|
||||
/// The bundled Goblin mark (legacy default QR center logo; still served so an
|
||||
/// operator can keep `GP_QR_LOGO=/static/goblin-mark.svg`).
|
||||
async fn goblin_mark() -> impl Responder {
|
||||
HttpResponse::Ok()
|
||||
.content_type("image/svg+xml")
|
||||
.body(include_str!("../../../static/goblin-mark.svg"))
|
||||
}
|
||||
|
||||
/// The GoblinPay mark, the default QR center logo (dark "P" on the brand gold,
|
||||
/// sized for contrast on the QR's white backing).
|
||||
async fn goblinpay_mark() -> impl Responder {
|
||||
HttpResponse::Ok()
|
||||
.content_type("image/svg+xml")
|
||||
.body(include_str!("../../../static/goblinpay-mark.svg"))
|
||||
}
|
||||
|
||||
/// The GoblinPay wordmark (white), shown as the checkout page header logo.
|
||||
async fn goblinpay_wordmark() -> impl Responder {
|
||||
HttpResponse::Ok()
|
||||
.content_type("image/svg+xml")
|
||||
.body(include_str!("../../../static/goblinpay-wordmark.svg"))
|
||||
}
|
||||
|
||||
/// Route table, shared by `main` and the tests.
|
||||
fn routes(cfg: &mut web::ServiceConfig) {
|
||||
cfg.route("/", web::get().to(index))
|
||||
.route("/health", web::get().to(health))
|
||||
.route("/static/style.css", web::get().to(style))
|
||||
.route("/static/goblin-mark.svg", web::get().to(goblin_mark));
|
||||
.route("/static/goblin-mark.svg", web::get().to(goblin_mark))
|
||||
.route("/static/goblinpay-mark.svg", web::get().to(goblinpay_mark))
|
||||
.route(
|
||||
"/static/goblinpay-wordmark.svg",
|
||||
web::get().to(goblinpay_wordmark),
|
||||
);
|
||||
// Payment status + signed-receipt reads (public-by-token, M4).
|
||||
payments::configure(cfg);
|
||||
// Hosted checkout + manual slatepack (public-by-token, M5).
|
||||
@@ -111,7 +132,7 @@ async fn start_ingest(cfg: &Config, pool: sqlx::SqlitePool) -> (Keys, GpWallet)
|
||||
eprintln!("warning: GP_NOTIFY_MERCHANT_DM=on but GP_MERCHANT_NPUB is unset/invalid");
|
||||
}
|
||||
let opts = gp_nostr::service::ServiceOptions {
|
||||
relays: gp_nostr::relays::resolve(&cfg.relays),
|
||||
relays: gp_nostr::relays::resolve(cfg.relay_mode, &cfg.bundled_relay_url, &cfg.relays),
|
||||
nym: cfg.nym,
|
||||
notify: gp_nostr::service::NotifyOptions {
|
||||
merchant,
|
||||
|
||||
@@ -164,6 +164,10 @@ async fn pay_page_renders_zero_js_with_qr_and_nprofile() {
|
||||
assert!(html.contains("1.5 GRIN"), "amount shown");
|
||||
assert!(html.contains("<svg"), "server-rendered QR present");
|
||||
assert!(html.contains("nprofile1"), "nprofile string present");
|
||||
assert!(
|
||||
!html.contains("Pay by Slatepack"),
|
||||
"no wallet loaded: the grin1 Slatepack option is omitted"
|
||||
);
|
||||
assert!(
|
||||
html.contains("http-equiv=\"refresh\""),
|
||||
"live status refresh while open"
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# GoblinPay environment. Copy to /etc/goblinpay.env (bare metal) or deploy/.env
|
||||
# (docker compose), then edit. NON-SECRET config only: the Grin seed and the
|
||||
# wallet password live as mode-0400 files (systemd LoadCredential / the compose
|
||||
# ./secrets mount), never in this file.
|
||||
|
||||
# --- domain / URLs ---
|
||||
# docker-compose serves GoblinPay on GP_DOMAIN and the bundled relay on
|
||||
# relay.<GP_DOMAIN>; point BOTH DNS records at this host before `compose up`.
|
||||
GP_DOMAIN=pay.example
|
||||
GP_PUBLIC_URL=https://pay.example
|
||||
|
||||
# --- relay (bundled is the default: GoblinPay runs its own relay) ---
|
||||
GP_RELAY_MODE=bundled
|
||||
# The bundled relay's PUBLIC url: it is BOTH dialed by the server AND advertised
|
||||
# to payers in the checkout nprofile, so it must be reachable from the internet.
|
||||
GP_BUNDLED_RELAY_URL=wss://relay.pay.example
|
||||
# For GP_RELAY_MODE=external instead, drop the bundled relay and set:
|
||||
#GP_RELAY_MODE=external
|
||||
#GP_RELAYS=wss://relay.damus.io,wss://nos.lol
|
||||
|
||||
# --- Grin node (read-only: confirmations + balance) ---
|
||||
GP_NODE_URL=https://main.gri.mw
|
||||
|
||||
# --- mixnet ---
|
||||
# on (default) routes THIS server's relay traffic over the Nym mixnet. off is a
|
||||
# supported production posture (server-side clearnet): the payer's Goblin Wallet
|
||||
# still provides sender privacy and the payload stays gift-wrapped end to end.
|
||||
GP_NYM=on
|
||||
|
||||
# --- API / admin tokens (bearer capabilities; use strong random values) ---
|
||||
GP_API_TOKEN=change-me-api-token
|
||||
GP_ADMIN_TOKEN=change-me-admin-token
|
||||
|
||||
# --- webhook to your store (optional; the URL requires the secret) ---
|
||||
#GP_WEBHOOK_URL=https://your-store/hook
|
||||
#GP_WEBHOOK_SECRET=change-me-webhook-secret
|
||||
|
||||
# --- default payment-matching mode: memo | derived | amount ---
|
||||
GP_MATCH_MODE=derived
|
||||
@@ -0,0 +1,23 @@
|
||||
# Caddy reverse proxy for a GoblinPay till, with automatic HTTPS.
|
||||
#
|
||||
# Two names on one host (point both A/AAAA records at this server before
|
||||
# `docker compose up`, so Caddy can obtain certificates):
|
||||
# {$GP_DOMAIN} -> the GoblinPay checkout pages + REST API (gp-server)
|
||||
# relay.{$GP_DOMAIN} -> the bundled nostr-rs-relay (payers connect here; it
|
||||
# is what the checkout nprofile advertises)
|
||||
#
|
||||
# The relay gets its OWN subdomain rather than a path on the main domain so
|
||||
# there is no path rewriting: nostr-rs-relay serves both the WebSocket relay
|
||||
# protocol and the NIP-11 relay-info document at the root.
|
||||
#
|
||||
# GP_DOMAIN is injected from the environment by docker-compose.
|
||||
|
||||
{$GP_DOMAIN} {
|
||||
encode gzip
|
||||
reverse_proxy gp-server:8080
|
||||
}
|
||||
|
||||
relay.{$GP_DOMAIN} {
|
||||
# WebSocket upgrades and the NIP-11 document both go straight through.
|
||||
reverse_proxy relay:7777
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
# Multi-stage build for the GoblinPay server, run as a non-root user.
|
||||
#
|
||||
# IMPORTANT — build context is the WORKSPACE PARENT, not the repo.
|
||||
# The Nostr/Nym money path depends on two crates that live next to this repo,
|
||||
# not inside it (see crates/gp-nostr/Cargo.toml):
|
||||
# nip44 -> ../nip44 (the NIP-44 v3 companion crate)
|
||||
# smolmix-> ../nym/smolmix/core (the in-process Nym mixnet)
|
||||
# So the image must be built from the directory that contains GoblinPay/,
|
||||
# nip44/, and nym/ side by side. docker-compose.yml already sets
|
||||
# `build.context: ../..` for this; to build by hand:
|
||||
#
|
||||
# cd "<workspace parent containing GoblinPay, nip44, nym>"
|
||||
# docker build -f GoblinPay/deploy/Dockerfile -t goblinpay:latest .
|
||||
#
|
||||
# Only `-p gp-server` is built, which EXCLUDES the gp-goblin-sender dev crate
|
||||
# (it needs the goblin wallet tree, absent on servers). gp-wallet's grin_wallet
|
||||
# crates are fetched from git during the build.
|
||||
|
||||
# ---- builder ----
|
||||
FROM rust:1-bookworm AS builder
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends clang cmake pkg-config libssl-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /build
|
||||
|
||||
# The three trees the gp-server dependency graph needs, in the same relative
|
||||
# layout the path deps expect (nip44 and nym are siblings of GoblinPay).
|
||||
COPY GoblinPay ./GoblinPay
|
||||
COPY nip44 ./nip44
|
||||
COPY nym ./nym
|
||||
|
||||
WORKDIR /build/GoblinPay
|
||||
# Build ONLY gp-server (and its deps); never the goblin-tree dev crate.
|
||||
RUN cargo build --release --locked -p gp-server
|
||||
|
||||
# ---- runtime ----
|
||||
FROM debian:bookworm-slim AS runtime
|
||||
# ca-certificates for outbound TLS (node reads, CoinGecko, relays); curl for the
|
||||
# healthcheck.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Non-root user; wallet files, seed-at-rest, and the SQLite db live under /data.
|
||||
RUN useradd --system --uid 10001 --home-dir /data --shell /usr/sbin/nologin goblinpay \
|
||||
&& mkdir -p /data \
|
||||
&& chown -R goblinpay:goblinpay /data
|
||||
|
||||
COPY --from=builder /build/GoblinPay/target/release/gp-server /usr/local/bin/gp-server
|
||||
|
||||
USER goblinpay
|
||||
WORKDIR /data
|
||||
VOLUME ["/data"]
|
||||
|
||||
# Bind on all interfaces inside the container (Caddy is the only thing in front);
|
||||
# keep state under the /data volume. Money/identity secrets are injected at run
|
||||
# time via the *_FILE mounted-secret variants, never baked into the image.
|
||||
ENV GP_BIND=0.0.0.0:8080 \
|
||||
GP_DB_PATH=/data/goblinpay.db \
|
||||
GP_DATA_DIR=/data/gp-data
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
||||
CMD curl -fsS http://127.0.0.1:8080/health || exit 1
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/gp-server"]
|
||||
@@ -0,0 +1,89 @@
|
||||
# A full, self-contained GoblinPay till: the server, its BUNDLED relay, and an
|
||||
# auto-HTTPS reverse proxy.
|
||||
#
|
||||
# cd deploy
|
||||
# cp .env.example .env # then edit it (domain, tokens)
|
||||
# mkdir -p secrets # drop the mounted-secret files in here
|
||||
# docker compose up -d
|
||||
#
|
||||
# gives you:
|
||||
# - gp-server : the GoblinPay payment server (this repo)
|
||||
# - relay : a stock nostr-rs-relay, the bundled relay GP_RELAY_MODE=bundled
|
||||
# points at (so no third-party relay is needed)
|
||||
# - caddy : auto-TLS reverse proxy terminating HTTPS for both
|
||||
#
|
||||
# Set GP_DOMAIN in .env to your own domain BEFORE bringing it up: Caddy obtains
|
||||
# a certificate for it, so DNS must already point at this host.
|
||||
#
|
||||
# NOTE on the build context: gp-server's Nostr/Nym path depends on the sibling
|
||||
# crates nip44/ and nym/ (see deploy/Dockerfile), so the build context is the
|
||||
# workspace parent (`../..`) that holds GoblinPay, nip44, and nym.
|
||||
|
||||
services:
|
||||
gp-server:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: GoblinPay/deploy/Dockerfile
|
||||
image: goblinpay:latest
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
# Bundled relay (default mode). GP_BUNDLED_RELAY_URL is BOTH dialed by the
|
||||
# server and advertised to payers in the nprofile, so it must be the
|
||||
# relay's PUBLIC url (payers connect here); the server reaches it back
|
||||
# through Caddy.
|
||||
GP_RELAY_MODE: bundled
|
||||
GP_BUNDLED_RELAY_URL: ${GP_BUNDLED_RELAY_URL:-wss://relay.${GP_DOMAIN}}
|
||||
GP_PUBLIC_URL: ${GP_PUBLIC_URL:-https://${GP_DOMAIN}}
|
||||
GP_BIND: 0.0.0.0:8080
|
||||
GP_DB_PATH: /data/goblinpay.db
|
||||
GP_DATA_DIR: /data/gp-data
|
||||
# Money/identity secrets come from mounted files (never the image/env):
|
||||
GP_MNEMONIC_FILE: /run/secrets/gp_mnemonic
|
||||
GP_WALLET_PASSWORD_FILE: /run/secrets/gp_wallet_password
|
||||
GP_NCRYPTSEC_FILE: /run/secrets/gp_ncryptsec
|
||||
volumes:
|
||||
- gp-data:/data
|
||||
- ./secrets:/run/secrets:ro
|
||||
expose:
|
||||
- "8080"
|
||||
depends_on:
|
||||
- relay
|
||||
|
||||
relay:
|
||||
image: scsibug/nostr-rs-relay:latest
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./relay/nostr-rs-relay.toml:/usr/src/app/config.toml:ro
|
||||
- relay-data:/usr/src/app/db
|
||||
expose:
|
||||
- "7777"
|
||||
# Bound the relay's footprint so an unauthenticated flood cannot starve the
|
||||
# till or proxy on the same host.
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
cpus: "1.0"
|
||||
|
||||
caddy:
|
||||
image: caddy:2
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- gp-server
|
||||
- relay
|
||||
environment:
|
||||
GP_DOMAIN: ${GP_DOMAIN:-pay.example}
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy-data:/data
|
||||
- caddy-config:/config
|
||||
|
||||
volumes:
|
||||
gp-data:
|
||||
relay-data:
|
||||
caddy-data:
|
||||
caddy-config:
|
||||
@@ -0,0 +1,80 @@
|
||||
# Hardened systemd unit for the GoblinPay server on bare metal.
|
||||
#
|
||||
# Install (or just run deploy/install.sh):
|
||||
# sudo install -m0755 target/release/gp-server /usr/local/bin/
|
||||
# sudo install -m0640 deploy/.env.example /etc/goblinpay.env # then EDIT it
|
||||
# sudo install -m0644 deploy/gp-server.service /etc/systemd/system/
|
||||
# sudo mkdir -p /etc/goblinpay/secrets # 0400 secret files
|
||||
# sudo systemctl daemon-reload && sudo systemctl enable --now gp-server
|
||||
#
|
||||
# Unlike goblin-nip05d, this service holds MONEY secrets (the Grin seed and the
|
||||
# wallet password) and a wallet data directory. The seed and password are passed
|
||||
# as systemd credentials (read by PID1 as root, exposed read-only to the dynamic
|
||||
# service user) rather than left world-readable, and the config supports the
|
||||
# `*_FILE` mounted-secret variants for exactly this.
|
||||
|
||||
[Unit]
|
||||
Description=GoblinPay — self-hostable, receive-only Grin payment server
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=exec
|
||||
# Throwaway unprivileged user allocated at runtime. For a stable data owner,
|
||||
# comment this out and set `User=goblinpay` (create the user first).
|
||||
DynamicUser=yes
|
||||
|
||||
# Non-secret config (domain, node, tokens, webhook, relay URL). Read by systemd
|
||||
# as root, so a 0640 root:root file is fine even under DynamicUser.
|
||||
EnvironmentFile=/etc/goblinpay.env
|
||||
|
||||
# Money/identity secrets as credentials: the source files stay root-owned 0400;
|
||||
# systemd exposes copies under $CREDENTIALS_DIRECTORY (%d), readable by the
|
||||
# dynamic service user. Point the wallet at them via the *_FILE variants.
|
||||
LoadCredential=gp_mnemonic:/etc/goblinpay/secrets/mnemonic
|
||||
LoadCredential=gp_wallet_password:/etc/goblinpay/secrets/wallet_password
|
||||
Environment=GP_MNEMONIC_FILE=%d/gp_mnemonic
|
||||
Environment=GP_WALLET_PASSWORD_FILE=%d/gp_wallet_password
|
||||
# Optional: a NIP-49 encrypted Nostr identity (else a random one is generated
|
||||
# and persisted under the data dir on first start). Uncomment with its file:
|
||||
#LoadCredential=gp_ncryptsec:/etc/goblinpay/secrets/ncryptsec
|
||||
#Environment=GP_NCRYPTSEC_FILE=%d/gp_ncryptsec
|
||||
|
||||
# Managed state at /var/lib/goblinpay: the SQLite db, the wallet files, and the
|
||||
# encrypted seed at rest. 0700 — only the service user may read it.
|
||||
StateDirectory=goblinpay
|
||||
StateDirectoryMode=0700
|
||||
Environment=GP_DB_PATH=/var/lib/goblinpay/goblinpay.db
|
||||
Environment=GP_DATA_DIR=/var/lib/goblinpay/gp-data
|
||||
|
||||
ExecStart=/usr/local/bin/gp-server
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
|
||||
# --- hardening ---
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
PrivateDevices=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectClock=yes
|
||||
ProtectHostname=yes
|
||||
RestrictNamespaces=yes
|
||||
RestrictRealtime=yes
|
||||
RestrictSUIDSGID=yes
|
||||
LockPersonality=yes
|
||||
# If the Nym mixnet stack ever fails to start with a W^X error, comment this out.
|
||||
MemoryDenyWriteExecute=yes
|
||||
SystemCallArchitectures=native
|
||||
SystemCallFilter=@system-service
|
||||
SystemCallFilter=~@privileged @resources
|
||||
# Only the state directory is writable.
|
||||
ReadWritePaths=/var/lib/goblinpay
|
||||
# No raw sockets; only IP + unix.
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Executable
+77
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env bash
|
||||
# One-command bare-metal bootstrap for the GoblinPay server:
|
||||
# - builds the release binary (gp-server only; never the goblin-tree dev crate)
|
||||
# - installs it to /usr/local/bin
|
||||
# - creates the managed state dir and the 0700 secrets dir
|
||||
# - installs an env file from deploy/.env.example (if absent)
|
||||
# - installs and enables the hardened systemd unit
|
||||
#
|
||||
# Re-runnable: it never overwrites an existing /etc/goblinpay.env.
|
||||
# Requires: a Rust toolchain (cargo) and root (sudo) for the install steps.
|
||||
#
|
||||
# BUILD PREREQUISITE: gp-server's Nostr/Nym path depends on the sibling crates
|
||||
# nip44/ and nym/ (see crates/gp-nostr/Cargo.toml). They must sit next to this
|
||||
# repo, exactly as on the deploy host. `-p gp-server` deliberately excludes the
|
||||
# gp-goblin-sender dev crate, which needs the (absent) goblin wallet tree.
|
||||
#
|
||||
# After it finishes, edit /etc/goblinpay.env and drop the secret files into
|
||||
# /etc/goblinpay/secrets (mnemonic, wallet_password), then:
|
||||
# sudo systemctl restart gp-server
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
BIN=/usr/local/bin/gp-server
|
||||
ENV_FILE=/etc/goblinpay.env
|
||||
UNIT=/etc/systemd/system/gp-server.service
|
||||
STATE_DIR=/var/lib/goblinpay
|
||||
SECRETS_DIR=/etc/goblinpay/secrets
|
||||
|
||||
say() { printf '\033[1;33m==>\033[0m %s\n' "$1"; }
|
||||
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
SUDO=sudo
|
||||
else
|
||||
SUDO=""
|
||||
fi
|
||||
|
||||
say "Building release binary (cargo build --release --locked -p gp-server)"
|
||||
( cd "$REPO_DIR" && cargo build --release --locked -p gp-server )
|
||||
|
||||
say "Installing binary to $BIN"
|
||||
$SUDO install -m0755 "$REPO_DIR/target/release/gp-server" "$BIN"
|
||||
|
||||
say "Creating state directory $STATE_DIR (0700)"
|
||||
$SUDO install -d -m0700 "$STATE_DIR"
|
||||
|
||||
say "Creating secrets directory $SECRETS_DIR (0700)"
|
||||
$SUDO install -d -m0700 "$SECRETS_DIR"
|
||||
|
||||
if [[ -f "$ENV_FILE" ]]; then
|
||||
say "Env file $ENV_FILE already exists — leaving it untouched"
|
||||
else
|
||||
say "Installing env file to $ENV_FILE (EDIT IT: domain, node, tokens)"
|
||||
$SUDO install -m0640 "$REPO_DIR/deploy/.env.example" "$ENV_FILE"
|
||||
fi
|
||||
|
||||
say "Installing systemd unit to $UNIT"
|
||||
$SUDO install -m0644 "$REPO_DIR/deploy/gp-server.service" "$UNIT"
|
||||
|
||||
say "Reloading systemd and enabling the service"
|
||||
$SUDO systemctl daemon-reload
|
||||
$SUDO systemctl enable gp-server
|
||||
|
||||
cat <<EOF
|
||||
|
||||
Done. Next steps:
|
||||
1. Edit $ENV_FILE — set GP_PUBLIC_URL, GP_NODE_URL, GP_BUNDLED_RELAY_URL,
|
||||
GP_API_TOKEN, GP_ADMIN_TOKEN (and GP_WEBHOOK_URL/GP_WEBHOOK_SECRET if used).
|
||||
2. Write the wallet secrets (root-owned, mode 0400):
|
||||
sudo install -m0400 /dev/stdin $SECRETS_DIR/mnemonic <<<'your 24 words'
|
||||
sudo install -m0400 /dev/stdin $SECRETS_DIR/wallet_password <<<'your password'
|
||||
3. Run the bundled relay (deploy/docker-compose.yml) or point
|
||||
GP_BUNDLED_RELAY_URL at a relay you control, and put a TLS reverse proxy
|
||||
in front (see deploy/Caddyfile).
|
||||
4. Start it: $SUDO systemctl start gp-server
|
||||
5. Check it: curl -s http://127.0.0.1:8080/health
|
||||
EOF
|
||||
@@ -0,0 +1,39 @@
|
||||
# Configuration for the BUNDLED GoblinPay relay: a stock, unmodified
|
||||
# nostr-rs-relay (https://github.com/scsibug/nostr-rs-relay) run as the `relay`
|
||||
# service in docker-compose.yml. This is the self-contained relay that
|
||||
# `GP_RELAY_MODE=bundled` (the default) points at, so a merchant needs no
|
||||
# third-party relay: GoblinPay dials it, and the checkout `nprofile` advertises
|
||||
# it to payers, who deliver their gift-wrapped slatepack straight to the
|
||||
# merchant's own relay.
|
||||
#
|
||||
# nostr-rs-relay is a small, SQLite-backed Rust relay: a good fit for a
|
||||
# single-merchant till, and vendored as-is (config only, no fork).
|
||||
|
||||
[info]
|
||||
# Set this to the relay's PUBLIC wss URL (the same value you put in
|
||||
# GP_BUNDLED_RELAY_URL). Payers connect here.
|
||||
relay_url = "wss://pay.example/"
|
||||
name = "GoblinPay bundled relay"
|
||||
description = "Co-located Nostr relay for a GoblinPay merchant till."
|
||||
|
||||
[database]
|
||||
data_directory = "/usr/src/app/db"
|
||||
|
||||
[network]
|
||||
# Inside the container. Caddy terminates TLS and proxies wss -> here.
|
||||
address = "0.0.0.0"
|
||||
port = 7777
|
||||
|
||||
[limits]
|
||||
# Bound the footprint so an unauthenticated ingest/subscription flood cannot
|
||||
# starve the till (mirrors the reasoning behind the bundled strfry limits in
|
||||
# goblin-nip05d). Payers publish NIP-59 gift wraps from random ephemeral keys,
|
||||
# so a pubkey allowlist is intentionally NOT used (it would block payments).
|
||||
messages_per_sec = 10
|
||||
subscriptions_per_min = 60
|
||||
max_event_bytes = 131072
|
||||
max_ws_message_bytes = 262144
|
||||
max_subscriptions = 20
|
||||
|
||||
[options]
|
||||
reject_future_seconds = 1800
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="GoblinPay">
|
||||
<rect width="64" height="64" rx="14" fill="#e9c542"/>
|
||||
<path fill="#201d09" fill-rule="evenodd" d="M22 14H35a12 12 0 0 1 0 24H30V50H22ZM30 21H34a6 6 0 0 1 0 12H30Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 272 B |
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="600.000000pt" height="232.000000pt" viewBox="0 0 600.000000 232.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
<g transform="translate(0.000000,232.000000) scale(0.050000,-0.050000)"
|
||||
fill="#ffffff" stroke="none">
|
||||
<path d="M52 4522 c-131 -444 37 -874 416 -1064 l122 -61 -95 11 c-111 13
|
||||
-226 67 -324 154 -83 72 -86 66 -28 -48 140 -278 398 -434 715 -434 197 0 331
|
||||
45 362 120 62 150 316 340 520 391 271 67 273 570 2 813 -194 175 -626 253
|
||||
-842 152 l-70 -33 91 -1 c191 -4 428 -112 506 -231 36 -53 15 -72 -26 -23 -89
|
||||
108 -361 154 -623 104 -384 -72 -552 -22 -664 197 l-30 60 -32 -107z"/>
|
||||
<path d="M4560 2533 l0 -1610 285 3 285 4 5 573 6 573 484 9 c534 10 636 29
|
||||
838 158 583 370 592 1348 15 1718 -240 155 -320 167 -1163 175 l-755 7 0
|
||||
-1610z m1450 1080 c389 -170 416 -801 42 -998 -75 -40 -118 -44 -497 -51
|
||||
l-415 -8 0 554 0 554 395 -8 c319 -7 410 -15 475 -43z"/>
|
||||
<path d="M3139 3938 c-92 -222 -202 -268 -639 -268 -462 0 -625 -70 -783 -338
|
||||
-58 -100 -60 -71 -4 66 37 92 27 95 -96 29 -298 -160 -537 -548 -537 -871 0
|
||||
-117 -1 -117 -150 -4 -124 94 -359 217 -456 240 l-55 13 43 -108 c23 -59 67
|
||||
-230 98 -381 87 -422 215 -636 412 -686 l94 -23 -4 -152 c-8 -255 71 -431 252
|
||||
-564 108 -79 133 -87 119 -36 -6 19 -16 75 -24 125 l-14 90 86 -87 c155 -155
|
||||
391 -243 648 -243 238 1 257 17 94 80 -148 58 -352 193 -453 300 l-60 63 150
|
||||
-81 c278 -149 618 -181 915 -86 79 25 130 33 138 20 43 -71 324 -122 433 -79
|
||||
31 12 26 23 -37 83 -138 133 -143 166 -41 260 116 107 160 201 145 310 -11 78
|
||||
-6 89 69 162 122 118 151 178 228 468 39 147 96 319 127 383 65 137 62 139
|
||||
-143 85 -158 -41 -258 -93 -362 -188 l-84 -77 -33 95 c-42 118 -118 202 -181
|
||||
202 -63 0 -316 127 -366 183 -21 23 -29 39 -18 34 11 -5 54 -25 96 -44 600
|
||||
-273 1151 104 1154 791 l0 94 -101 -107 c-197 -209 -486 -299 -738 -231 l-88
|
||||
23 64 51 c95 74 147 190 147 330 0 142 -10 158 -45 74z m-172 -1574 c115 -128
|
||||
161 -505 71 -595 -162 -162 -289 -89 -286 164 3 327 112 544 215 431z m-1030
|
||||
-33 c101 -52 201 -243 241 -459 71 -385 -574 -317 -668 70 -55 226 234 489
|
||||
427 389z m1703 -77 c0 -181 -102 -341 -141 -222 l-24 74 -27 -63 c-47 -112
|
||||
-128 -19 -128 146 0 76 37 97 54 31 12 -47 46 -54 46 -9 0 48 62 61 92 19 26
|
||||
-35 27 -35 41 2 37 103 87 116 87 22z m-2770 -34 c16 -73 41 -77 58 -10 33
|
||||
129 140 -19 128 -177 l-6 -83 -43 66 -42 66 -13 -51 c-14 -56 -66 -69 -85 -21
|
||||
-17 45 -36 36 -61 -30 -50 -132 -103 -48 -68 108 39 175 108 243 132 132z
|
||||
m1350 -837 c0 -123 205 -183 399 -116 109 38 122 31 46 -24 -212 -151 -633
|
||||
-30 -516 148 43 65 71 62 71 -8z"/>
|
||||
<path d="M7910 3299 c-533 -109 -818 -523 -817 -1189 0 -436 84 -696 300 -929
|
||||
341 -369 1014 -413 1335 -88 l90 92 -13 -78 c-7 -42 -13 -99 -14 -127 l-1 -50
|
||||
250 -6 c138 -3 270 -1 295 6 l45 11 0 1159 0 1160 -281 0 -281 0 14 -125 14
|
||||
-125 -82 84 c-183 188 -531 271 -854 205z m590 -485 c333 -171 432 -878 177
|
||||
-1262 -222 -336 -773 -264 -938 121 -297 691 196 1430 761 1141z"/>
|
||||
<path d="M9680 3249 c0 -6 211 -496 469 -1090 l469 -1078 -84 -201 c-162 -386
|
||||
-315 -476 -665 -388 l-49 12 0 -231 c0 -269 -11 -255 217 -268 402 -22 736
|
||||
151 909 470 43 81 553 1429 994 2630 l57 155 -296 0 -295 0 -256 -730 c-141
|
||||
-402 -260 -726 -265 -722 -5 5 -146 331 -314 725 l-305 717 -293 6 c-161 3
|
||||
-293 0 -293 -7z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
@@ -31,6 +31,9 @@ main {
|
||||
|
||||
main.admin { max-width: 60rem; }
|
||||
|
||||
.brand { display: block; }
|
||||
.brandmark { height: 30px; width: auto; display: block; margin: 0 0 1.25rem; }
|
||||
|
||||
h1 { font-size: 1.5rem; margin: 0 0 0.5rem; }
|
||||
h2 { font-size: 1.05rem; margin: 1.75rem 0 0.5rem; color: var(--dim); text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
|
||||
@@ -92,6 +95,11 @@ details.manual { margin-top: 1.5rem; border-top: 1px solid var(--line); padding-
|
||||
details.manual summary { cursor: pointer; color: var(--accent); font-weight: 600; }
|
||||
details.manual ol { color: var(--dim); font-size: 0.9rem; padding-left: 1.2rem; }
|
||||
|
||||
/* Each first-class way to pay (Goblin/Nostr, Slatepack/grin1). */
|
||||
.pay-method { margin-top: 1.5rem; border-top: 1px solid var(--line); padding-top: 0.75rem; }
|
||||
.pay-method:first-of-type { border-top: none; padding-top: 0; }
|
||||
.pay-method ol { color: var(--dim); font-size: 0.9rem; padding-left: 1.2rem; }
|
||||
|
||||
a { color: var(--accent); }
|
||||
|
||||
.footer { margin-top: 2rem; color: var(--dim); font-size: 0.78rem; text-align: center; }
|
||||
|
||||
+23
-15
@@ -9,6 +9,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<main class="checkout">
|
||||
<a class="brand" href="/"><img class="brandmark" src="/static/goblinpay-wordmark.svg" alt="GoblinPay"></a>
|
||||
<h1>Pay with Goblin</h1>
|
||||
<p class="amount">{{ info.amount_display }}</p>
|
||||
|
||||
@@ -19,30 +20,37 @@
|
||||
<p class="status expired">This invoice has expired.</p>
|
||||
{% else %}
|
||||
<p class="status open">Waiting for payment…</p>
|
||||
<div class="qr">{{ info.qr_svg|safe }}</div>
|
||||
<p class="hint">Scan with your Goblin Wallet, or copy the address below.</p>
|
||||
|
||||
<label for="nprofile">Payment address (nprofile)</label>
|
||||
<textarea id="nprofile" class="copybox" rows="3" readonly>{{ info.nprofile }}</textarea>
|
||||
{% if !info.nprofile.is_empty() %}
|
||||
<section class="pay-method">
|
||||
<h2>Pay with Goblin Wallet</h2>
|
||||
<div class="qr">{{ info.qr_svg|safe }}</div>
|
||||
<p class="hint">Scan with your Goblin Wallet, or copy the address below.</p>
|
||||
<label for="nprofile">Payment address (nprofile)</label>
|
||||
<textarea id="nprofile" class="copybox" rows="3" readonly>{{ info.nprofile }}</textarea>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<details class="manual">
|
||||
<summary>Can't scan? Pay manually with a slatepack</summary>
|
||||
{% if wallet_available %}
|
||||
{% if let Some(grin1) = info.slatepack_address %}
|
||||
<section class="pay-method">
|
||||
<h2>Pay by Slatepack (grin1)</h2>
|
||||
<p class="hint">Pay from any Grin wallet, no Nostr needed. Send <strong>{{ info.amount_display }}</strong> to the address below.</p>
|
||||
{% if let Some(grin1_qr) = info.slatepack_qr_svg %}<div class="qr">{{ grin1_qr|safe }}</div>{% endif %}
|
||||
<label for="grin1">Slatepack address (grin1)</label>
|
||||
<textarea id="grin1" class="copybox" rows="3" readonly>{{ grin1 }}</textarea>
|
||||
<ol>
|
||||
<li>In your wallet, send {{ info.amount_display }} using the manual / slatepack option.</li>
|
||||
<li>Paste the generated <strong>S1</strong> slatepack below and submit.</li>
|
||||
<li>Copy the <strong>response</strong> slatepack we return, back into your wallet to finalize and post.</li>
|
||||
<li>In your Grin wallet, send {{ info.amount_display }} to this address using the Slatepack / file method, then paste the Slatepack it produces below.</li>
|
||||
<li>The pasted <strong>S1</strong> Slatepack is received here and a <strong>response</strong> Slatepack is returned.</li>
|
||||
<li>Paste that response back into your wallet to finalize and broadcast it, which completes the payment.</li>
|
||||
</ol>
|
||||
<form method="post" action="/pay/{{ info.token }}/slatepack">
|
||||
<label for="s1">Your slatepack (S1)</label>
|
||||
<label for="s1">Your Slatepack (S1)</label>
|
||||
<textarea id="s1" name="slatepack" rows="6" required
|
||||
placeholder="BEGINSLATEPACK. … ENDSLATEPACK."></textarea>
|
||||
<button type="submit">Submit slatepack</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p>Manual receive is unavailable on this instance.</p>
|
||||
{% endif %}
|
||||
</details>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if let Some(memo) = info.memo %}<p class="memo">{{ memo }}</p>{% endif %}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<textarea id="s2" class="copybox" rows="8" readonly>{{ s2_armor }}</textarea>
|
||||
<ol>
|
||||
<li>Select all of the text above and copy it.</li>
|
||||
<li>Paste it back into your wallet to finalize the transaction.</li>
|
||||
<li>Paste it back into your wallet to finalize and broadcast the transaction; this completes the payment.</li>
|
||||
<li>Your wallet posts it to the chain; GoblinPay confirms it on receipt.</li>
|
||||
</ol>
|
||||
{% endif %}
|
||||
|
||||
Reference in New Issue
Block a user