GoblinPay: receive-only Grin payment server
A self-hostable Grin payment server for shops, creators, and sites: show a code, Grin lands in your wallet, with a verifiable Grin payment proof on receive. Workspace crates (gp-core / gp-nostr / gp-server / gp-wallet / gp-goblin-sender), a WooCommerce connector, a hosted /pay/<token> checkout, and NIP-44 v3 gift-wrapped payment DMs carried over the Nym mixnet. All secrets are read from the environment; none are committed.
This commit is contained in:
+15
@@ -0,0 +1,15 @@
|
||||
/target
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
# Runtime wallet/node secrets, keys, and logs — NEVER commit (payment server)
|
||||
.owner_api_secret
|
||||
.foreign_api_secret
|
||||
grin-wallet.toml
|
||||
grin-wallet.log
|
||||
*.log
|
||||
.env
|
||||
secrets/
|
||||
/data
|
||||
/wallet_data
|
||||
*.seed
|
||||
Generated
+12209
File diff suppressed because it is too large
Load Diff
+29
@@ -0,0 +1,29 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/gp-wallet",
|
||||
"crates/gp-goblin-sender",
|
||||
"crates/gp-nostr",
|
||||
"crates/gp-core",
|
||||
"crates/gp-server",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "Apache-2.0"
|
||||
|
||||
[workspace.dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sqlx = { version = "0.8", default-features = false, features = [
|
||||
"runtime-tokio-rustls",
|
||||
"sqlite",
|
||||
"migrate",
|
||||
"macros",
|
||||
] }
|
||||
tokio = { version = "1", default-features = false }
|
||||
|
||||
[profile.release]
|
||||
opt-level = 2
|
||||
strip = true
|
||||
@@ -0,0 +1,184 @@
|
||||
# GoblinPay
|
||||
|
||||
A self-hostable, receive-only Grin payment server. A merchant runs it, a
|
||||
customer pays from Goblin Wallet by scanning a QR code, and the payment
|
||||
travels as a gift-wrapped slatepack over Nostr (optionally over the Nym
|
||||
mixnet). GoblinPay auto-receives, returns the S2 reply so the payer can
|
||||
finalize, confirms the transaction on chain, and signals paid.
|
||||
|
||||
**Status: milestone 6 (invoices, hosted checkout, per-user endpubs,
|
||||
notifications).** On top of the milestone 2-4 wallet + transport + confirmation
|
||||
path, GoblinPay now carries the full merchant surface:
|
||||
|
||||
- **Invoices + matching (M5):** create an invoice against an order, matched by
|
||||
any of three modes (per-invoice override or the `GP_MATCH_MODE` default):
|
||||
the payer's memo, a per-invoice derived Nostr identity (a stateless child of
|
||||
the server nsec, recommended for stores), or an exact amount. The matcher
|
||||
runs inside the ingest pipeline, so a gift-wrapped payment resolves to its
|
||||
invoice automatically.
|
||||
- **Hosted checkout (M5):** 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.
|
||||
- **Per-user endpubs (M5b):** 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
|
||||
and an overlap window so a just-rotated endpub still lands. All funds still
|
||||
land in the one Grin wallet.
|
||||
- **Notifications (M6, all optional):** an HMAC-signed, idempotent, retried
|
||||
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. Store connectors and the
|
||||
conversion oracle arrive in later milestones; comprehensive documentation lands
|
||||
at milestone 11.
|
||||
|
||||
## Workspace
|
||||
|
||||
| Crate | Purpose |
|
||||
|---|---|
|
||||
| `crates/gp-wallet` | Grin wallet handoff: open from mnemonic, S1 -> `receive_tx` -> S2 (offline) |
|
||||
| `crates/gp-goblin-sender` | Test-only gate helper: sends and finalizes with Goblin's wallet stack |
|
||||
| `crates/gp-nostr` | Nostr transport: identity, gift wrap (NIP-44 v2/v3), ingest, Nym mixnet |
|
||||
| `crates/gp-core` | Domain core: config, SQLite persistence (sqlx, raw SQL) |
|
||||
| `crates/gp-server` | Actix-Web binary: routes, Askama templates, rustls TLS |
|
||||
|
||||
Supporting directories: `migrations/` (raw sqlx SQL), `templates/` (Askama,
|
||||
zero JS), `static/` (one hand-written CSS file, no build step).
|
||||
|
||||
## Configuration
|
||||
|
||||
Everything is environment variables, defaults are safe for local use.
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `GP_BIND` | `127.0.0.1:8080` | Listen address |
|
||||
| `GP_TLS` | `off` | `off` (plain HTTP) or `rustls` (in-process TLS) |
|
||||
| `GP_TLS_CERT` | unset | PEM certificate chain path, required for `rustls` |
|
||||
| `GP_TLS_KEY` | unset | PEM private key path, required for `rustls` |
|
||||
| `GP_DB_PATH` | `./goblinpay.db` | SQLite file, created on first start |
|
||||
| `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_INGEST` | `on` | Nostr ingest service (`off` = HTTP surface only, for debugging) |
|
||||
| `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 |
|
||||
| `GP_NSEC` | unset | Nostr identity key (payment identity secret) |
|
||||
| `GP_NCRYPTSEC` | unset | NIP-49 encrypted identity key (unlocked with the wallet password) |
|
||||
| `GP_PUBLIC_URL` | `http://<bind>` | Public base URL for the hosted `/pay/<token>` links |
|
||||
| `GP_API_TOKEN` | unset | Bearer token for the connector/create-invoice API (unset = write API closed) |
|
||||
| `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_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 |
|
||||
| `GP_ENDPUB_ROTATE_INTERVAL` | `0` | Default per-user endpub rotation interval in seconds (0 = off) |
|
||||
| `GP_ENDPUB_OVERLAP_EPOCHS` | `1` | Past epochs kept watched after a rotation |
|
||||
| `GP_RATE_SOURCE` | `coingecko` | Conversion-rate oracle source for pricing fiat invoices |
|
||||
| `GP_RATE_CURRENCIES` | `usd` | Comma-separated fiat currencies the oracle prices (ISO codes) |
|
||||
| `GP_RATE_CACHE_TTL` | `60` | Seconds a fetched rate is reused before refetching (0 = always) |
|
||||
| `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) |
|
||||
|
||||
### Conversion rates (optional)
|
||||
|
||||
A store that prices in fiat (for example cryptodrip.com prices in USD) sends
|
||||
`amount_fiat` + `currency` to `POST /invoice`. GoblinPay then quotes the Grin
|
||||
amount through the configured oracle, locks it for `GP_QUOTE_TTL` seconds, and
|
||||
fills the invoice `expected_amount` so the invoice matches by amount. A
|
||||
Grin-denominated invoice (`amount_grin`) bypasses the oracle unchanged.
|
||||
|
||||
The oracle default is CoinGecko (GRIN is listed under id `grin`), queried at
|
||||
`api.coingecko.com/api/v3/simple/price?ids=grin&vs_currencies=<currencies>`.
|
||||
Rates are cached for `GP_RATE_CACHE_TTL` seconds so concurrent checkouts do not
|
||||
hammer the source. If the source is unreachable or the currency is not enabled,
|
||||
`create-invoice` fails fast with a clear error rather than creating an
|
||||
unpriceable invoice; `GP_RATE_STALE_MAX` optionally permits serving the last
|
||||
cached rate within a bounded window instead. The oracle fetch goes DIRECT over
|
||||
normal HTTP, never through the Nym mixnet (the mixnet carries only the Nostr
|
||||
gift-wrap layer, the same ruling as the read-only node client).
|
||||
|
||||
The secrets also accept mounted-file variants, `GP_MNEMONIC_FILE`,
|
||||
`GP_WALLET_PASSWORD_FILE`, `GP_NSEC_FILE`, and `GP_NCRYPTSEC_FILE`
|
||||
(mode 0400 recommended). Setting both the variable and its `_FILE` variant
|
||||
is an error, as is setting both `GP_NSEC` and `GP_NCRYPTSEC`. When neither
|
||||
identity variable is set, a fresh random identity is generated on first
|
||||
start and persisted NIP-49 encrypted at `<GP_DATA_DIR>/nostr/identity.json`
|
||||
(mode 0600). The mnemonic and the nsec are deliberately independent secrets:
|
||||
the mnemonic recovers the funds, the nsec recovers the payment identity, and
|
||||
the Grin seed is never used for anything Nostr.
|
||||
|
||||
## REST API
|
||||
|
||||
Public (no auth): `/health`, and the token-as-capability routes below. Bearer
|
||||
auth (`Authorization: Bearer <token>`) where noted; the `_FILE` mounted-file
|
||||
variant works for `GP_API_TOKEN`, `GP_ADMIN_TOKEN`, and `GP_WEBHOOK_SECRET` too.
|
||||
|
||||
| Method | Route | Auth | Purpose |
|
||||
|---|---|---|---|
|
||||
| GET | `/health` | none | Liveness + version |
|
||||
| POST | `/invoice` | api | Create an invoice, returns checkout info (pay_url, nprofile, QR SVG) |
|
||||
| GET | `/invoice/{id}` | api | Invoice checkout info + status |
|
||||
| GET | `/pay/{token}` | token | Hosted zero-JS checkout page |
|
||||
| GET | `/pay/{token}/status` | token | Invoice status JSON (for polling) |
|
||||
| POST | `/pay/{token}/slatepack` | token | Manual fallback: paste S1, returns the S2 page |
|
||||
| GET | `/payment/{id}` | token | Payment status JSON |
|
||||
| GET | `/payment/{id}/receipt` | token | Server-signed verifiable receipt (M4) |
|
||||
| GET | `/admin` | admin | Dashboard (payments, balances, config) |
|
||||
| GET | `/admin/payments` | admin | Recent payments JSON |
|
||||
| GET/POST | `/admin/users` | admin | List users / create a user + endpub |
|
||||
| GET | `/admin/users/{id}` | admin | A user's current endpub + QR |
|
||||
| POST | `/admin/users/{id}/rotate` | admin | Force-rotate a user's endpub |
|
||||
| POST | `/admin/users/{id}/rotate-interval` | admin | Set the per-user rotation interval |
|
||||
| GET | `/admin/webhooks` | admin | Webhook delivery log |
|
||||
|
||||
`POST /invoice` body: `{ order_ref?, amount_grin? | (amount_fiat + currency), memo?, match_mode?, expiry_secs? }`.
|
||||
|
||||
## Webhook contract
|
||||
|
||||
On a received payment, GoblinPay POSTs `application/json` to `GP_WEBHOOK_URL`:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "5f3c...", // 128-bit hex, the idempotency key
|
||||
"event_type": "payment.received",
|
||||
"created_at": "2026-07-01T12:00:00Z",
|
||||
"payment": {
|
||||
"slate_id": "...", "amount": 2000000000, "amount_grin": "2",
|
||||
"status": "received", "payer": "...hex...", "confirmed_height": null
|
||||
},
|
||||
"invoice_id": "...", "order_ref": "order-42", "user_id": "..."
|
||||
}
|
||||
```
|
||||
|
||||
Headers: `X-GoblinPay-Signature: sha256=<hex(HMAC-SHA256(secret, raw_body))>`
|
||||
and `X-GoblinPay-Delivery: <event_id>`. Verify by recomputing the HMAC over the
|
||||
exact received bytes (constant-time) and dedupe on the delivery id. Deliveries
|
||||
are persisted and retried with exponential backoff.
|
||||
|
||||
## Run
|
||||
|
||||
```
|
||||
cargo run -p gp-server
|
||||
curl http://127.0.0.1:8080/health
|
||||
```
|
||||
|
||||
## Develop
|
||||
|
||||
```
|
||||
./ci.sh # cargo fmt --check, clippy -D warnings, tests
|
||||
```
|
||||
|
||||
## Credits
|
||||
|
||||
GoblinPay is developed with the help of Claude (Anthropic).
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env sh
|
||||
# CI gate: formatting, lints (warnings are errors), tests.
|
||||
set -eu
|
||||
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy --workspace -- -D warnings
|
||||
cargo test --workspace
|
||||
@@ -0,0 +1,86 @@
|
||||
# Installing GoblinPay for WooCommerce
|
||||
|
||||
## 1. Package the plugin
|
||||
|
||||
Zip the plugin directory so the archive contains a single top-level folder named
|
||||
`goblinpay-woocommerce` with the plugin files inside it:
|
||||
|
||||
```
|
||||
cd connectors
|
||||
zip -r goblinpay-woocommerce.zip woocommerce \
|
||||
-x '*/.git/*'
|
||||
```
|
||||
|
||||
If you prefer the folder name to match the plugin, rename `woocommerce` to
|
||||
`goblinpay-woocommerce` before zipping. WordPress does not require the folder
|
||||
name to match; it reads the plugin header from `goblinpay-woocommerce.php`.
|
||||
|
||||
## 2. Upload and activate
|
||||
|
||||
In WordPress, open Plugins, then Add New Plugin, then Upload Plugin. Choose the
|
||||
zip, install it, and activate. WooCommerce 8.0 or newer must already be active.
|
||||
|
||||
Alternatively, copy the `woocommerce` folder into
|
||||
`wp-content/plugins/goblinpay-woocommerce/` on the server and activate from the
|
||||
Plugins screen.
|
||||
|
||||
## 3. Configure the gateway
|
||||
|
||||
Open WooCommerce, then Settings, then Payments, then GoblinPay (Grin), and set:
|
||||
|
||||
- GoblinPay URL: the base URL of your GoblinPay server, for example
|
||||
`http://127.0.0.1:8192` when GoblinPay runs on the same host. No trailing
|
||||
slash.
|
||||
- API Token: the GoblinPay create-invoice bearer token (`GP_API_TOKEN`).
|
||||
- Webhook Secret: the shared HMAC secret (`GP_WEBHOOK_SECRET`).
|
||||
- Matching mode: leave on Per-invoice identity (recommended) unless you have a
|
||||
reason to match by order reference or amount.
|
||||
- Checkout experience: Redirect (recommended) or Embed the QR on the
|
||||
order-received page.
|
||||
- Payment window: minutes before an unpaid order is cancelled (0 disables it).
|
||||
|
||||
Enable the method and save.
|
||||
|
||||
## 4. Register the webhook in GoblinPay
|
||||
|
||||
Point your GoblinPay server at this site so it can report payments. Set these on
|
||||
the GoblinPay side:
|
||||
|
||||
- `GP_WEBHOOK_URL` = `https://YOUR-SITE/wp-json/goblinpay/v1/webhook`
|
||||
- `GP_WEBHOOK_SECRET` = the same secret you entered in the gateway settings.
|
||||
- `GP_API_TOKEN` = the same token you entered as the API Token.
|
||||
|
||||
GoblinPay signs each delivery with `X-GoblinPay-Signature: sha256=<hmac>` over
|
||||
the raw body and sends an idempotency key in `X-GoblinPay-Delivery`. The plugin
|
||||
verifies the signature, dedupes on the event id, and completes the matching
|
||||
order.
|
||||
|
||||
The exact POST target the plugin exposes (the value to use for
|
||||
`GP_WEBHOOK_URL`) is:
|
||||
|
||||
```
|
||||
https://YOUR-SITE/wp-json/goblinpay/v1/webhook
|
||||
```
|
||||
|
||||
Make sure the WordPress REST API is reachable from the GoblinPay host. If the
|
||||
webhook is ever missed, the plugin also polls
|
||||
`GET {GoblinPay URL}/invoice/{invoice_id}` (with the bearer token) as a
|
||||
fallback.
|
||||
|
||||
## 5. Test
|
||||
|
||||
Place a test order, choose Grin (GRIN), and confirm:
|
||||
|
||||
- Redirect mode sends you to the GoblinPay `/pay/<token>` page.
|
||||
- Embed mode shows the Goblin QR on the order-received page.
|
||||
- Paying from a Goblin Wallet moves the order to processing or completed once
|
||||
GoblinPay delivers the `payment.received` webhook.
|
||||
|
||||
Turn on Debug logging in the gateway settings to trace requests and webhooks in
|
||||
WooCommerce, then Status, then Logs, source `goblinpay`.
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,75 @@
|
||||
# GoblinPay for WooCommerce
|
||||
|
||||
Accept Grin (GRIN / MimbleWimble) payments in WooCommerce through a self-hosted
|
||||
GoblinPay server. The customer pays from their Goblin Wallet by scanning an
|
||||
`nprofile` QR code. The payment travels as a gift-wrapped slatepack over Nostr
|
||||
(optionally over the Nym mixnet). GoblinPay receives it, returns the reply
|
||||
slatepack to the payer, watches the chain to confirm, and notifies WooCommerce.
|
||||
|
||||
This plugin is a thin client. All of the Grin and Nostr work happens in
|
||||
GoblinPay; WooCommerce only talks HTTP to your GoblinPay instance. No BTCPay, no
|
||||
node exposed to the store, no wallet RPC.
|
||||
|
||||
## What it does
|
||||
|
||||
- Adds a "Grin (GRIN)" payment method to both the classic checkout and the
|
||||
WooCommerce Blocks (Cart/Checkout block) checkout.
|
||||
- On checkout, calls GoblinPay to create an invoice for the order, then either
|
||||
redirects the customer to GoblinPay's hosted `/pay/<token>` page (the default)
|
||||
or shows the Goblin QR on the order-received page (the embedded option).
|
||||
- Marks the order complete when GoblinPay reports the payment, via a signed
|
||||
webhook. If a webhook is missed, the plugin polls GoblinPay for the invoice
|
||||
status as a fallback.
|
||||
- Declares HPOS (custom order tables) and Cart/Checkout Blocks compatibility.
|
||||
|
||||
## Requirements
|
||||
|
||||
- WordPress with WooCommerce 8.0 or newer (tested against WooCommerce 10.8).
|
||||
- PHP 8.0 or newer (target host runs PHP 8.2).
|
||||
- A running GoblinPay server reachable from the WordPress host.
|
||||
|
||||
## Settings
|
||||
|
||||
Open WooCommerce, then Settings, then Payments, then GoblinPay (Grin).
|
||||
|
||||
- GoblinPay URL: base URL of your GoblinPay server, for example
|
||||
`http://127.0.0.1:8192`. No trailing slash.
|
||||
- API Token: the GoblinPay create-invoice bearer token (`GP_API_TOKEN` on the
|
||||
server).
|
||||
- Webhook Secret: the shared HMAC secret (`GP_WEBHOOK_SECRET` on the server).
|
||||
- Matching mode: how GoblinPay ties an incoming payment to the order. The
|
||||
default, per-invoice identity, gives each order its own QR and is the most
|
||||
reliable. Order reference (memo) and amount-only are also available.
|
||||
- Checkout experience: redirect to the hosted GoblinPay checkout (the default),
|
||||
or embed the QR on the order-received page.
|
||||
- Payment window: minutes before an unpaid order is cancelled. Set 0 to disable.
|
||||
|
||||
Point your GoblinPay server's `GP_WEBHOOK_URL` at this site's webhook endpoint,
|
||||
shown in the Webhook Secret field, which is:
|
||||
|
||||
```
|
||||
https://YOUR-SITE/wp-json/goblinpay/v1/webhook
|
||||
```
|
||||
|
||||
## 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. This plugin marks refunds as unsupported for that
|
||||
reason, 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 (`hash_equals`). A bad or missing
|
||||
signature is rejected with HTTP 401.
|
||||
- Webhook deliveries are deduplicated on their event id, and order completion is
|
||||
idempotent, so a retried or duplicated delivery is a no-op.
|
||||
- The QR SVG rendered on the order-received page is passed through a strict
|
||||
`wp_kses` allowlist (svg, g, rect, path, image, title), so a compromised or
|
||||
misconfigured endpoint cannot inject script.
|
||||
- Secrets live in the gateway settings, never in code.
|
||||
|
||||
## Credit
|
||||
|
||||
Built by Claude (Anthropic) for the Goblin project.
|
||||
@@ -0,0 +1,41 @@
|
||||
/* global window */
|
||||
/**
|
||||
* WooCommerce Blocks (Checkout/Cart block) integration for the GoblinPay
|
||||
* payment gateway. No build step: uses the globals WooCommerce Blocks exposes
|
||||
* (wc-blocks-registry, wc-settings, wp-element, wp-html-entities). This is a
|
||||
* redirect/on-site gateway: the block submits to the Store API, which runs the
|
||||
* server-side process_payment() and follows the returned redirect (to the
|
||||
* hosted GoblinPay /pay page, or the order-received page for the embedded QR).
|
||||
*/
|
||||
( function () {
|
||||
'use strict';
|
||||
|
||||
if ( ! window.wc || ! window.wc.wcBlocksRegistry || ! window.wp || ! window.wp.element ) {
|
||||
return;
|
||||
}
|
||||
|
||||
var registerPaymentMethod = window.wc.wcBlocksRegistry.registerPaymentMethod;
|
||||
var getSetting = window.wc.wcSettings.getSetting;
|
||||
var createElement = window.wp.element.createElement;
|
||||
var decodeEntities = ( window.wp.htmlEntities && window.wp.htmlEntities.decodeEntities ) || function ( s ) { return s; };
|
||||
|
||||
var data = getSetting( 'goblinpay_data', {} );
|
||||
var title = decodeEntities( data.title || 'Pay with Grin (GRIN)' );
|
||||
var description = decodeEntities( data.description || '' );
|
||||
|
||||
var Content = function () {
|
||||
return createElement( 'div', { className: 'goblinpay-blocks-description' }, description );
|
||||
};
|
||||
|
||||
registerPaymentMethod( {
|
||||
name: 'goblinpay',
|
||||
label: createElement( 'span', null, title ),
|
||||
content: createElement( Content, null ),
|
||||
edit: createElement( Content, null ),
|
||||
canMakePayment: function () { return true; },
|
||||
ariaLabel: title,
|
||||
supports: {
|
||||
features: data.supports || [ 'products' ],
|
||||
},
|
||||
} );
|
||||
} )();
|
||||
@@ -0,0 +1,516 @@
|
||||
<?php
|
||||
/**
|
||||
* Plugin Name: GoblinPay for WooCommerce
|
||||
* Plugin URI: https://git.us-ea.st/GRIN/GoblinPay
|
||||
* Description: Accept Grin (GRIN / MimbleWimble) payments in WooCommerce through a self-hosted GoblinPay server. The customer pays from their Goblin Wallet by scanning an nprofile QR; payment travels as a gift-wrapped slatepack over Nostr (optionally over the Nym mixnet). Works with the classic and the Blocks checkout. HPOS-compatible.
|
||||
* Version: 1.0.0
|
||||
* Author: GoblinPay
|
||||
* License: GPL-2.0-or-later
|
||||
* Requires PHP: 8.0
|
||||
* Requires at least: 6.0
|
||||
* WC requires at least: 8.0
|
||||
* WC tested up to: 10.8
|
||||
* Text Domain: goblinpay-woocommerce
|
||||
*
|
||||
* GoblinPay is a receive-only Grin payment server. This gateway talks to its
|
||||
* REST API directly:
|
||||
* POST {gp_url}/invoice
|
||||
* Authorization: Bearer <api_token>
|
||||
* { order_ref, amount_fiat, currency, memo, match_mode, expiry_secs }
|
||||
* -> { invoice_id, token, pay_url, nprofile, npub, qr_svg, amount, status, ... }
|
||||
* and receives payment events at /wp-json/goblinpay/v1/webhook
|
||||
* (HMAC-SHA256 over the raw body, header "X-GoblinPay-Signature: sha256=<hex>",
|
||||
* idempotency key in "X-GoblinPay-Delivery: <event_id>").
|
||||
*
|
||||
* Refunds are NOT automated: GoblinPay is receive-only (it never sends), so a
|
||||
* refund is a manual, out-of-band Grin send by the merchant. See README.md.
|
||||
*
|
||||
* @package GoblinPayWooCommerce
|
||||
*/
|
||||
|
||||
if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
define('GOBLINPAY_WC_VERSION', '1.0.0');
|
||||
define('GOBLINPAY_WC_PLUGIN_FILE', __FILE__);
|
||||
define('GOBLINPAY_WC_WH_NS', 'goblinpay/v1'); // keep stable: this is the webhook URL registered in GoblinPay
|
||||
define('GOBLINPAY_WC_GATEWAY_ID', 'goblinpay'); // keep stable: ties to the saved settings option
|
||||
define('GOBLINPAY_WC_EXPIRE_HOOK', 'goblinpay_wc_expire_check');
|
||||
define('GOBLINPAY_WC_POLL_HOOK', 'goblinpay_wc_poll_check');
|
||||
|
||||
/* HPOS (custom order tables) + Cart/Checkout Blocks compatibility. */
|
||||
add_action('before_woocommerce_init', function () {
|
||||
if (class_exists('\Automattic\WooCommerce\Utilities\FeaturesUtil')) {
|
||||
\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility('custom_order_tables', __FILE__, true);
|
||||
\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility('cart_checkout_blocks', __FILE__, true);
|
||||
}
|
||||
});
|
||||
|
||||
/* Block checkout payment integration (Woo Blocks, merged into WC core). */
|
||||
add_action('woocommerce_blocks_payment_method_type_registration', function ($registry) {
|
||||
if (class_exists('Automattic\WooCommerce\Blocks\Payments\Integrations\AbstractPaymentMethodType')) {
|
||||
require_once __DIR__ . '/includes/class-blocks.php';
|
||||
$registry->register(new GoblinPay_WC_Blocks_Support());
|
||||
}
|
||||
});
|
||||
|
||||
/* Register the gateway. */
|
||||
add_filter('woocommerce_payment_gateways', function ($gateways) {
|
||||
$gateways[] = 'WC_Gateway_GoblinPay';
|
||||
return $gateways;
|
||||
});
|
||||
|
||||
/* Settings link on the Plugins list. */
|
||||
add_filter('plugin_action_links_' . plugin_basename(__FILE__), function ($links) {
|
||||
$url = admin_url('admin.php?page=wc-settings&tab=checkout§ion=' . GOBLINPAY_WC_GATEWAY_ID);
|
||||
array_unshift($links, '<a href="' . esc_url($url) . '">' . esc_html__('Settings', 'goblinpay-woocommerce') . '</a>');
|
||||
return $links;
|
||||
});
|
||||
|
||||
add_action('plugins_loaded', function () {
|
||||
if (!class_exists('WC_Payment_Gateway')) {
|
||||
return;
|
||||
}
|
||||
|
||||
class WC_Gateway_GoblinPay extends WC_Payment_Gateway {
|
||||
|
||||
public function __construct() {
|
||||
$this->id = GOBLINPAY_WC_GATEWAY_ID;
|
||||
$this->method_title = __('GoblinPay (Grin)', 'goblinpay-woocommerce');
|
||||
$this->method_description = __('Accept Grin (GRIN) payments through a self-hosted GoblinPay server. Customers pay from their Goblin Wallet.', 'goblinpay-woocommerce');
|
||||
$this->has_fields = false;
|
||||
$this->supports = array('products');
|
||||
|
||||
$this->init_form_fields();
|
||||
$this->init_settings();
|
||||
|
||||
$this->title = $this->get_option('title', __('Grin (GRIN)', 'goblinpay-woocommerce'));
|
||||
$this->description = $this->get_option('description');
|
||||
$this->enabled = $this->get_option('enabled', 'no');
|
||||
|
||||
add_action('woocommerce_update_options_payment_gateways_' . $this->id, array($this, 'process_admin_options'));
|
||||
add_action('woocommerce_thankyou_' . $this->id, array($this, 'thankyou_page'));
|
||||
}
|
||||
|
||||
public function init_form_fields() {
|
||||
$webhook_url = esc_html(rest_url(GOBLINPAY_WC_WH_NS . '/webhook'));
|
||||
$this->form_fields = array(
|
||||
'enabled' => array(
|
||||
'title' => __('Enable/Disable', 'goblinpay-woocommerce'),
|
||||
'type' => 'checkbox',
|
||||
'label' => __('Enable Grin payments via GoblinPay', 'goblinpay-woocommerce'),
|
||||
'default' => 'no',
|
||||
),
|
||||
'title' => array(
|
||||
'title' => __('Title', 'goblinpay-woocommerce'),
|
||||
'type' => 'text',
|
||||
'default' => __('Grin (GRIN)', 'goblinpay-woocommerce'),
|
||||
'desc_tip' => true,
|
||||
'description' => __('Payment method title shown at checkout.', 'goblinpay-woocommerce'),
|
||||
),
|
||||
'description' => array(
|
||||
'title' => __('Description', 'goblinpay-woocommerce'),
|
||||
'type' => 'textarea',
|
||||
'default' => __('Pay with Grin from your Goblin Wallet. You will be shown a QR code (or redirected to a secure checkout) to complete the payment.', 'goblinpay-woocommerce'),
|
||||
),
|
||||
'gp_url' => array(
|
||||
'title' => __('GoblinPay URL', 'goblinpay-woocommerce'),
|
||||
'type' => 'text',
|
||||
'default' => 'http://127.0.0.1:8192',
|
||||
'placeholder' => 'http://127.0.0.1:8192',
|
||||
'desc_tip' => true,
|
||||
'description' => __('Base URL of your GoblinPay server (no trailing slash).', 'goblinpay-woocommerce'),
|
||||
),
|
||||
'api_token' => array(
|
||||
'title' => __('API Token', 'goblinpay-woocommerce'),
|
||||
'type' => 'password',
|
||||
'desc_tip' => true,
|
||||
'description' => __('Bearer token for the GoblinPay create-invoice API (GP_API_TOKEN on the server).', 'goblinpay-woocommerce'),
|
||||
),
|
||||
'webhook_secret' => array(
|
||||
'title' => __('Webhook Secret', 'goblinpay-woocommerce'),
|
||||
'type' => 'password',
|
||||
'description' => sprintf(
|
||||
/* translators: %s: webhook URL */
|
||||
__('Shared HMAC secret (GP_WEBHOOK_SECRET on the server). Set GoblinPay\'s GP_WEBHOOK_URL to: %s', 'goblinpay-woocommerce'),
|
||||
'<code>' . $webhook_url . '</code>'
|
||||
),
|
||||
),
|
||||
'match_mode' => array(
|
||||
'title' => __('Matching mode', 'goblinpay-woocommerce'),
|
||||
'type' => 'select',
|
||||
'default' => 'derived',
|
||||
'options' => array(
|
||||
'derived' => __('Per-invoice identity (recommended)', 'goblinpay-woocommerce'),
|
||||
'memo' => __('Order reference (memo)', 'goblinpay-woocommerce'),
|
||||
'amount' => __('Amount only', 'goblinpay-woocommerce'),
|
||||
'' => __('Server default', 'goblinpay-woocommerce'),
|
||||
),
|
||||
'desc_tip' => true,
|
||||
'description' => __('How GoblinPay matches an incoming payment to this order. Per-invoice identity gives each order its own QR and is the most reliable.', 'goblinpay-woocommerce'),
|
||||
),
|
||||
'checkout_ux' => array(
|
||||
'title' => __('Checkout experience', 'goblinpay-woocommerce'),
|
||||
'type' => 'select',
|
||||
'default' => 'redirect',
|
||||
'options' => array(
|
||||
'redirect' => __('Redirect to the hosted GoblinPay checkout (recommended)', 'goblinpay-woocommerce'),
|
||||
'embed' => __('Show the QR on the order-received page', 'goblinpay-woocommerce'),
|
||||
),
|
||||
'desc_tip' => true,
|
||||
'description' => __('Redirect sends the customer to GoblinPay\'s /pay page. Embed keeps them on your site and shows the Goblin QR on the order-received page.', 'goblinpay-woocommerce'),
|
||||
),
|
||||
'payment_window' => array(
|
||||
'title' => __('Payment window (minutes)', 'goblinpay-woocommerce'),
|
||||
'type' => 'number',
|
||||
'default' => '1440',
|
||||
'desc_tip' => true,
|
||||
'description' => __('If still unpaid after this many minutes, the order is cancelled. Set 0 to disable.', 'goblinpay-woocommerce'),
|
||||
),
|
||||
'debug' => array(
|
||||
'title' => __('Debug logging', 'goblinpay-woocommerce'),
|
||||
'type' => 'checkbox',
|
||||
'label' => __('Log requests/webhooks (WooCommerce -> Status -> Logs, source "goblinpay")', 'goblinpay-woocommerce'),
|
||||
'default' => 'no',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private function log($msg) {
|
||||
if ('yes' === $this->get_option('debug', 'no') && function_exists('wc_get_logger')) {
|
||||
wc_get_logger()->info(is_string($msg) ? $msg : wp_json_encode($msg), array('source' => 'goblinpay'));
|
||||
}
|
||||
}
|
||||
|
||||
public function process_payment($order_id) {
|
||||
$order = wc_get_order($order_id);
|
||||
$gp_url = rtrim((string) $this->get_option('gp_url'), '/');
|
||||
$token = trim((string) $this->get_option('api_token'));
|
||||
|
||||
if (!$order || '' === $gp_url || '' === $token) {
|
||||
wc_add_notice(__('Grin payments are not fully configured.', 'goblinpay-woocommerce'), 'error');
|
||||
return array('result' => 'failure');
|
||||
}
|
||||
|
||||
$window = (int) $this->get_option('payment_window', 1440);
|
||||
$mode = (string) $this->get_option('match_mode', 'derived');
|
||||
|
||||
$payload = array(
|
||||
'order_ref' => (string) $order->get_id(),
|
||||
'amount_fiat' => (string) $order->get_total(),
|
||||
'currency' => $order->get_currency(),
|
||||
'memo' => sprintf(
|
||||
/* translators: 1: order number, 2: site name */
|
||||
__('Order %1$s at %2$s', 'goblinpay-woocommerce'),
|
||||
$order->get_order_number(),
|
||||
wp_specialchars_decode(get_bloginfo('name'), ENT_QUOTES)
|
||||
),
|
||||
);
|
||||
if ('' !== $mode) {
|
||||
$payload['match_mode'] = $mode;
|
||||
}
|
||||
if ($window > 0) {
|
||||
$payload['expiry_secs'] = $window * 60;
|
||||
}
|
||||
$this->log(array('create_invoice' => $gp_url . '/invoice', 'payload' => $payload));
|
||||
|
||||
$resp = wp_remote_post($gp_url . '/invoice', array(
|
||||
'timeout' => 30,
|
||||
'headers' => array(
|
||||
'Content-Type' => 'application/json',
|
||||
'Accept' => 'application/json',
|
||||
'Authorization' => 'Bearer ' . $token,
|
||||
),
|
||||
'body' => wp_json_encode($payload),
|
||||
));
|
||||
if (is_wp_error($resp)) {
|
||||
$this->log(array('create_invoice_error' => $resp->get_error_message()));
|
||||
wc_add_notice(__('Could not reach the GoblinPay server. Please try again.', 'goblinpay-woocommerce'), 'error');
|
||||
return array('result' => 'failure');
|
||||
}
|
||||
|
||||
$code = wp_remote_retrieve_response_code($resp);
|
||||
$body = json_decode(wp_remote_retrieve_body($resp), true);
|
||||
if ($code < 200 || $code >= 300 || !is_array($body) || empty($body['invoice_id']) || empty($body['pay_url'])) {
|
||||
$err = (is_array($body) && isset($body['error'])) ? $body['error'] : ('HTTP ' . $code);
|
||||
$this->log(array('create_invoice_bad_response' => $code, 'body' => $body));
|
||||
wc_add_notice(
|
||||
sprintf(
|
||||
/* translators: %s: error message */
|
||||
__('Grin payment could not be started: %s', 'goblinpay-woocommerce'),
|
||||
esc_html((string) $err)
|
||||
),
|
||||
'error'
|
||||
);
|
||||
return array('result' => 'failure');
|
||||
}
|
||||
|
||||
// Persist the checkout details for the order-received page and reconciliation.
|
||||
$order->update_meta_data('_goblinpay_invoice_id', sanitize_text_field((string) $body['invoice_id']));
|
||||
$order->update_meta_data('_goblinpay_pay_url', esc_url_raw((string) $body['pay_url']));
|
||||
if (!empty($body['token'])) {
|
||||
$order->update_meta_data('_goblinpay_token', sanitize_text_field((string) $body['token']));
|
||||
}
|
||||
if (!empty($body['nprofile'])) {
|
||||
$order->update_meta_data('_goblinpay_nprofile', sanitize_text_field((string) $body['nprofile']));
|
||||
}
|
||||
if (!empty($body['amount'])) {
|
||||
$order->update_meta_data('_goblinpay_amount', sanitize_text_field((string) $body['amount']));
|
||||
}
|
||||
if (!empty($body['qr_svg'])) {
|
||||
// Sanitised on output; store the raw SVG returned by our own GoblinPay.
|
||||
$order->update_meta_data('_goblinpay_qr_svg', (string) $body['qr_svg']);
|
||||
}
|
||||
|
||||
// Awaiting payment -> on-hold (reserves stock; avoids WooCommerce's
|
||||
// default unpaid-order auto-cancel that would kill slow crypto payments).
|
||||
$order->update_status('on-hold', sprintf(
|
||||
/* translators: %s: GoblinPay invoice id */
|
||||
__('Awaiting Grin payment (GoblinPay invoice %s).', 'goblinpay-woocommerce'),
|
||||
sanitize_text_field((string) $body['invoice_id'])
|
||||
));
|
||||
$order->save();
|
||||
|
||||
// Webhook-miss safety net: poll the invoice once, mid-window.
|
||||
wp_schedule_single_event(time() + 5 * MINUTE_IN_SECONDS, GOBLINPAY_WC_POLL_HOOK, array($order->get_id()));
|
||||
// Expiry fallback.
|
||||
if ($window > 0) {
|
||||
wp_schedule_single_event(time() + $window * MINUTE_IN_SECONDS, GOBLINPAY_WC_EXPIRE_HOOK, array($order->get_id()));
|
||||
}
|
||||
|
||||
if (function_exists('WC') && WC()->cart) {
|
||||
WC()->cart->empty_cart();
|
||||
}
|
||||
|
||||
$ux = (string) $this->get_option('checkout_ux', 'redirect');
|
||||
if ('embed' === $ux) {
|
||||
// Stay on-site; the QR renders on the order-received page.
|
||||
return array('result' => 'success', 'redirect' => $this->get_return_url($order));
|
||||
}
|
||||
return array('result' => 'success', 'redirect' => esc_url_raw((string) $body['pay_url']));
|
||||
}
|
||||
|
||||
/** Render the Goblin QR + nprofile panel on the order-received page (embed UX). */
|
||||
public function thankyou_page($order_id) {
|
||||
if ('embed' !== (string) $this->get_option('checkout_ux', 'redirect')) {
|
||||
return;
|
||||
}
|
||||
$order = wc_get_order($order_id);
|
||||
if (!$order || $order->get_payment_method() !== GOBLINPAY_WC_GATEWAY_ID) {
|
||||
return;
|
||||
}
|
||||
if ($order->is_paid()) {
|
||||
echo '<section class="goblinpay-panel goblinpay-paid"><p>'
|
||||
. esc_html__('Grin payment received. Thank you!', 'goblinpay-woocommerce')
|
||||
. '</p></section>';
|
||||
return;
|
||||
}
|
||||
|
||||
$qr = (string) $order->get_meta('_goblinpay_qr_svg');
|
||||
$nprofile = (string) $order->get_meta('_goblinpay_nprofile');
|
||||
$pay_url = (string) $order->get_meta('_goblinpay_pay_url');
|
||||
$amount = (string) $order->get_meta('_goblinpay_amount');
|
||||
|
||||
echo '<section class="goblinpay-panel" style="margin:1.5em 0;padding:1em;border:1px solid #e0e0e0;border-radius:8px;max-width:420px">';
|
||||
echo '<h2 style="margin-top:0">' . esc_html__('Pay with Goblin (GRIN)', 'goblinpay-woocommerce') . '</h2>';
|
||||
echo '<p>' . esc_html__('Scan this code with your Goblin Wallet to pay.', 'goblinpay-woocommerce') . '</p>';
|
||||
if ('' !== $amount) {
|
||||
echo '<p><strong>' . esc_html__('Amount:', 'goblinpay-woocommerce') . '</strong> ' . esc_html($amount) . '</p>';
|
||||
}
|
||||
if ('' !== $qr) {
|
||||
echo '<div class="goblinpay-qr" style="max-width:280px">' . goblinpay_wc_kses_svg($qr) . '</div>';
|
||||
}
|
||||
if ('' !== $nprofile) {
|
||||
echo '<p style="word-break:break-all;font-family:monospace;font-size:12px">' . esc_html($nprofile) . '</p>';
|
||||
}
|
||||
if ('' !== $pay_url) {
|
||||
echo '<p><a href="' . esc_url($pay_url) . '" target="_blank" rel="noopener">'
|
||||
. esc_html__('Open the secure GoblinPay checkout', 'goblinpay-woocommerce') . '</a></p>';
|
||||
}
|
||||
echo '<p class="goblinpay-status">' . esc_html__('Waiting for payment. This page refreshes automatically.', 'goblinpay-woocommerce') . '</p>';
|
||||
// Zero-JS live refresh while the order is unpaid (mirrors the hosted page).
|
||||
echo '<meta http-equiv="refresh" content="20">';
|
||||
echo '</section>';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/* ----------------------------------------------------------------------- *
|
||||
* Webhook receiver: POST /wp-json/goblinpay/v1/webhook
|
||||
* ----------------------------------------------------------------------- */
|
||||
add_action('rest_api_init', function () {
|
||||
register_rest_route(GOBLINPAY_WC_WH_NS, '/webhook', array(
|
||||
'methods' => 'POST',
|
||||
'permission_callback' => '__return_true', // authenticated by the HMAC signature below
|
||||
'callback' => 'goblinpay_wc_handle_webhook',
|
||||
));
|
||||
});
|
||||
|
||||
function goblinpay_wc_log($m) {
|
||||
$s = get_option('woocommerce_' . GOBLINPAY_WC_GATEWAY_ID . '_settings', array());
|
||||
if (is_array($s) && !empty($s['debug']) && 'yes' === $s['debug'] && function_exists('wc_get_logger')) {
|
||||
wc_get_logger()->info(is_string($m) ? $m : wp_json_encode($m), array('source' => 'goblinpay'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a GoblinPay payment webhook. Verifies the HMAC over the exact raw
|
||||
* body, dedupes on the event id, maps order_ref -> WC order, and settles.
|
||||
*/
|
||||
function goblinpay_wc_handle_webhook(WP_REST_Request $request) {
|
||||
$settings = get_option('woocommerce_' . GOBLINPAY_WC_GATEWAY_ID . '_settings', array());
|
||||
$secret = (is_array($settings) && isset($settings['webhook_secret'])) ? $settings['webhook_secret'] : '';
|
||||
|
||||
$raw = $request->get_body();
|
||||
$sig = (string) $request->get_header('x-goblinpay-signature');
|
||||
|
||||
if ('' === (string) $secret) {
|
||||
return new WP_REST_Response(array('error' => 'webhook secret not configured'), 500);
|
||||
}
|
||||
// Verify HMAC-SHA256 over the EXACT raw body bytes, constant-time compare.
|
||||
$expected = 'sha256=' . hash_hmac('sha256', $raw, (string) $secret);
|
||||
if (!hash_equals($expected, $sig)) {
|
||||
goblinpay_wc_log(array('webhook_bad_sig' => $sig));
|
||||
return new WP_REST_Response(array('error' => 'invalid signature'), 401);
|
||||
}
|
||||
|
||||
$data = json_decode($raw, true);
|
||||
if (!is_array($data)) {
|
||||
return new WP_REST_Response(array('error' => 'bad payload'), 400);
|
||||
}
|
||||
goblinpay_wc_log(array('webhook' => $data));
|
||||
|
||||
// Idempotency: dedupe on the event id (also carried in X-GoblinPay-Delivery).
|
||||
$event_id = isset($data['event_id']) ? (string) $data['event_id'] : (string) $request->get_header('x-goblinpay-delivery');
|
||||
if ('' !== $event_id) {
|
||||
$key = 'goblinpay_evt_' . md5($event_id);
|
||||
if (false !== get_transient($key)) {
|
||||
return new WP_REST_Response(array('ok' => true, 'dedupe' => true), 200); // already processed
|
||||
}
|
||||
set_transient($key, 1, WEEK_IN_SECONDS);
|
||||
}
|
||||
|
||||
$event_type = isset($data['event_type']) ? (string) $data['event_type'] : '';
|
||||
$order_ref = isset($data['order_ref']) ? (string) $data['order_ref'] : '';
|
||||
$invoice_id = isset($data['invoice_id']) ? (string) $data['invoice_id'] : '';
|
||||
$payment = (isset($data['payment']) && is_array($data['payment'])) ? $data['payment'] : array();
|
||||
$slate_id = isset($payment['slate_id']) ? (string) $payment['slate_id'] : '';
|
||||
|
||||
if ('' === $order_ref) {
|
||||
return new WP_REST_Response(array('ok' => true, 'note' => 'no order_ref'), 200); // ack, nothing to do
|
||||
}
|
||||
$order = wc_get_order((int) $order_ref);
|
||||
if (!$order || $order->get_payment_method() !== GOBLINPAY_WC_GATEWAY_ID) {
|
||||
return new WP_REST_Response(array('ok' => true, 'note' => 'order not found'), 200);
|
||||
}
|
||||
|
||||
// Bind the webhook to the invoice we created for this order (defence in depth).
|
||||
$known = (string) $order->get_meta('_goblinpay_invoice_id');
|
||||
if ('' !== $known && '' !== $invoice_id && !hash_equals($known, $invoice_id)) {
|
||||
goblinpay_wc_log(array('invoice_mismatch' => array('order' => $order_ref, 'known' => $known, 'got' => $invoice_id)));
|
||||
return new WP_REST_Response(array('ok' => true, 'note' => 'invoice mismatch'), 200);
|
||||
}
|
||||
|
||||
switch ($event_type) {
|
||||
case 'payment.received':
|
||||
// Funds received off-chain (S2 returned). Complete the order.
|
||||
goblinpay_wc_settle_order($order, $slate_id, __('Grin payment received via GoblinPay.', 'goblinpay-woocommerce'));
|
||||
break;
|
||||
|
||||
case 'payment.confirmed':
|
||||
// On-chain confirmation may arrive after payment.received. Idempotent:
|
||||
// complete if not already paid, otherwise just note the confirmation.
|
||||
if (!$order->is_paid()) {
|
||||
goblinpay_wc_settle_order($order, $slate_id, __('Grin payment confirmed on chain via GoblinPay.', 'goblinpay-woocommerce'));
|
||||
} else {
|
||||
$height = isset($payment['confirmed_height']) ? $payment['confirmed_height'] : null;
|
||||
$order->add_order_note(
|
||||
null === $height
|
||||
? __('Grin payment confirmed on chain.', 'goblinpay-woocommerce')
|
||||
: sprintf(
|
||||
/* translators: %s: block height */
|
||||
__('Grin payment confirmed on chain at height %s.', 'goblinpay-woocommerce'),
|
||||
(string) $height
|
||||
)
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
goblinpay_wc_log(array('unhandled_event' => $event_type));
|
||||
}
|
||||
|
||||
return new WP_REST_Response(array('ok' => true), 200);
|
||||
}
|
||||
|
||||
/** Complete an order once, idempotently. */
|
||||
function goblinpay_wc_settle_order($order, $slate_id, $note) {
|
||||
if ($order->is_paid()) {
|
||||
return;
|
||||
}
|
||||
$order->payment_complete('' !== (string) $slate_id ? $slate_id : '');
|
||||
$order->add_order_note($note);
|
||||
}
|
||||
|
||||
/* Poll fallback: if a webhook was missed, ask GoblinPay for the invoice status. */
|
||||
add_action(GOBLINPAY_WC_POLL_HOOK, 'goblinpay_wc_poll_invoice');
|
||||
function goblinpay_wc_poll_invoice($order_id) {
|
||||
$order = wc_get_order($order_id);
|
||||
if (!$order || $order->get_payment_method() !== GOBLINPAY_WC_GATEWAY_ID || $order->is_paid()) {
|
||||
return;
|
||||
}
|
||||
$settings = get_option('woocommerce_' . GOBLINPAY_WC_GATEWAY_ID . '_settings', array());
|
||||
$gp_url = isset($settings['gp_url']) ? rtrim((string) $settings['gp_url'], '/') : '';
|
||||
$token = isset($settings['api_token']) ? trim((string) $settings['api_token']) : '';
|
||||
$invoice_id = (string) $order->get_meta('_goblinpay_invoice_id');
|
||||
if ('' === $gp_url || '' === $token || '' === $invoice_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
$resp = wp_remote_get($gp_url . '/invoice/' . rawurlencode($invoice_id), array(
|
||||
'timeout' => 20,
|
||||
'headers' => array(
|
||||
'Accept' => 'application/json',
|
||||
'Authorization' => 'Bearer ' . $token,
|
||||
),
|
||||
));
|
||||
if (is_wp_error($resp)) {
|
||||
goblinpay_wc_log(array('poll_error' => $resp->get_error_message()));
|
||||
return;
|
||||
}
|
||||
$body = json_decode(wp_remote_retrieve_body($resp), true);
|
||||
if (is_array($body) && isset($body['status']) && 'paid' === $body['status']) {
|
||||
goblinpay_wc_settle_order($order, '', __('Grin payment reconciled via GoblinPay status poll.', 'goblinpay-woocommerce'));
|
||||
}
|
||||
}
|
||||
|
||||
/* WooCommerce-side expiry fallback (polls once more before cancelling). */
|
||||
add_action(GOBLINPAY_WC_EXPIRE_HOOK, 'goblinpay_wc_maybe_expire_order');
|
||||
function goblinpay_wc_maybe_expire_order($order_id) {
|
||||
goblinpay_wc_poll_invoice($order_id); // last chance to catch a missed webhook
|
||||
$order = wc_get_order($order_id);
|
||||
if (!$order || $order->get_payment_method() !== GOBLINPAY_WC_GATEWAY_ID) {
|
||||
return;
|
||||
}
|
||||
if (!$order->is_paid() && $order->has_status(array('on-hold', 'pending'))) {
|
||||
$order->update_status('cancelled', __('Grin payment window elapsed without payment.', 'goblinpay-woocommerce'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitise a GoblinPay-generated QR SVG for safe output. Allows only the small
|
||||
* tag/attribute set the server emits (svg/g/rect/path/image), so a compromised
|
||||
* or misconfigured endpoint cannot inject script into the order-received page.
|
||||
*/
|
||||
function goblinpay_wc_kses_svg($svg) {
|
||||
$allowed = array(
|
||||
'svg' => array('xmlns' => true, 'width' => true, 'height' => true, 'viewbox' => true, 'viewBox' => true, 'role' => true, 'shape-rendering' => true, 'class' => true),
|
||||
'g' => array('fill' => true, 'transform' => true),
|
||||
'rect' => array('x' => true, 'y' => true, 'width' => true, 'height' => true, 'rx' => true, 'ry' => true, 'fill' => true),
|
||||
'path' => array('d' => true, 'fill' => true),
|
||||
'image' => array('x' => true, 'y' => true, 'width' => true, 'height' => true, 'href' => true, 'xlink:href' => true, 'preserveaspectratio' => true),
|
||||
'title' => array(),
|
||||
);
|
||||
return wp_kses($svg, $allowed);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
/**
|
||||
* WooCommerce Blocks payment-method integration for the GoblinPay gateway.
|
||||
*
|
||||
* @package GoblinPayWooCommerce
|
||||
*/
|
||||
|
||||
if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
use Automattic\WooCommerce\Blocks\Payments\Integrations\AbstractPaymentMethodType;
|
||||
|
||||
final class GoblinPay_WC_Blocks_Support extends AbstractPaymentMethodType {
|
||||
|
||||
protected $name = 'goblinpay';
|
||||
|
||||
/** @var array Named to avoid clashing with the parent's protected $settings. */
|
||||
private $gw_settings = array();
|
||||
|
||||
public function initialize() {
|
||||
$this->gw_settings = get_option('woocommerce_goblinpay_settings', array());
|
||||
if (!is_array($this->gw_settings)) {
|
||||
$this->gw_settings = array();
|
||||
}
|
||||
}
|
||||
|
||||
public function is_active() {
|
||||
return !empty($this->gw_settings['enabled']) && 'yes' === $this->gw_settings['enabled'];
|
||||
}
|
||||
|
||||
public function get_payment_method_script_handles() {
|
||||
$handle = 'goblinpay-blocks';
|
||||
wp_register_script(
|
||||
$handle,
|
||||
plugins_url('assets/js/blocks.js', GOBLINPAY_WC_PLUGIN_FILE),
|
||||
array('wc-blocks-registry', 'wc-settings', 'wp-element', 'wp-html-entities'),
|
||||
GOBLINPAY_WC_VERSION,
|
||||
true
|
||||
);
|
||||
return array($handle);
|
||||
}
|
||||
|
||||
public function get_payment_method_data() {
|
||||
return array(
|
||||
'title' => !empty($this->gw_settings['title']) ? $this->gw_settings['title'] : 'Grin (GRIN)',
|
||||
'description' => isset($this->gw_settings['description']) ? $this->gw_settings['description'] : '',
|
||||
'supports' => array('products'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
[package]
|
||||
name = "gp-core"
|
||||
description = "GoblinPay domain core: configuration, persistence, invoices, payments"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sqlx.workspace = true
|
||||
|
||||
# Child-identity derivation (matching mode 2 + per-user endpubs): SHA-256 over
|
||||
# the master nsec, reduced to a valid secp256k1 scalar. secp256k1 is pinned to
|
||||
# the same 0.31 line gp-nostr already uses (shared in the lock).
|
||||
sha2 = "0.10"
|
||||
secp256k1 = "0.31"
|
||||
hex = "0.4"
|
||||
# Webhook HMAC signing (milestone 6) with a constant-time verify.
|
||||
hmac = "0.12"
|
||||
subtle = "2"
|
||||
# Server-rendered QR SVG at ECC-H, matrix only (no image/raster feature).
|
||||
qrcode = { version = "0.14", default-features = false }
|
||||
# Random invoice ids and unguessable checkout tokens (same line as gp-nostr).
|
||||
rand = "0.9"
|
||||
# Host-only log line when a stale rate is served (matches the crate's
|
||||
# host-only logging posture).
|
||||
log = "0.4"
|
||||
# The conversion-rate oracle fetch (milestone 7). DIRECT HTTP, never Nym:
|
||||
# gp-core has no mixnet linkage, so the direct path is structural. Reuses the
|
||||
# process-installed rustls `ring` provider (`rustls-no-provider`, no aws-lc-rs),
|
||||
# exactly as gp-server's webhook client does; no JSON feature (we parse the
|
||||
# small body with serde_json).
|
||||
reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["rt", "rt-multi-thread", "macros"] }
|
||||
@@ -0,0 +1,801 @@
|
||||
//! Runtime configuration. Everything that identifies a particular operator's
|
||||
//! GoblinPay instance is read from the environment at startup (env-first,
|
||||
//! same shape as goblin-nip05d), so a second operator can run their own
|
||||
//! instance without touching the source.
|
||||
//!
|
||||
//! Secrets (`GP_MNEMONIC`, `GP_NSEC`) can come from the environment directly
|
||||
//! or from a mounted file via the `*_FILE` variants, never from the repo.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Default listen address (loopback; put a proxy or `GP_TLS=rustls` in front
|
||||
/// for public exposure).
|
||||
pub const DEFAULT_BIND: &str = "127.0.0.1:8080";
|
||||
/// Default SQLite database file, relative to the working directory.
|
||||
pub const DEFAULT_DB_PATH: &str = "./goblinpay.db";
|
||||
/// Default data directory (wallet files, encrypted seed at rest).
|
||||
pub const DEFAULT_DATA_DIR: &str = "./gp-data";
|
||||
/// Default external Grin node (read-only: confirmations and balance).
|
||||
///
|
||||
/// `main.gri.mw`, not `api.grin.money`: the milestone-2/dev round found
|
||||
/// `api.grin.money`'s bulk UTXO scan (`get_unspent_outputs`) returns errors,
|
||||
/// while `main.gri.mw` serves the foreign API (`get_tip`, `get_kernel`)
|
||||
/// cleanly. GoblinPay only ever reads (kernel confirmation + a cached balance),
|
||||
/// and this traffic goes DIRECT over normal HTTP, never through the Nym tunnel
|
||||
/// (owner ruling: node reads are a server concern, like Goblin's own
|
||||
/// wallet->node reads which never ride the mixnet; the mixnet carries only the
|
||||
/// Nostr gift-wrap layer in gp-nostr).
|
||||
pub const DEFAULT_NODE_URL: &str = "https://main.gri.mw";
|
||||
|
||||
/// TLS mode for the HTTP server.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Tls {
|
||||
/// Plain HTTP (default). Run behind a reverse proxy, or local only.
|
||||
Off,
|
||||
/// In-process rustls with a PEM certificate chain and private key.
|
||||
Rustls { cert: String, key: String },
|
||||
}
|
||||
|
||||
/// Grin network to operate on.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Chain {
|
||||
/// Grin mainnet (default).
|
||||
Mainnet,
|
||||
/// Grin testnet.
|
||||
Testnet,
|
||||
}
|
||||
|
||||
/// Where the Nostr relay lives.
|
||||
#[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).
|
||||
Bundled,
|
||||
/// Only external relays from `GP_RELAYS` are used.
|
||||
External,
|
||||
}
|
||||
|
||||
/// Where the conversion oracle fetches the GRIN price (module `rates`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum RateSource {
|
||||
/// CoinGecko simple-price API (default; GRIN is listed under id `grin`).
|
||||
CoinGecko,
|
||||
}
|
||||
|
||||
impl RateSource {
|
||||
/// Stable string id, used on the quote/receipt and in logs.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
RateSource::CoinGecko => "coingecko",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Global default payment-matching mode (per-invoice override comes later).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum MatchMode {
|
||||
/// Match by the payer's memo/reference tag.
|
||||
Memo,
|
||||
/// Match by a per-invoice derived Nostr identity.
|
||||
Derived,
|
||||
/// Match by expected amount within tolerance and expiry.
|
||||
Amount,
|
||||
}
|
||||
|
||||
/// A sensitive value. Debug and serde output never reveal it, so a config
|
||||
/// dump or a startup log line cannot leak a seed or key.
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct Secret(String);
|
||||
|
||||
impl Secret {
|
||||
pub fn new(value: String) -> Self {
|
||||
Secret(value)
|
||||
}
|
||||
|
||||
/// Access the underlying value. Call sites should be deliberate.
|
||||
pub fn reveal(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for Secret {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("Secret(redacted)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolved, validated runtime configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
/// Address the HTTP server binds (`GP_BIND`).
|
||||
pub bind: String,
|
||||
/// TLS mode (`GP_TLS`: `off` or `rustls`, plus `GP_TLS_CERT`/`GP_TLS_KEY`).
|
||||
pub tls: Tls,
|
||||
/// SQLite database path (`GP_DB_PATH`); created on first start.
|
||||
pub db_path: String,
|
||||
/// Data directory (`GP_DATA_DIR`); holds the wallet files, including the
|
||||
/// encrypted seed at rest.
|
||||
pub data_dir: String,
|
||||
/// External Grin node URL (`GP_NODE_URL`), read-only.
|
||||
pub node_url: String,
|
||||
/// Grin network (`GP_CHAIN`: `mainnet` or `testnet`).
|
||||
pub chain: Chain,
|
||||
/// Relay mode (`GP_RELAY_MODE`: `bundled` or `external`).
|
||||
pub relay_mode: RelayMode,
|
||||
/// External relays (`GP_RELAYS`, comma separated).
|
||||
pub relays: Vec<String>,
|
||||
/// Route Nostr traffic over the Nym mixnet (`GP_NYM`: `on` or `off`,
|
||||
/// default on; clearnet is a debugging escape hatch only).
|
||||
pub nym: bool,
|
||||
/// 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,
|
||||
/// Global default matching mode (`GP_MATCH_MODE`).
|
||||
pub match_mode: MatchMode,
|
||||
/// Grin seed mnemonic (`GP_MNEMONIC` or `GP_MNEMONIC_FILE`). Money secret.
|
||||
#[serde(skip)]
|
||||
pub mnemonic: Option<Secret>,
|
||||
/// Password encrypting the wallet seed file at rest (`GP_WALLET_PASSWORD`
|
||||
/// or `GP_WALLET_PASSWORD_FILE`). Required to open the wallet. Also
|
||||
/// encrypts the auto-generated Nostr identity file at rest.
|
||||
#[serde(skip)]
|
||||
pub wallet_password: Option<Secret>,
|
||||
/// Nostr identity key (`GP_NSEC` or `GP_NSEC_FILE`). Payment identity
|
||||
/// secret, deliberately independent of the Grin seed.
|
||||
#[serde(skip)]
|
||||
pub nsec: Option<Secret>,
|
||||
/// NIP-49 encrypted Nostr identity key (`GP_NCRYPTSEC` or
|
||||
/// `GP_NCRYPTSEC_FILE`), unlocked with the wallet password. Mutually
|
||||
/// exclusive with `GP_NSEC`.
|
||||
#[serde(skip)]
|
||||
pub ncryptsec: Option<Secret>,
|
||||
/// Public base URL of this instance (`GP_PUBLIC_URL`), used to build the
|
||||
/// hosted `/pay/<token>` links. Defaults to `http://<bind>`.
|
||||
pub public_url: String,
|
||||
/// Bearer token for the connector/create-invoice API (`GP_API_TOKEN`).
|
||||
/// When unset, the write API is closed (503) rather than open.
|
||||
#[serde(skip)]
|
||||
pub api_token: Option<Secret>,
|
||||
/// Bearer token for the admin dashboard/API (`GP_ADMIN_TOKEN`).
|
||||
#[serde(skip)]
|
||||
pub admin_token: Option<Secret>,
|
||||
/// Webhook endpoint (`GP_WEBHOOK_URL`) payment events are delivered to.
|
||||
pub webhook_url: Option<String>,
|
||||
/// HMAC secret for signing webhooks (`GP_WEBHOOK_SECRET`).
|
||||
#[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.
|
||||
pub qr_logo: Option<String>,
|
||||
/// Merchant npub for confirmed-payment DMs (`GP_MERCHANT_NPUB`).
|
||||
pub merchant_npub: Option<String>,
|
||||
/// Send a NIP-17 DM to the merchant on a received payment
|
||||
/// (`GP_NOTIFY_MERCHANT_DM`, default off).
|
||||
pub notify_merchant_dm: bool,
|
||||
/// Send a NIP-17 receipt DM to the payer (`GP_NOTIFY_PAYER_RECEIPT`,
|
||||
/// default off).
|
||||
pub notify_payer_receipt: bool,
|
||||
/// Default per-user endpub rotation interval in seconds
|
||||
/// (`GP_ENDPUB_ROTATE_INTERVAL`, 0 = off).
|
||||
pub endpub_rotate_interval: i64,
|
||||
/// How many past epochs to keep watching after a rotation
|
||||
/// (`GP_ENDPUB_OVERLAP_EPOCHS`, default 1).
|
||||
pub endpub_overlap_epochs: i64,
|
||||
/// Conversion-rate source (`GP_RATE_SOURCE`, default `coingecko`).
|
||||
pub rate_source: RateSource,
|
||||
/// Supported fiat currencies (`GP_RATE_CURRENCIES`, comma separated,
|
||||
/// lowercased ISO codes; default `usd`). A fiat invoice in any other
|
||||
/// currency is rejected up front.
|
||||
pub rate_currencies: Vec<String>,
|
||||
/// Seconds a fetched rate is reused before refetching
|
||||
/// (`GP_RATE_CACHE_TTL`, default 60).
|
||||
pub rate_cache_ttl: i64,
|
||||
/// Seconds a created fiat invoice locks its Grin quote (`GP_QUOTE_TTL`,
|
||||
/// default 900); this becomes the invoice expiry window.
|
||||
pub quote_ttl: i64,
|
||||
/// Bounded stale-rate fallback in seconds (`GP_RATE_STALE_MAX`, default 0
|
||||
/// = off): if a live fetch fails, a cached rate this recent is served
|
||||
/// rather than failing the checkout.
|
||||
pub rate_stale_max: i64,
|
||||
}
|
||||
|
||||
/// Default supported fiat currency when `GP_RATE_CURRENCIES` is unset.
|
||||
pub const DEFAULT_RATE_CURRENCY: &str = "usd";
|
||||
/// Default rate cache freshness (seconds).
|
||||
pub const DEFAULT_RATE_CACHE_TTL: i64 = 60;
|
||||
/// Default quote lock window (seconds).
|
||||
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";
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Config {
|
||||
bind: DEFAULT_BIND.into(),
|
||||
tls: Tls::Off,
|
||||
db_path: DEFAULT_DB_PATH.into(),
|
||||
data_dir: DEFAULT_DATA_DIR.into(),
|
||||
node_url: DEFAULT_NODE_URL.into(),
|
||||
chain: Chain::Mainnet,
|
||||
relay_mode: RelayMode::Bundled,
|
||||
relays: Vec::new(),
|
||||
nym: true,
|
||||
ingest: true,
|
||||
match_mode: MatchMode::Memo,
|
||||
mnemonic: None,
|
||||
wallet_password: None,
|
||||
nsec: None,
|
||||
ncryptsec: None,
|
||||
public_url: format!("http://{DEFAULT_BIND}"),
|
||||
api_token: None,
|
||||
admin_token: None,
|
||||
webhook_url: None,
|
||||
webhook_secret: None,
|
||||
qr_logo: Some(DEFAULT_QR_LOGO.into()),
|
||||
merchant_npub: None,
|
||||
notify_merchant_dm: false,
|
||||
notify_payer_receipt: false,
|
||||
endpub_rotate_interval: 0,
|
||||
endpub_overlap_epochs: 1,
|
||||
rate_source: RateSource::CoinGecko,
|
||||
rate_currencies: vec![DEFAULT_RATE_CURRENCY.to_string()],
|
||||
rate_cache_ttl: DEFAULT_RATE_CACHE_TTL,
|
||||
quote_ttl: DEFAULT_QUOTE_TTL,
|
||||
rate_stale_max: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Load from the process environment, applying defaults, then validate.
|
||||
/// Returns an error string on misconfiguration (caller should fail fast).
|
||||
pub fn from_env() -> Result<Self, String> {
|
||||
Self::from_lookup(&|key| std::env::var(key).ok())
|
||||
}
|
||||
|
||||
/// Load from an arbitrary key lookup (the environment in production, a
|
||||
/// map in tests, so tests never mutate global process state).
|
||||
pub fn from_lookup(get: &dyn Fn(&str) -> Option<String>) -> Result<Self, String> {
|
||||
let defaults = Config::default();
|
||||
|
||||
let bind = get("GP_BIND").unwrap_or(defaults.bind);
|
||||
|
||||
let tls = match get("GP_TLS").as_deref().unwrap_or("off") {
|
||||
"off" => Tls::Off,
|
||||
"rustls" => {
|
||||
let cert = get("GP_TLS_CERT")
|
||||
.ok_or("GP_TLS=rustls requires GP_TLS_CERT (PEM certificate chain path)")?;
|
||||
let key = get("GP_TLS_KEY")
|
||||
.ok_or("GP_TLS=rustls requires GP_TLS_KEY (PEM private key path)")?;
|
||||
Tls::Rustls { cert, key }
|
||||
}
|
||||
other => return Err(format!("GP_TLS must be `off` or `rustls` (got `{other}`)")),
|
||||
};
|
||||
|
||||
let db_path = get("GP_DB_PATH").unwrap_or(defaults.db_path);
|
||||
let data_dir = get("GP_DATA_DIR").unwrap_or(defaults.data_dir);
|
||||
let node_url = get("GP_NODE_URL").unwrap_or(defaults.node_url);
|
||||
|
||||
let chain = match get("GP_CHAIN").as_deref().unwrap_or("mainnet") {
|
||||
"mainnet" => Chain::Mainnet,
|
||||
"testnet" => Chain::Testnet,
|
||||
other => {
|
||||
return Err(format!(
|
||||
"GP_CHAIN must be `mainnet` or `testnet` (got `{other}`)"
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let relay_mode = match get("GP_RELAY_MODE").as_deref().unwrap_or("bundled") {
|
||||
"bundled" => RelayMode::Bundled,
|
||||
"external" => RelayMode::External,
|
||||
other => {
|
||||
return Err(format!(
|
||||
"GP_RELAY_MODE must be `bundled` or `external` (got `{other}`)"
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let relays = get("GP_RELAYS")
|
||||
.unwrap_or_default()
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let nym = match get("GP_NYM").as_deref().unwrap_or("on") {
|
||||
"on" => true,
|
||||
"off" => false,
|
||||
other => return Err(format!("GP_NYM must be `on` or `off` (got `{other}`)")),
|
||||
};
|
||||
|
||||
let ingest = match get("GP_INGEST").as_deref().unwrap_or("on") {
|
||||
"on" => true,
|
||||
"off" => false,
|
||||
other => return Err(format!("GP_INGEST must be `on` or `off` (got `{other}`)")),
|
||||
};
|
||||
|
||||
let match_mode = match get("GP_MATCH_MODE").as_deref().unwrap_or("memo") {
|
||||
"memo" => MatchMode::Memo,
|
||||
"derived" => MatchMode::Derived,
|
||||
"amount" => MatchMode::Amount,
|
||||
other => {
|
||||
return Err(format!(
|
||||
"GP_MATCH_MODE must be `memo`, `derived`, or `amount` (got `{other}`)"
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let mnemonic = secret(get, "GP_MNEMONIC")?;
|
||||
let wallet_password = secret(get, "GP_WALLET_PASSWORD")?;
|
||||
let nsec = secret(get, "GP_NSEC")?;
|
||||
let ncryptsec = secret(get, "GP_NCRYPTSEC")?;
|
||||
|
||||
let public_url = get("GP_PUBLIC_URL")
|
||||
.map(|u| u.trim_end_matches('/').to_string())
|
||||
.filter(|u| !u.is_empty())
|
||||
.unwrap_or_else(|| format!("http://{bind}"));
|
||||
let api_token = secret(get, "GP_API_TOKEN")?;
|
||||
let admin_token = secret(get, "GP_ADMIN_TOKEN")?;
|
||||
let webhook_url = get("GP_WEBHOOK_URL").filter(|s| !s.trim().is_empty());
|
||||
let webhook_secret = secret(get, "GP_WEBHOOK_SECRET")?;
|
||||
let qr_logo = match get("GP_QR_LOGO").as_deref() {
|
||||
None => Some(DEFAULT_QR_LOGO.to_string()),
|
||||
Some("off") | Some("none") | Some("") => None,
|
||||
Some(other) => Some(other.to_string()),
|
||||
};
|
||||
let merchant_npub = get("GP_MERCHANT_NPUB").filter(|s| !s.trim().is_empty());
|
||||
let notify_merchant_dm = parse_bool(get, "GP_NOTIFY_MERCHANT_DM", false)?;
|
||||
let notify_payer_receipt = parse_bool(get, "GP_NOTIFY_PAYER_RECEIPT", false)?;
|
||||
let endpub_rotate_interval = parse_i64(get, "GP_ENDPUB_ROTATE_INTERVAL", 0)?;
|
||||
let endpub_overlap_epochs = parse_i64(get, "GP_ENDPUB_OVERLAP_EPOCHS", 1)?;
|
||||
|
||||
let rate_source = match get("GP_RATE_SOURCE").as_deref().unwrap_or("coingecko") {
|
||||
"coingecko" => RateSource::CoinGecko,
|
||||
other => {
|
||||
return Err(format!(
|
||||
"GP_RATE_SOURCE must be `coingecko` (got `{other}`)"
|
||||
))
|
||||
}
|
||||
};
|
||||
let rate_currencies = match get("GP_RATE_CURRENCIES") {
|
||||
None => vec![DEFAULT_RATE_CURRENCY.to_string()],
|
||||
Some(raw) => {
|
||||
let list = raw
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_lowercase())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
if list.is_empty() {
|
||||
return Err("GP_RATE_CURRENCIES must list at least one currency".into());
|
||||
}
|
||||
list
|
||||
}
|
||||
};
|
||||
let rate_cache_ttl = parse_i64(get, "GP_RATE_CACHE_TTL", DEFAULT_RATE_CACHE_TTL)?;
|
||||
let quote_ttl = parse_i64(get, "GP_QUOTE_TTL", DEFAULT_QUOTE_TTL)?;
|
||||
let rate_stale_max = parse_i64(get, "GP_RATE_STALE_MAX", 0)?;
|
||||
|
||||
let cfg = Config {
|
||||
bind,
|
||||
tls,
|
||||
db_path,
|
||||
data_dir,
|
||||
node_url,
|
||||
chain,
|
||||
relay_mode,
|
||||
relays,
|
||||
nym,
|
||||
ingest,
|
||||
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,
|
||||
rate_source,
|
||||
rate_currencies,
|
||||
rate_cache_ttl,
|
||||
quote_ttl,
|
||||
rate_stale_max,
|
||||
};
|
||||
cfg.validate()?;
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
/// The QR center-logo href to render, or `None` when disabled.
|
||||
pub fn qr_logo_href(&self) -> Option<&str> {
|
||||
self.qr_logo.as_deref()
|
||||
}
|
||||
|
||||
/// Fail-fast consistency checks.
|
||||
fn validate(&self) -> Result<(), String> {
|
||||
if self.bind.is_empty() {
|
||||
return Err("GP_BIND must not be empty".into());
|
||||
}
|
||||
if self.db_path.is_empty() {
|
||||
return Err("GP_DB_PATH must not be empty".into());
|
||||
}
|
||||
if self.data_dir.is_empty() {
|
||||
return Err("GP_DATA_DIR must not be empty".into());
|
||||
}
|
||||
if !self.node_url.starts_with("http://") && !self.node_url.starts_with("https://") {
|
||||
return Err(format!(
|
||||
"GP_NODE_URL must start with http:// or https:// (got `{}`)",
|
||||
self.node_url
|
||||
));
|
||||
}
|
||||
if self.relay_mode == RelayMode::External && self.relays.is_empty() {
|
||||
return Err("GP_RELAY_MODE=external requires GP_RELAYS".into());
|
||||
}
|
||||
if self.nsec.is_some() && self.ncryptsec.is_some() {
|
||||
return Err("set only one of GP_NSEC and GP_NCRYPTSEC".into());
|
||||
}
|
||||
if self.webhook_url.is_some() && self.webhook_secret.is_none() {
|
||||
return Err(
|
||||
"GP_WEBHOOK_URL requires GP_WEBHOOK_SECRET (webhooks are HMAC-signed)".into(),
|
||||
);
|
||||
}
|
||||
if self.endpub_overlap_epochs < 0 {
|
||||
return Err("GP_ENDPUB_OVERLAP_EPOCHS must be >= 0".into());
|
||||
}
|
||||
if self.endpub_rotate_interval < 0 {
|
||||
return Err("GP_ENDPUB_ROTATE_INTERVAL must be >= 0 (0 = off)".into());
|
||||
}
|
||||
if self.rate_currencies.is_empty() {
|
||||
return Err("GP_RATE_CURRENCIES must list at least one currency".into());
|
||||
}
|
||||
if self.quote_ttl <= 0 {
|
||||
return Err("GP_QUOTE_TTL must be > 0 (seconds)".into());
|
||||
}
|
||||
if self.rate_cache_ttl < 0 {
|
||||
return Err("GP_RATE_CACHE_TTL must be >= 0 (0 = always refetch)".into());
|
||||
}
|
||||
if self.rate_stale_max < 0 {
|
||||
return Err("GP_RATE_STALE_MAX must be >= 0 (0 = off)".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One-line summary for the startup log. Secrets show only as set/unset.
|
||||
pub fn summary(&self) -> String {
|
||||
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={} \
|
||||
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={} \
|
||||
rate_source={} rate_currencies={:?} rate_cache_ttl={} quote_ttl={} \
|
||||
rate_stale_max={}",
|
||||
self.bind,
|
||||
match &self.tls {
|
||||
Tls::Off => "off".to_string(),
|
||||
Tls::Rustls { cert, key } => format!("rustls(cert={cert},key={key})"),
|
||||
},
|
||||
self.db_path,
|
||||
self.data_dir,
|
||||
self.node_url,
|
||||
self.chain,
|
||||
self.relay_mode,
|
||||
self.relays,
|
||||
if self.nym { "on" } else { "off" },
|
||||
if self.ingest { "on" } else { "off" },
|
||||
self.match_mode,
|
||||
set(self.mnemonic.is_some()),
|
||||
set(self.wallet_password.is_some()),
|
||||
set(self.nsec.is_some()),
|
||||
set(self.ncryptsec.is_some()),
|
||||
self.public_url,
|
||||
set(self.api_token.is_some()),
|
||||
set(self.admin_token.is_some()),
|
||||
self.webhook_url.as_deref().unwrap_or("unset"),
|
||||
set(self.webhook_secret.is_some()),
|
||||
self.qr_logo.as_deref().unwrap_or("off"),
|
||||
self.merchant_npub.as_deref().unwrap_or("unset"),
|
||||
if self.notify_merchant_dm { "on" } else { "off" },
|
||||
if self.notify_payer_receipt {
|
||||
"on"
|
||||
} else {
|
||||
"off"
|
||||
},
|
||||
self.endpub_rotate_interval,
|
||||
self.endpub_overlap_epochs,
|
||||
self.rate_source.as_str(),
|
||||
self.rate_currencies,
|
||||
self.rate_cache_ttl,
|
||||
self.quote_ttl,
|
||||
self.rate_stale_max,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse an `on`/`off` flag with a default.
|
||||
fn parse_bool(
|
||||
get: &dyn Fn(&str) -> Option<String>,
|
||||
key: &str,
|
||||
default: bool,
|
||||
) -> Result<bool, String> {
|
||||
match get(key).as_deref() {
|
||||
None => Ok(default),
|
||||
Some("on") => Ok(true),
|
||||
Some("off") => Ok(false),
|
||||
Some(other) => Err(format!("{key} must be `on` or `off` (got `{other}`)")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse an integer with a default.
|
||||
fn parse_i64(get: &dyn Fn(&str) -> Option<String>, key: &str, default: i64) -> Result<i64, String> {
|
||||
match get(key) {
|
||||
None => Ok(default),
|
||||
Some(v) => v
|
||||
.trim()
|
||||
.parse::<i64>()
|
||||
.map_err(|_| format!("{key} must be an integer (got `{v}`)")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a secret from `KEY` or `KEY_FILE` (mounted file, trailing newline
|
||||
/// trimmed). Setting both is a hard error, so a stray env var can never
|
||||
/// silently shadow the mounted file or vice versa.
|
||||
fn secret(get: &dyn Fn(&str) -> Option<String>, key: &str) -> Result<Option<Secret>, String> {
|
||||
let file_key = format!("{key}_FILE");
|
||||
match (get(key), get(&file_key)) {
|
||||
(Some(_), Some(_)) => Err(format!("set only one of {key} and {file_key}")),
|
||||
(Some(value), None) => Ok(Some(Secret::new(value))),
|
||||
(None, Some(path)) => {
|
||||
let text = std::fs::read_to_string(&path)
|
||||
.map_err(|e| format!("{file_key} `{path}` unreadable: {e}"))?;
|
||||
let value = text.trim_end_matches(['\n', '\r']).to_string();
|
||||
if value.is_empty() {
|
||||
return Err(format!("{file_key} `{path}` is empty"));
|
||||
}
|
||||
Ok(Some(Secret::new(value)))
|
||||
}
|
||||
(None, None) => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn load(vars: &[(&str, &str)]) -> Result<Config, String> {
|
||||
let map: HashMap<String, String> = vars
|
||||
.iter()
|
||||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||
.collect();
|
||||
Config::from_lookup(&|key| map.get(key).cloned())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_env_yields_defaults() {
|
||||
let cfg = load(&[]).unwrap();
|
||||
assert_eq!(cfg.bind, DEFAULT_BIND);
|
||||
assert_eq!(cfg.tls, Tls::Off);
|
||||
assert_eq!(cfg.db_path, DEFAULT_DB_PATH);
|
||||
assert_eq!(cfg.data_dir, DEFAULT_DATA_DIR);
|
||||
assert_eq!(cfg.node_url, DEFAULT_NODE_URL);
|
||||
assert_eq!(cfg.chain, Chain::Mainnet);
|
||||
assert_eq!(cfg.relay_mode, RelayMode::Bundled);
|
||||
assert!(cfg.relays.is_empty());
|
||||
assert!(cfg.nym);
|
||||
assert!(cfg.ingest);
|
||||
assert_eq!(cfg.match_mode, MatchMode::Memo);
|
||||
assert!(cfg.mnemonic.is_none());
|
||||
assert!(cfg.wallet_password.is_none());
|
||||
assert!(cfg.nsec.is_none());
|
||||
assert!(cfg.ncryptsec.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overrides_are_applied() {
|
||||
let cfg = load(&[
|
||||
("GP_BIND", "0.0.0.0:9000"),
|
||||
("GP_DB_PATH", "/var/lib/goblinpay/gp.db"),
|
||||
("GP_DATA_DIR", "/var/lib/goblinpay/data"),
|
||||
("GP_NODE_URL", "http://127.0.0.1:3413"),
|
||||
("GP_CHAIN", "testnet"),
|
||||
("GP_RELAY_MODE", "external"),
|
||||
("GP_RELAYS", "wss://relay.example, wss://relay2.example ,"),
|
||||
("GP_NYM", "off"),
|
||||
("GP_INGEST", "off"),
|
||||
("GP_MATCH_MODE", "derived"),
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(cfg.bind, "0.0.0.0:9000");
|
||||
assert_eq!(cfg.db_path, "/var/lib/goblinpay/gp.db");
|
||||
assert_eq!(cfg.data_dir, "/var/lib/goblinpay/data");
|
||||
assert_eq!(cfg.node_url, "http://127.0.0.1:3413");
|
||||
assert_eq!(cfg.chain, Chain::Testnet);
|
||||
assert_eq!(cfg.relay_mode, RelayMode::External);
|
||||
assert_eq!(
|
||||
cfg.relays,
|
||||
vec!["wss://relay.example", "wss://relay2.example"]
|
||||
);
|
||||
assert!(!cfg.nym);
|
||||
assert!(!cfg.ingest);
|
||||
assert_eq!(cfg.match_mode, MatchMode::Derived);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tls_rustls_requires_cert_and_key() {
|
||||
assert!(load(&[("GP_TLS", "rustls")]).is_err());
|
||||
assert!(load(&[("GP_TLS", "rustls"), ("GP_TLS_CERT", "/c.pem")]).is_err());
|
||||
let cfg = load(&[
|
||||
("GP_TLS", "rustls"),
|
||||
("GP_TLS_CERT", "/c.pem"),
|
||||
("GP_TLS_KEY", "/k.pem"),
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
cfg.tls,
|
||||
Tls::Rustls {
|
||||
cert: "/c.pem".into(),
|
||||
key: "/k.pem".into()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_enum_values() {
|
||||
assert!(load(&[("GP_TLS", "acme")]).is_err());
|
||||
assert!(load(&[("GP_CHAIN", "floonet")]).is_err());
|
||||
assert!(load(&[("GP_RELAY_MODE", "both")]).is_err());
|
||||
assert!(load(&[("GP_NYM", "true")]).is_err());
|
||||
assert!(load(&[("GP_INGEST", "yes")]).is_err());
|
||||
assert!(load(&[("GP_MATCH_MODE", "exact")]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nsec_and_ncryptsec_together_is_an_error() {
|
||||
assert!(load(&[("GP_NSEC", "nsec1a"), ("GP_NCRYPTSEC", "ncryptsec1b")]).is_err());
|
||||
assert!(load(&[("GP_NCRYPTSEC", "ncryptsec1b")]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_node_url_and_external_without_relays() {
|
||||
assert!(load(&[("GP_NODE_URL", "grin.money")]).is_err());
|
||||
assert!(load(&[("GP_RELAY_MODE", "external")]).is_err());
|
||||
assert!(load(&[("GP_DATA_DIR", "")]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_from_env_var() {
|
||||
let cfg = load(&[("GP_MNEMONIC", "abandon ability able")]).unwrap();
|
||||
assert_eq!(cfg.mnemonic.unwrap().reveal(), "abandon ability able");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_from_mounted_file_trims_trailing_newline() {
|
||||
let path = std::env::temp_dir().join(format!("gp-nsec-{}", std::process::id()));
|
||||
std::fs::write(&path, "nsec1testvalue\n").unwrap();
|
||||
let cfg = load(&[("GP_NSEC_FILE", path.to_str().unwrap())]).unwrap();
|
||||
assert_eq!(cfg.nsec.unwrap().reveal(), "nsec1testvalue");
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_env_and_file_together_is_an_error() {
|
||||
assert!(load(&[("GP_NSEC", "a"), ("GP_NSEC_FILE", "/tmp/x")]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_missing_file_is_an_error() {
|
||||
assert!(load(&[("GP_MNEMONIC_FILE", "/nonexistent/gp-seed")]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn m5_m6_defaults_and_overrides() {
|
||||
let cfg = load(&[]).unwrap();
|
||||
assert_eq!(cfg.public_url, format!("http://{DEFAULT_BIND}"));
|
||||
assert_eq!(cfg.qr_logo.as_deref(), Some(DEFAULT_QR_LOGO));
|
||||
assert!(!cfg.notify_merchant_dm);
|
||||
assert!(!cfg.notify_payer_receipt);
|
||||
assert_eq!(cfg.endpub_rotate_interval, 0);
|
||||
assert_eq!(cfg.endpub_overlap_epochs, 1);
|
||||
assert!(cfg.api_token.is_none());
|
||||
|
||||
let cfg = load(&[
|
||||
("GP_PUBLIC_URL", "https://pay.example/"),
|
||||
("GP_API_TOKEN", "apitok"),
|
||||
("GP_ADMIN_TOKEN", "admintok"),
|
||||
("GP_QR_LOGO", "off"),
|
||||
("GP_NOTIFY_MERCHANT_DM", "on"),
|
||||
("GP_ENDPUB_ROTATE_INTERVAL", "3600"),
|
||||
("GP_ENDPUB_OVERLAP_EPOCHS", "2"),
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(cfg.public_url, "https://pay.example"); // trailing slash trimmed
|
||||
assert_eq!(cfg.api_token.unwrap().reveal(), "apitok");
|
||||
assert!(cfg.qr_logo.is_none(), "off disables the logo");
|
||||
assert!(cfg.notify_merchant_dm);
|
||||
assert_eq!(cfg.endpub_rotate_interval, 3600);
|
||||
assert_eq!(cfg.endpub_overlap_epochs, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webhook_url_requires_secret_and_flags_validate() {
|
||||
assert!(load(&[("GP_WEBHOOK_URL", "https://store/hook")]).is_err());
|
||||
assert!(load(&[
|
||||
("GP_WEBHOOK_URL", "https://store/hook"),
|
||||
("GP_WEBHOOK_SECRET", "shh"),
|
||||
])
|
||||
.is_ok());
|
||||
assert!(load(&[("GP_NOTIFY_MERCHANT_DM", "yes")]).is_err());
|
||||
assert!(load(&[("GP_ENDPUB_ROTATE_INTERVAL", "-5")]).is_err());
|
||||
assert!(load(&[("GP_ENDPUB_ROTATE_INTERVAL", "notanumber")]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn m7_rate_defaults_and_overrides() {
|
||||
let cfg = load(&[]).unwrap();
|
||||
assert_eq!(cfg.rate_source, RateSource::CoinGecko);
|
||||
assert_eq!(cfg.rate_currencies, vec!["usd".to_string()]);
|
||||
assert_eq!(cfg.rate_cache_ttl, DEFAULT_RATE_CACHE_TTL);
|
||||
assert_eq!(cfg.quote_ttl, DEFAULT_QUOTE_TTL);
|
||||
assert_eq!(cfg.rate_stale_max, 0);
|
||||
|
||||
let cfg = load(&[
|
||||
("GP_RATE_SOURCE", "coingecko"),
|
||||
("GP_RATE_CURRENCIES", "USD, eur , GBP,"),
|
||||
("GP_RATE_CACHE_TTL", "30"),
|
||||
("GP_QUOTE_TTL", "600"),
|
||||
("GP_RATE_STALE_MAX", "1800"),
|
||||
])
|
||||
.unwrap();
|
||||
// Currencies are lowercased and trimmed, blanks dropped.
|
||||
assert_eq!(cfg.rate_currencies, vec!["usd", "eur", "gbp"]);
|
||||
assert_eq!(cfg.rate_cache_ttl, 30);
|
||||
assert_eq!(cfg.quote_ttl, 600);
|
||||
assert_eq!(cfg.rate_stale_max, 1800);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn m7_rate_validation_rejects_bad_values() {
|
||||
assert!(load(&[("GP_RATE_SOURCE", "messari")]).is_err());
|
||||
assert!(load(&[("GP_RATE_CURRENCIES", " , ")]).is_err());
|
||||
assert!(load(&[("GP_QUOTE_TTL", "0")]).is_err());
|
||||
assert!(load(&[("GP_QUOTE_TTL", "-1")]).is_err());
|
||||
assert!(load(&[("GP_RATE_CACHE_TTL", "-1")]).is_err());
|
||||
assert!(load(&[("GP_RATE_STALE_MAX", "-5")]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_and_summary_never_leak_secrets() {
|
||||
let cfg = load(&[
|
||||
("GP_MNEMONIC", "topsecret words"),
|
||||
("GP_WALLET_PASSWORD", "hushhush"),
|
||||
])
|
||||
.unwrap();
|
||||
let debug = format!("{cfg:?}");
|
||||
assert!(!debug.contains("topsecret"));
|
||||
assert!(!debug.contains("hushhush"));
|
||||
assert!(debug.contains("Secret(redacted)"));
|
||||
let summary = cfg.summary();
|
||||
assert!(!summary.contains("topsecret"));
|
||||
assert!(!summary.contains("hushhush"));
|
||||
assert!(summary.contains("mnemonic=set"));
|
||||
assert!(summary.contains("wallet_password=set"));
|
||||
assert!(summary.contains("nsec=unset"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//! SQLite persistence via raw `sqlx` (no ORM). One database file, zero-ops,
|
||||
//! trivial backup. DB access stays behind this thin module so a later
|
||||
//! Postgres swap is contained.
|
||||
|
||||
use sqlx::migrate::Migrator;
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
|
||||
|
||||
/// Embedded migrations from the workspace-level `migrations/` directory.
|
||||
pub static MIGRATOR: Migrator = sqlx::migrate!("../../migrations");
|
||||
|
||||
/// Open (creating if missing) the SQLite database at `db_path` and bring the
|
||||
/// schema up to date. Called once at startup.
|
||||
///
|
||||
/// A single pooled connection: SQLite serializes writers anyway, and one
|
||||
/// connection keeps the migrator (and every write) free of "database is
|
||||
/// locked" contention. Fine for a low-traffic receive-only till; a later
|
||||
/// Postgres swap for the multi-store backend lifts the ceiling.
|
||||
pub async fn init(db_path: &str) -> Result<SqlitePool, sqlx::Error> {
|
||||
let options = SqliteConnectOptions::new()
|
||||
.filename(db_path)
|
||||
.create_if_missing(true)
|
||||
.busy_timeout(std::time::Duration::from_secs(10))
|
||||
.foreign_keys(true);
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect_with(options)
|
||||
.await?;
|
||||
MIGRATOR.run(&pool).await?;
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
/// A migrated in-memory database on a single shared connection, for tests.
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn test_pool() -> SqlitePool {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.expect("open in-memory sqlite");
|
||||
MIGRATOR.run(&pool).await.expect("run migrations");
|
||||
pool
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
async fn table_names(pool: &SqlitePool) -> Vec<String> {
|
||||
sqlx::query_scalar("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name")
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn init_creates_db_and_applies_migrations() {
|
||||
let path = std::env::temp_dir().join(format!("gp-db-test-{}.db", std::process::id()));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
|
||||
let pool = init(path.to_str().unwrap()).await.unwrap();
|
||||
let tables = table_names(&pool).await;
|
||||
assert!(tables.contains(&"payment".to_string()), "{tables:?}");
|
||||
assert!(tables.contains(&"invoice".to_string()), "{tables:?}");
|
||||
pool.close().await;
|
||||
|
||||
// Re-opening an existing database re-runs the migrator harmlessly.
|
||||
let pool = init(path.to_str().unwrap()).await.unwrap();
|
||||
assert!(table_names(&pool).await.contains(&"payment".to_string()));
|
||||
pool.close().await;
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
//! Deterministic, stateless child-identity derivation from the server's Nostr
|
||||
//! secret key.
|
||||
//!
|
||||
//! Both matching mode 2 (per-invoice derived identity) and the per-user
|
||||
//! endpubs of milestone 5b derive a fresh Nostr identity as a child of the
|
||||
//! server nsec, keyed by a context (the invoice id, or `user_id || epoch`):
|
||||
//!
|
||||
//! ```text
|
||||
//! child_sk = SHA256(master_sk || context) (retry with a counter if the
|
||||
//! digest is not a valid scalar)
|
||||
//! ```
|
||||
//!
|
||||
//! This is deliberately derived from the **Nostr** master secret, never the
|
||||
//! Grin seed (the two-secrets rule, G6): a child identity can decrypt a gift
|
||||
//! wrap, it can never touch the money. Nothing is stored: any child key
|
||||
//! recomputes from its context on demand, so the database holds only public
|
||||
//! keys, assignments, and the rotation clock.
|
||||
//!
|
||||
//! The happy path is exactly `SHA256(master_sk || context)`; a one-byte
|
||||
//! big-endian counter is appended only on the (cryptographically negligible)
|
||||
//! chance the digest is zero or exceeds the curve order, so derivation stays a
|
||||
//! pure function of `(master_sk, context)`.
|
||||
|
||||
use secp256k1::{Secp256k1, SecretKey};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// The derived child secret key (32 bytes), guaranteed a valid secp256k1
|
||||
/// scalar. `context` is the domain material appended after the master key
|
||||
/// (e.g. the invoice id bytes, or `user_id` bytes followed by the epoch).
|
||||
pub fn child_secret(master_sk: &[u8; 32], context: &[&[u8]]) -> [u8; 32] {
|
||||
// Rejection sampling: hash, and if the digest is not a valid scalar
|
||||
// (probability ~2^-128), append an incrementing counter and rehash. The
|
||||
// counter is absent on the first, near-certain attempt, so the derivation
|
||||
// matches the documented `SHA256(master_sk || context)` exactly.
|
||||
for counter in 0u32.. {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(master_sk);
|
||||
for part in context {
|
||||
hasher.update(part);
|
||||
}
|
||||
if counter > 0 {
|
||||
hasher.update(counter.to_be_bytes());
|
||||
}
|
||||
let digest: [u8; 32] = hasher.finalize().into();
|
||||
if SecretKey::from_byte_array(digest).is_ok() {
|
||||
return digest;
|
||||
}
|
||||
}
|
||||
unreachable!("a valid secp256k1 scalar is found within the first few counters")
|
||||
}
|
||||
|
||||
/// The x-only (BIP-340) public key of a derived child, lowercase hex. This is
|
||||
/// the same 32-byte key a Nostr `npub` encodes, so it compares directly
|
||||
/// against the `p` tag of an incoming gift wrap.
|
||||
pub fn child_pubkey_hex(master_sk: &[u8; 32], context: &[&[u8]]) -> String {
|
||||
let secret = child_secret(master_sk, context);
|
||||
let secp = Secp256k1::new();
|
||||
let sk = SecretKey::from_byte_array(secret).expect("child_secret returns a valid scalar");
|
||||
let (xonly, _parity) = sk.x_only_public_key(&secp);
|
||||
hex::encode(xonly.serialize())
|
||||
}
|
||||
|
||||
/// Context for a per-invoice derived identity (matching mode 2):
|
||||
/// `SHA256(master_sk || invoice_id)`.
|
||||
pub fn invoice_context(invoice_id: &str) -> Vec<Vec<u8>> {
|
||||
vec![invoice_id.as_bytes().to_vec()]
|
||||
}
|
||||
|
||||
/// Context for a per-user endpub (milestone 5b):
|
||||
/// `SHA256(master_sk || user_id || epoch)` with the epoch big-endian.
|
||||
pub fn endpub_context(user_id: &str, epoch: i64) -> Vec<Vec<u8>> {
|
||||
vec![user_id.as_bytes().to_vec(), epoch.to_be_bytes().to_vec()]
|
||||
}
|
||||
|
||||
/// Derive the child secret for an invoice.
|
||||
pub fn invoice_secret(master_sk: &[u8; 32], invoice_id: &str) -> [u8; 32] {
|
||||
let ctx = invoice_context(invoice_id);
|
||||
let parts: Vec<&[u8]> = ctx.iter().map(|p| p.as_slice()).collect();
|
||||
child_secret(master_sk, &parts)
|
||||
}
|
||||
|
||||
/// Derive the child x-only pubkey hex for an invoice.
|
||||
pub fn invoice_pubkey_hex(master_sk: &[u8; 32], invoice_id: &str) -> String {
|
||||
let ctx = invoice_context(invoice_id);
|
||||
let parts: Vec<&[u8]> = ctx.iter().map(|p| p.as_slice()).collect();
|
||||
child_pubkey_hex(master_sk, &parts)
|
||||
}
|
||||
|
||||
/// Derive the child secret for a user's endpub at a given epoch.
|
||||
pub fn endpub_secret(master_sk: &[u8; 32], user_id: &str, epoch: i64) -> [u8; 32] {
|
||||
let ctx = endpub_context(user_id, epoch);
|
||||
let parts: Vec<&[u8]> = ctx.iter().map(|p| p.as_slice()).collect();
|
||||
child_secret(master_sk, &parts)
|
||||
}
|
||||
|
||||
/// Derive the child x-only pubkey hex for a user's endpub at a given epoch.
|
||||
pub fn endpub_pubkey_hex(master_sk: &[u8; 32], user_id: &str, epoch: i64) -> String {
|
||||
let ctx = endpub_context(user_id, epoch);
|
||||
let parts: Vec<&[u8]> = ctx.iter().map(|p| p.as_slice()).collect();
|
||||
child_pubkey_hex(master_sk, &parts)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const MASTER: [u8; 32] = [7u8; 32];
|
||||
|
||||
#[test]
|
||||
fn derivation_is_deterministic_and_stateless() {
|
||||
// Same inputs, same key — every time, with no stored state.
|
||||
let a = invoice_secret(&MASTER, "inv-abc");
|
||||
let b = invoice_secret(&MASTER, "inv-abc");
|
||||
assert_eq!(a, b);
|
||||
assert_eq!(
|
||||
invoice_pubkey_hex(&MASTER, "inv-abc"),
|
||||
invoice_pubkey_hex(&MASTER, "inv-abc")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_contexts_yield_distinct_keys() {
|
||||
assert_ne!(
|
||||
invoice_secret(&MASTER, "inv-1"),
|
||||
invoice_secret(&MASTER, "inv-2")
|
||||
);
|
||||
// Per-user, per-epoch keys are all distinct.
|
||||
assert_ne!(
|
||||
endpub_secret(&MASTER, "alice", 0),
|
||||
endpub_secret(&MASTER, "alice", 1)
|
||||
);
|
||||
assert_ne!(
|
||||
endpub_secret(&MASTER, "alice", 0),
|
||||
endpub_secret(&MASTER, "bob", 0)
|
||||
);
|
||||
// And an invoice context never collides with an endpub context of the
|
||||
// same textual prefix (the epoch bytes keep them apart).
|
||||
assert_ne!(
|
||||
invoice_pubkey_hex(&MASTER, "alice"),
|
||||
endpub_pubkey_hex(&MASTER, "alice", 0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_different_master_gives_a_different_child() {
|
||||
let other = [9u8; 32];
|
||||
assert_ne!(
|
||||
invoice_secret(&MASTER, "inv-abc"),
|
||||
invoice_secret(&other, "inv-abc")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derived_secret_is_a_valid_scalar_and_pubkey_is_32_hex_bytes() {
|
||||
let secret = endpub_secret(&MASTER, "carol", 3);
|
||||
assert!(SecretKey::from_byte_array(secret).is_ok());
|
||||
let pk = endpub_pubkey_hex(&MASTER, "carol", 3);
|
||||
assert_eq!(pk.len(), 64, "x-only pubkey is 32 bytes = 64 hex chars");
|
||||
assert!(hex::decode(&pk).is_ok());
|
||||
// The pubkey matches an independent recomputation of the secret.
|
||||
let secp = Secp256k1::new();
|
||||
let sk = SecretKey::from_byte_array(secret).unwrap();
|
||||
let (xonly, _) = sk.x_only_public_key(&secp);
|
||||
assert_eq!(pk, hex::encode(xonly.serialize()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
//! Per-user endpubs with optional rolling rotation (milestone 5b,
|
||||
//! multi-tenant receiving).
|
||||
//!
|
||||
//! An admin assigns one receiving identity ("endpub") per end-user. The
|
||||
//! endpub is a stateless child of the server nsec keyed by `(user_id, epoch)`
|
||||
//! (see [`crate::derive`]), so the database stores only the assignment and the
|
||||
//! rotation clock, never a private key. All funds still land in the one Grin
|
||||
//! wallet; the endpub only decides which user an incoming payment credits.
|
||||
//!
|
||||
//! Optional rotation advances a user's epoch on a per-user (or global default)
|
||||
//! interval, rolling their advertised endpub. An overlap window keeps the last
|
||||
//! N epochs watched, so a payment sent to a just-rotated endpub still lands and
|
||||
//! still maps to that user.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::{derive, ids};
|
||||
|
||||
/// A tenant user. `rotate_interval` is a per-user override in seconds
|
||||
/// (`NULL` = global default, `0` = rotation off). `epoch` is the current
|
||||
/// (highest) endpub epoch.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct User {
|
||||
pub id: String,
|
||||
pub rotate_interval: Option<i64>,
|
||||
pub epoch: i64,
|
||||
pub last_rotated_at: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
/// One endpub assignment: a user's receiving pubkey at a given epoch. The
|
||||
/// pubkey is the derived x-only hex (public, never a secret).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct Endpub {
|
||||
pub user_id: String,
|
||||
pub epoch: i64,
|
||||
pub pubkey: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
/// A user with their current endpub and running balance (admin listing).
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct UserBalance {
|
||||
pub user_id: String,
|
||||
pub epoch: i64,
|
||||
pub endpub: String,
|
||||
/// Total received nanogrin credited to this user.
|
||||
pub balance: i64,
|
||||
}
|
||||
|
||||
/// Create a user (id auto-generated when `id` is `None`) and assign their
|
||||
/// epoch-0 endpub. Returns the user and their first endpub.
|
||||
pub async fn create_user(
|
||||
pool: &SqlitePool,
|
||||
master_sk: &[u8; 32],
|
||||
id: Option<String>,
|
||||
rotate_interval: Option<i64>,
|
||||
) -> Result<(User, Endpub), sqlx::Error> {
|
||||
let id = id.unwrap_or_else(ids::random_id);
|
||||
sqlx::query(
|
||||
"INSERT INTO user (id, rotate_interval, epoch, last_rotated_at, created_at) \
|
||||
VALUES (?1, ?2, 0, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), \
|
||||
strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(rotate_interval)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
let endpub = assign(pool, master_sk, &id, 0).await?;
|
||||
let user = get_user(pool, &id).await?.ok_or(sqlx::Error::RowNotFound)?;
|
||||
Ok((user, endpub))
|
||||
}
|
||||
|
||||
/// Assign (idempotently) the endpub for `(user_id, epoch)`, deriving its
|
||||
/// pubkey. Returns the assignment row.
|
||||
async fn assign(
|
||||
pool: &SqlitePool,
|
||||
master_sk: &[u8; 32],
|
||||
user_id: &str,
|
||||
epoch: i64,
|
||||
) -> Result<Endpub, sqlx::Error> {
|
||||
let pubkey = derive::endpub_pubkey_hex(master_sk, user_id, epoch);
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO endpub_assignment (user_id, epoch, pubkey, created_at) \
|
||||
VALUES (?1, ?2, ?3, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(epoch)
|
||||
.bind(&pubkey)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
sqlx::query_as::<_, Endpub>(
|
||||
"SELECT user_id, epoch, pubkey, created_at FROM endpub_assignment \
|
||||
WHERE user_id = ?1 AND epoch = ?2",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(epoch)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Fetch a user by id.
|
||||
pub async fn get_user(pool: &SqlitePool, id: &str) -> Result<Option<User>, sqlx::Error> {
|
||||
sqlx::query_as::<_, User>(
|
||||
"SELECT id, rotate_interval, epoch, last_rotated_at, created_at FROM user WHERE id = ?1",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// The user's current (highest-epoch) endpub.
|
||||
pub async fn current_endpub(
|
||||
pool: &SqlitePool,
|
||||
user_id: &str,
|
||||
) -> Result<Option<Endpub>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Endpub>(
|
||||
"SELECT user_id, epoch, pubkey, created_at FROM endpub_assignment \
|
||||
WHERE user_id = ?1 ORDER BY epoch DESC LIMIT 1",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Set (or clear, with `None`) a user's per-user rotation interval in seconds.
|
||||
pub async fn set_rotate_interval(
|
||||
pool: &SqlitePool,
|
||||
user_id: &str,
|
||||
interval: Option<i64>,
|
||||
) -> Result<bool, sqlx::Error> {
|
||||
let result = sqlx::query("UPDATE user SET rotate_interval = ?2 WHERE id = ?1")
|
||||
.bind(user_id)
|
||||
.bind(interval)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
/// Force-rotate a user now: advance their epoch and assign the new endpub.
|
||||
/// Returns the new endpub.
|
||||
pub async fn rotate(
|
||||
pool: &SqlitePool,
|
||||
master_sk: &[u8; 32],
|
||||
user_id: &str,
|
||||
) -> Result<Endpub, sqlx::Error> {
|
||||
let user = get_user(pool, user_id)
|
||||
.await?
|
||||
.ok_or(sqlx::Error::RowNotFound)?;
|
||||
let next = user.epoch + 1;
|
||||
sqlx::query(
|
||||
"UPDATE user SET epoch = ?2, last_rotated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') \
|
||||
WHERE id = ?1",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(next)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
assign(pool, master_sk, user_id, next).await
|
||||
}
|
||||
|
||||
/// Rotate every user whose rotation clock has elapsed (per-user interval, else
|
||||
/// `global_interval`; `0`/`None` = off). Called by a periodic tick. Returns
|
||||
/// the number of users rotated. Rotation is staggered by each user's own
|
||||
/// clock, never a flag-day.
|
||||
pub async fn rotate_due(
|
||||
pool: &SqlitePool,
|
||||
master_sk: &[u8; 32],
|
||||
global_interval: i64,
|
||||
) -> Result<usize, sqlx::Error> {
|
||||
let users = sqlx::query_as::<_, User>(
|
||||
"SELECT id, rotate_interval, epoch, last_rotated_at, created_at FROM user",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
let mut rotated = 0;
|
||||
for user in users {
|
||||
let interval = user.rotate_interval.unwrap_or(global_interval);
|
||||
if interval <= 0 {
|
||||
continue;
|
||||
}
|
||||
// Elapsed since last rotation, in whole seconds, computed in SQL to
|
||||
// avoid a Rust-side clock dependency.
|
||||
let elapsed: i64 = sqlx::query_scalar(
|
||||
"SELECT CAST(strftime('%s', 'now') AS INTEGER) \
|
||||
- CAST(strftime('%s', ?1) AS INTEGER)",
|
||||
)
|
||||
.bind(&user.last_rotated_at)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
if elapsed >= interval {
|
||||
rotate(pool, master_sk, &user.id).await?;
|
||||
rotated += 1;
|
||||
}
|
||||
}
|
||||
Ok(rotated)
|
||||
}
|
||||
|
||||
/// The union of pubkeys to subscribe to: for every user, the current epoch and
|
||||
/// the previous `overlap` epochs. This is what gp-nostr watches so a payment
|
||||
/// to a just-rotated endpub still lands within the window.
|
||||
pub async fn watched_pubkeys(pool: &SqlitePool, overlap: i64) -> Result<Vec<Endpub>, sqlx::Error> {
|
||||
let overlap = overlap.max(0);
|
||||
sqlx::query_as::<_, Endpub>(
|
||||
"SELECT a.user_id, a.epoch, a.pubkey, a.created_at \
|
||||
FROM endpub_assignment a JOIN user u ON u.id = a.user_id \
|
||||
WHERE a.epoch >= u.epoch - ?1 \
|
||||
ORDER BY a.user_id, a.epoch",
|
||||
)
|
||||
.bind(overlap)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Resolve a received pubkey to its `(user_id, epoch)`, if it is any assigned
|
||||
/// endpub (crediting works for any stored assignment, even one just rotated
|
||||
/// past the watch window).
|
||||
pub async fn user_for_pubkey(
|
||||
pool: &SqlitePool,
|
||||
pubkey: &str,
|
||||
) -> Result<Option<(String, i64)>, sqlx::Error> {
|
||||
let row: Option<(String, i64)> =
|
||||
sqlx::query_as("SELECT user_id, epoch FROM endpub_assignment WHERE pubkey = ?1 LIMIT 1")
|
||||
.bind(pubkey)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
/// Every user with their current endpub and running received balance.
|
||||
pub async fn list_with_balances(pool: &SqlitePool) -> Result<Vec<UserBalance>, sqlx::Error> {
|
||||
let users = sqlx::query_as::<_, User>(
|
||||
"SELECT id, rotate_interval, epoch, last_rotated_at, created_at FROM user \
|
||||
ORDER BY created_at DESC",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
let mut out = Vec::with_capacity(users.len());
|
||||
for user in users {
|
||||
let endpub = current_endpub(pool, &user.id)
|
||||
.await?
|
||||
.map(|e| e.pubkey)
|
||||
.unwrap_or_default();
|
||||
let balance: i64 =
|
||||
sqlx::query_scalar("SELECT COALESCE(SUM(amount), 0) FROM payment WHERE user_id = ?1")
|
||||
.bind(&user.id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
out.push(UserBalance {
|
||||
user_id: user.id,
|
||||
epoch: user.epoch,
|
||||
endpub,
|
||||
balance,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db;
|
||||
|
||||
async fn pool() -> SqlitePool {
|
||||
db::test_pool().await
|
||||
}
|
||||
|
||||
const MASTER: [u8; 32] = [5u8; 32];
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_user_assigns_a_deterministic_endpub() {
|
||||
let pool = pool().await;
|
||||
let (user, endpub) = create_user(&pool, &MASTER, Some("alice".into()), None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(user.epoch, 0);
|
||||
assert_eq!(endpub.epoch, 0);
|
||||
// Stateless: the stored pubkey equals a fresh derivation.
|
||||
assert_eq!(
|
||||
endpub.pubkey,
|
||||
derive::endpub_pubkey_hex(&MASTER, "alice", 0)
|
||||
);
|
||||
// And it resolves back to the user.
|
||||
assert_eq!(
|
||||
user_for_pubkey(&pool, &endpub.pubkey).await.unwrap(),
|
||||
Some(("alice".into(), 0))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rotation_keeps_old_epochs_payable_within_the_window() {
|
||||
let pool = pool().await;
|
||||
let (_u, first) = create_user(&pool, &MASTER, Some("bob".into()), None)
|
||||
.await
|
||||
.unwrap();
|
||||
let second = rotate(&pool, &MASTER, "bob").await.unwrap();
|
||||
assert_eq!(second.epoch, 1);
|
||||
assert_ne!(first.pubkey, second.pubkey);
|
||||
|
||||
// Both epochs still credit the same user (crediting is not gated on
|
||||
// the watch window)...
|
||||
assert_eq!(
|
||||
user_for_pubkey(&pool, &first.pubkey).await.unwrap(),
|
||||
Some(("bob".into(), 0))
|
||||
);
|
||||
assert_eq!(
|
||||
user_for_pubkey(&pool, &second.pubkey).await.unwrap(),
|
||||
Some(("bob".into(), 1))
|
||||
);
|
||||
|
||||
// ...and with overlap >= 1 the just-rotated old endpub is still watched.
|
||||
let watched: Vec<String> = watched_pubkeys(&pool, 1)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|e| e.pubkey)
|
||||
.collect();
|
||||
assert!(
|
||||
watched.contains(&first.pubkey),
|
||||
"overlap keeps epoch 0 watched"
|
||||
);
|
||||
assert!(watched.contains(&second.pubkey));
|
||||
|
||||
// With no overlap only the current epoch is watched.
|
||||
let watched0: Vec<String> = watched_pubkeys(&pool, 0)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|e| e.pubkey)
|
||||
.collect();
|
||||
assert_eq!(watched0, vec![second.pubkey.clone()]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rotate_due_respects_intervals() {
|
||||
let pool = pool().await;
|
||||
// interval 0 (off): never rotates even though last_rotated is old.
|
||||
create_user(&pool, &MASTER, Some("off".into()), Some(0))
|
||||
.await
|
||||
.unwrap();
|
||||
// A short interval with a backdated clock rotates.
|
||||
create_user(&pool, &MASTER, Some("due".into()), Some(10))
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("UPDATE user SET last_rotated_at = '2000-01-01T00:00:00Z' WHERE id = 'due'")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let rotated = rotate_due(&pool, &MASTER, 0).await.unwrap();
|
||||
assert_eq!(rotated, 1);
|
||||
assert_eq!(get_user(&pool, "due").await.unwrap().unwrap().epoch, 1);
|
||||
assert_eq!(get_user(&pool, "off").await.unwrap().unwrap().epoch, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Random identifiers and timestamps.
|
||||
//!
|
||||
//! Invoice ids and webhook event ids are random 128-bit values, hex encoded.
|
||||
//! Checkout tokens are 256-bit and treated as bearer capabilities (the
|
||||
//! unguessable secret that authorizes `/pay/<token>`), so they get twice the
|
||||
//! entropy and a URL-safe base64 encoding.
|
||||
|
||||
use rand::RngCore;
|
||||
|
||||
/// A random 128-bit id, lowercase hex (32 chars). Used for invoice ids,
|
||||
/// webhook event ids, and user ids.
|
||||
pub fn random_id() -> String {
|
||||
let mut bytes = [0u8; 16];
|
||||
rand::rng().fill_bytes(&mut bytes);
|
||||
hex::encode(bytes)
|
||||
}
|
||||
|
||||
/// A random 256-bit checkout token, URL-safe base64 without padding (43
|
||||
/// chars). This is the bearer capability for the hosted `/pay/<token>` page:
|
||||
/// unguessable and not enumerable, never a database row number.
|
||||
pub fn checkout_token() -> String {
|
||||
let mut bytes = [0u8; 32];
|
||||
rand::rng().fill_bytes(&mut bytes);
|
||||
base64_url_nopad(&bytes)
|
||||
}
|
||||
|
||||
/// Minimal URL-safe base64 (no padding), so the token needs no percent
|
||||
/// encoding in a path and pulls in no base64 dependency of its own.
|
||||
fn base64_url_nopad(input: &[u8]) -> String {
|
||||
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
||||
let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
|
||||
for chunk in input.chunks(3) {
|
||||
let b0 = chunk[0] as u32;
|
||||
let b1 = *chunk.get(1).unwrap_or(&0) as u32;
|
||||
let b2 = *chunk.get(2).unwrap_or(&0) as u32;
|
||||
let n = (b0 << 16) | (b1 << 8) | b2;
|
||||
out.push(ALPHABET[(n >> 18 & 0x3f) as usize] as char);
|
||||
out.push(ALPHABET[(n >> 12 & 0x3f) as usize] as char);
|
||||
if chunk.len() > 1 {
|
||||
out.push(ALPHABET[(n >> 6 & 0x3f) as usize] as char);
|
||||
}
|
||||
if chunk.len() > 2 {
|
||||
out.push(ALPHABET[(n & 0x3f) as usize] as char);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashSet;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ids_are_unique_and_hex() {
|
||||
let mut seen = HashSet::new();
|
||||
for _ in 0..1000 {
|
||||
let id = random_id();
|
||||
assert_eq!(id.len(), 32);
|
||||
assert!(hex::decode(&id).is_ok());
|
||||
assert!(seen.insert(id), "ids must not collide");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tokens_are_unguessable_length_and_url_safe() {
|
||||
let mut seen = HashSet::new();
|
||||
for _ in 0..1000 {
|
||||
let token = checkout_token();
|
||||
assert_eq!(token.len(), 43, "256 bits, url-safe base64 no pad");
|
||||
assert!(token
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'));
|
||||
assert!(seen.insert(token), "tokens must not collide");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base64_matches_known_vectors() {
|
||||
// Cross-checked against RFC 4648 URL-safe base64 (no padding).
|
||||
assert_eq!(base64_url_nopad(b""), "");
|
||||
assert_eq!(base64_url_nopad(b"f"), "Zg");
|
||||
assert_eq!(base64_url_nopad(b"fo"), "Zm8");
|
||||
assert_eq!(base64_url_nopad(b"foo"), "Zm9v");
|
||||
assert_eq!(base64_url_nopad(b"foob"), "Zm9vYg");
|
||||
assert_eq!(base64_url_nopad(&[0xff, 0xff, 0xff]), "____");
|
||||
assert_eq!(base64_url_nopad(&[0xfb, 0xff, 0xbf]), "-_-_");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
//! Invoices: the optional order-matching layer over received payments.
|
||||
//!
|
||||
//! An invoice pins an expected payment (an amount, or a fiat quote to be
|
||||
//! filled by the conversion milestone) to an order reference and mints an
|
||||
//! unguessable checkout token for the hosted `/pay/<token>` page. Its
|
||||
//! recipient identity is either the server's master Nostr key (for memo and
|
||||
//! amount matching) or a per-invoice derived child (matching mode 2); only the
|
||||
//! public key is stored, the child secret is recomputed on demand.
|
||||
//!
|
||||
//! Lifecycle: `open` -> `paid` (a received payment matched it) or `expired`
|
||||
//! (its expiry passed while still open). Expiry is evaluated lazily on read
|
||||
//! and by a periodic sweep, never by a background per-invoice timer.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::config::MatchMode;
|
||||
use crate::{derive, ids};
|
||||
|
||||
/// Invoice lifecycle status.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum InvoiceStatus {
|
||||
/// Awaiting a matching payment.
|
||||
Open,
|
||||
/// A received payment matched this invoice.
|
||||
Paid,
|
||||
/// Expiry passed before a payment matched.
|
||||
Expired,
|
||||
}
|
||||
|
||||
impl InvoiceStatus {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
InvoiceStatus::Open => "open",
|
||||
InvoiceStatus::Paid => "paid",
|
||||
InvoiceStatus::Expired => "expired",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> InvoiceStatus {
|
||||
match s {
|
||||
"paid" => InvoiceStatus::Paid,
|
||||
"expired" => InvoiceStatus::Expired,
|
||||
_ => InvoiceStatus::Open,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How to state an invoice amount at creation: an exact Grin amount, a raw
|
||||
/// fiat amount plus currency (unpriced), or a fiat amount already priced into
|
||||
/// Grin by the conversion oracle (milestone 7).
|
||||
///
|
||||
/// The connector/API sends `Grin` or `Fiat`; the server resolves a `Fiat`
|
||||
/// through the oracle into a `FiatQuoted` (with the locked nanogrin) before
|
||||
/// persisting, so a fiat invoice's `expected_amount` is filled and it matches
|
||||
/// by amount. A bare `Fiat` that reaches persistence stays unpriced
|
||||
/// (`expected_amount` NULL), matchable only by memo or a derived identity.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AmountSpec {
|
||||
/// Exact amount in nanogrin.
|
||||
Grin(u64),
|
||||
/// Fiat amount (decimal string) in the given ISO currency code, not yet
|
||||
/// priced (the pre-oracle state; expected_amount stays NULL).
|
||||
Fiat { amount: String, currency: String },
|
||||
/// A fiat amount priced into Grin by the oracle: the locked quote.
|
||||
FiatQuoted {
|
||||
/// The original fiat amount (decimal string), echoed for display.
|
||||
amount: String,
|
||||
/// The ISO currency code.
|
||||
currency: String,
|
||||
/// The locked Grin amount in nanogrin (becomes `expected_amount`).
|
||||
nanogrin: u64,
|
||||
/// The rate used, fiat per GRIN (decimal string, for the receipt).
|
||||
rate: String,
|
||||
/// The oracle source the rate came from (e.g. `coingecko`).
|
||||
source: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Parameters for [`create`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewInvoice {
|
||||
/// The store's order reference (also the memo/subject match key).
|
||||
pub order_ref: Option<String>,
|
||||
/// The amount, exact Grin or a fiat quote.
|
||||
pub amount: AmountSpec,
|
||||
/// A human memo shown on the checkout page.
|
||||
pub memo: Option<String>,
|
||||
/// Per-invoice matching-mode override; `None` uses the global default.
|
||||
pub match_mode: Option<MatchMode>,
|
||||
/// Expiry, seconds from now; `None` means no expiry.
|
||||
pub expiry_secs: Option<i64>,
|
||||
}
|
||||
|
||||
/// A persisted invoice row.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct Invoice {
|
||||
pub id: String,
|
||||
#[sqlx(rename = "ref")]
|
||||
pub order_ref: Option<String>,
|
||||
pub expected_amount: Option<i64>,
|
||||
pub expiry: Option<String>,
|
||||
pub status: String,
|
||||
pub created_at: String,
|
||||
pub token: Option<String>,
|
||||
pub memo: Option<String>,
|
||||
pub recipient_pubkey: Option<String>,
|
||||
pub fiat_amount: Option<String>,
|
||||
pub fiat_currency: Option<String>,
|
||||
pub match_mode: Option<String>,
|
||||
pub paid_payment_id: Option<String>,
|
||||
pub paid_at: Option<String>,
|
||||
/// The locked rate (fiat per GRIN) a fiat quote was priced at, else NULL.
|
||||
pub quote_rate: Option<String>,
|
||||
/// The oracle source the quote came from (e.g. `coingecko`), else NULL.
|
||||
pub quote_source: Option<String>,
|
||||
}
|
||||
|
||||
impl Invoice {
|
||||
/// The effective matching mode: the per-invoice override, else the global
|
||||
/// default supplied by the caller.
|
||||
pub fn effective_mode(&self, default: MatchMode) -> MatchMode {
|
||||
match self.match_mode.as_deref() {
|
||||
Some("memo") => MatchMode::Memo,
|
||||
Some("derived") => MatchMode::Derived,
|
||||
Some("amount") => MatchMode::Amount,
|
||||
_ => default,
|
||||
}
|
||||
}
|
||||
|
||||
/// The status as a typed enum.
|
||||
pub fn status(&self) -> InvoiceStatus {
|
||||
InvoiceStatus::parse(&self.status)
|
||||
}
|
||||
}
|
||||
|
||||
fn mode_str(mode: MatchMode) -> &'static str {
|
||||
match mode {
|
||||
MatchMode::Memo => "memo",
|
||||
MatchMode::Derived => "derived",
|
||||
MatchMode::Amount => "amount",
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an invoice: mint an id + checkout token, resolve the recipient
|
||||
/// identity (a per-invoice derived child in `derived` mode, else the server
|
||||
/// master key), persist it `open`, and return the row.
|
||||
///
|
||||
/// `master_sk` is the server Nostr secret (used only to derive the child
|
||||
/// public key; the secret is never stored). `master_pubkey_hex` is the
|
||||
/// server's own x-only key, used as the recipient for memo/amount invoices.
|
||||
pub async fn create(
|
||||
pool: &SqlitePool,
|
||||
params: NewInvoice,
|
||||
master_sk: &[u8; 32],
|
||||
master_pubkey_hex: &str,
|
||||
default_mode: MatchMode,
|
||||
) -> Result<Invoice, sqlx::Error> {
|
||||
let id = ids::random_id();
|
||||
let token = ids::checkout_token();
|
||||
let effective = params.match_mode.unwrap_or(default_mode);
|
||||
|
||||
// Derived mode gets a unique per-invoice child key; everything else
|
||||
// receives on the server's own identity and matches by memo or amount.
|
||||
let recipient_pubkey = if effective == MatchMode::Derived {
|
||||
derive::invoice_pubkey_hex(master_sk, &id)
|
||||
} else {
|
||||
master_pubkey_hex.to_string()
|
||||
};
|
||||
|
||||
let (expected_amount, fiat_amount, fiat_currency, quote_rate, quote_source) =
|
||||
match ¶ms.amount {
|
||||
AmountSpec::Grin(nano) => (Some(*nano as i64), None, None, None, None),
|
||||
AmountSpec::Fiat { amount, currency } => (
|
||||
None,
|
||||
Some(amount.clone()),
|
||||
Some(currency.clone()),
|
||||
None,
|
||||
None,
|
||||
),
|
||||
AmountSpec::FiatQuoted {
|
||||
amount,
|
||||
currency,
|
||||
nanogrin,
|
||||
rate,
|
||||
source,
|
||||
} => (
|
||||
Some(*nanogrin as i64),
|
||||
Some(amount.clone()),
|
||||
Some(currency.clone()),
|
||||
Some(rate.clone()),
|
||||
Some(source.clone()),
|
||||
),
|
||||
};
|
||||
|
||||
// Store the per-invoice override only when it differs from a bare default,
|
||||
// so an invoice created under one global default keeps behaving as created
|
||||
// even if the operator later changes GP_MATCH_MODE.
|
||||
let stored_mode = params.match_mode.map(mode_str);
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO invoice \
|
||||
(id, ref, expected_amount, expiry, status, created_at, token, memo, \
|
||||
recipient_pubkey, fiat_amount, fiat_currency, match_mode, \
|
||||
quote_rate, quote_source) \
|
||||
VALUES (?1, ?2, ?3, \
|
||||
CASE WHEN ?4 IS NULL THEN NULL \
|
||||
ELSE strftime('%Y-%m-%dT%H:%M:%SZ', 'now', ?4) END, \
|
||||
'open', strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), \
|
||||
?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(¶ms.order_ref)
|
||||
.bind(expected_amount)
|
||||
.bind(params.expiry_secs.map(|s| format!("{s:+} seconds")))
|
||||
.bind(&token)
|
||||
.bind(¶ms.memo)
|
||||
.bind(&recipient_pubkey)
|
||||
.bind(&fiat_amount)
|
||||
.bind(&fiat_currency)
|
||||
.bind(stored_mode)
|
||||
.bind("e_rate)
|
||||
.bind("e_source)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
get(pool, &id)
|
||||
.await?
|
||||
.ok_or_else(|| sqlx::Error::RowNotFound)
|
||||
}
|
||||
|
||||
const COLUMNS: &str = "id, ref, expected_amount, expiry, status, created_at, token, memo, \
|
||||
recipient_pubkey, fiat_amount, fiat_currency, match_mode, paid_payment_id, paid_at, \
|
||||
quote_rate, quote_source";
|
||||
|
||||
/// Fetch an invoice by id, marking it expired first if its expiry has passed.
|
||||
pub async fn get(pool: &SqlitePool, id: &str) -> Result<Option<Invoice>, sqlx::Error> {
|
||||
expire_if_due_id(pool, id).await?;
|
||||
let sql = format!("SELECT {COLUMNS} FROM invoice WHERE id = ?1");
|
||||
sqlx::query_as::<_, Invoice>(&sql)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Fetch an invoice by its checkout token (the `/pay/<token>` bearer),
|
||||
/// marking it expired first if due.
|
||||
pub async fn get_by_token(pool: &SqlitePool, token: &str) -> Result<Option<Invoice>, sqlx::Error> {
|
||||
// Expire lazily so the hosted page reflects the true status on load.
|
||||
sqlx::query(
|
||||
"UPDATE invoice SET status = 'expired' \
|
||||
WHERE token = ?1 AND status = 'open' \
|
||||
AND expiry IS NOT NULL AND expiry <= strftime('%Y-%m-%dT%H:%M:%SZ', 'now')",
|
||||
)
|
||||
.bind(token)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
let sql = format!("SELECT {COLUMNS} FROM invoice WHERE token = ?1");
|
||||
sqlx::query_as::<_, Invoice>(&sql)
|
||||
.bind(token)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// The most recent invoices, newest first (admin listing).
|
||||
pub async fn list(pool: &SqlitePool, limit: i64) -> Result<Vec<Invoice>, sqlx::Error> {
|
||||
expire_due(pool).await?;
|
||||
let sql = format!("SELECT {COLUMNS} FROM invoice ORDER BY created_at DESC LIMIT ?1");
|
||||
sqlx::query_as::<_, Invoice>(&sql)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Mark an invoice paid, linking the payment that satisfied it. Idempotent:
|
||||
/// only an `open` invoice transitions, so a replayed match is a no-op.
|
||||
pub async fn mark_paid(
|
||||
pool: &SqlitePool,
|
||||
invoice_id: &str,
|
||||
payment_id: &str,
|
||||
) -> Result<bool, sqlx::Error> {
|
||||
let result = sqlx::query(
|
||||
"UPDATE invoice SET status = 'paid', paid_payment_id = ?2, \
|
||||
paid_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') \
|
||||
WHERE id = ?1 AND status = 'open'",
|
||||
)
|
||||
.bind(invoice_id)
|
||||
.bind(payment_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
/// Sweep: mark every open invoice whose expiry has passed as expired.
|
||||
pub async fn expire_due(pool: &SqlitePool) -> Result<u64, sqlx::Error> {
|
||||
let result = sqlx::query(
|
||||
"UPDATE invoice SET status = 'expired' \
|
||||
WHERE status = 'open' AND expiry IS NOT NULL \
|
||||
AND expiry <= strftime('%Y-%m-%dT%H:%M:%SZ', 'now')",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
async fn expire_if_due_id(pool: &SqlitePool, id: &str) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
"UPDATE invoice SET status = 'expired' \
|
||||
WHERE id = ?1 AND status = 'open' \
|
||||
AND expiry IS NOT NULL AND expiry <= strftime('%Y-%m-%dT%H:%M:%SZ', 'now')",
|
||||
)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db;
|
||||
|
||||
async fn pool() -> SqlitePool {
|
||||
// In-memory database, migrated: fast and isolated per test.
|
||||
db::test_pool().await
|
||||
}
|
||||
|
||||
const MASTER: [u8; 32] = [3u8; 32];
|
||||
const MASTER_PUB: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
|
||||
fn grin(nano: u64) -> NewInvoice {
|
||||
NewInvoice {
|
||||
order_ref: Some("order-7".into()),
|
||||
amount: AmountSpec::Grin(nano),
|
||||
memo: Some("Coffee".into()),
|
||||
match_mode: None,
|
||||
expiry_secs: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_get_and_token_roundtrip() {
|
||||
let pool = pool().await;
|
||||
let inv = create(
|
||||
&pool,
|
||||
grin(1_500_000_000),
|
||||
&MASTER,
|
||||
MASTER_PUB,
|
||||
MatchMode::Memo,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(inv.status(), InvoiceStatus::Open);
|
||||
assert_eq!(inv.expected_amount, Some(1_500_000_000));
|
||||
assert_eq!(inv.order_ref.as_deref(), Some("order-7"));
|
||||
let token = inv.token.clone().unwrap();
|
||||
assert_eq!(token.len(), 43);
|
||||
|
||||
let by_id = get(&pool, &inv.id).await.unwrap().unwrap();
|
||||
assert_eq!(by_id.id, inv.id);
|
||||
let by_token = get_by_token(&pool, &token).await.unwrap().unwrap();
|
||||
assert_eq!(by_token.id, inv.id);
|
||||
// Memo-mode invoices receive on the master identity.
|
||||
assert_eq!(by_token.recipient_pubkey.as_deref(), Some(MASTER_PUB));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn derived_mode_gets_a_unique_child_recipient() {
|
||||
let pool = pool().await;
|
||||
let mut p = grin(1);
|
||||
p.match_mode = Some(MatchMode::Derived);
|
||||
let inv = create(&pool, p, &MASTER, MASTER_PUB, MatchMode::Memo)
|
||||
.await
|
||||
.unwrap();
|
||||
let recipient = inv.recipient_pubkey.clone().unwrap();
|
||||
assert_ne!(recipient, MASTER_PUB, "derived mode must not reuse master");
|
||||
// Stateless: recomputing from the invoice id yields the same key.
|
||||
assert_eq!(recipient, derive::invoice_pubkey_hex(&MASTER, &inv.id));
|
||||
assert_eq!(inv.effective_mode(MatchMode::Memo), MatchMode::Derived);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fiat_invoice_has_no_expected_grin_amount_yet() {
|
||||
let pool = pool().await;
|
||||
let p = NewInvoice {
|
||||
order_ref: None,
|
||||
amount: AmountSpec::Fiat {
|
||||
amount: "19.99".into(),
|
||||
currency: "USD".into(),
|
||||
},
|
||||
memo: None,
|
||||
match_mode: None,
|
||||
expiry_secs: None,
|
||||
};
|
||||
let inv = create(&pool, p, &MASTER, MASTER_PUB, MatchMode::Amount)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(inv.expected_amount, None);
|
||||
assert_eq!(inv.fiat_amount.as_deref(), Some("19.99"));
|
||||
assert_eq!(inv.fiat_currency.as_deref(), Some("USD"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expiry_is_evaluated_lazily() {
|
||||
let pool = pool().await;
|
||||
let mut p = grin(1);
|
||||
p.expiry_secs = Some(-1); // already in the past
|
||||
let inv = create(&pool, p, &MASTER, MASTER_PUB, MatchMode::Memo)
|
||||
.await
|
||||
.unwrap();
|
||||
// Fetching it flips open -> expired.
|
||||
let fetched = get(&pool, &inv.id).await.unwrap().unwrap();
|
||||
assert_eq!(fetched.status(), InvoiceStatus::Expired);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mark_paid_is_idempotent() {
|
||||
let pool = pool().await;
|
||||
let inv = create(&pool, grin(10), &MASTER, MASTER_PUB, MatchMode::Memo)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(mark_paid(&pool, &inv.id, "pay-1").await.unwrap());
|
||||
// Second call does not transition again (already paid).
|
||||
assert!(!mark_paid(&pool, &inv.id, "pay-2").await.unwrap());
|
||||
let fetched = get(&pool, &inv.id).await.unwrap().unwrap();
|
||||
assert_eq!(fetched.status(), InvoiceStatus::Paid);
|
||||
assert_eq!(fetched.paid_payment_id.as_deref(), Some("pay-1"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//! GoblinPay domain core.
|
||||
//!
|
||||
//! Holds everything that is not transport or wallet crypto: the runtime
|
||||
//! configuration (env-first, like goblin-nip05d), the SQLite persistence
|
||||
//! layer, and (in later milestones) invoices, payments, matching, conversion,
|
||||
//! and notification traits.
|
||||
|
||||
pub mod config;
|
||||
pub mod db;
|
||||
pub mod derive;
|
||||
pub mod endpub;
|
||||
pub mod ids;
|
||||
pub mod invoice;
|
||||
pub mod matching;
|
||||
pub mod qr;
|
||||
pub mod rates;
|
||||
pub mod store;
|
||||
pub mod webhook;
|
||||
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
/// Constant-time byte-string equality, for comparing bearer tokens and other
|
||||
/// secrets without leaking a timing side channel.
|
||||
pub fn ct_eq(a: &[u8], b: &[u8]) -> bool {
|
||||
a.len() == b.len() && bool::from(a.ct_eq(b))
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
//! The shared matching layer: map one received payment to an open invoice
|
||||
//! (advancing its status) and to a tenant user (for crediting), composing all
|
||||
//! three matching modes.
|
||||
//!
|
||||
//! An incoming payment carries the identity it was received on (the master key
|
||||
//! or a per-invoice / per-user derived child), the amount, and an optional
|
||||
//! memo (the payer's `subject` tag). Resolution tries, in order:
|
||||
//!
|
||||
//! 1. **Derived identity** (mode 2) — the recipient pubkey uniquely names a
|
||||
//! per-invoice child, an O(1) indexed lookup. Recommended for stores.
|
||||
//! 2. **Memo / reference** (mode 1) — the memo equals the invoice's order ref.
|
||||
//! 3. **Amount** (mode 3) — the exact expected amount, among unexpired open
|
||||
//! invoices, oldest first.
|
||||
//!
|
||||
//! Each candidate is scoped to invoices whose *effective* mode is that mode
|
||||
//! (the per-invoice override, else the global default), so an amount-mode
|
||||
//! invoice is never matched by a same-amount derived-mode invoice and vice
|
||||
//! versa. User crediting (5b) is resolved independently from the endpub the
|
||||
//! payment landed on and composes with any invoice match.
|
||||
//!
|
||||
//! This runs after the wallet has recorded the payment; it is a pure database
|
||||
//! operation over synthetic inputs, so every mode is unit-testable without a
|
||||
//! relay or a wallet.
|
||||
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::config::MatchMode;
|
||||
use crate::{endpub, invoice};
|
||||
|
||||
/// One received payment presented to the matcher. `slate_id` is also the
|
||||
/// payment row id.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IncomingPayment<'a> {
|
||||
pub slate_id: &'a str,
|
||||
pub amount: u64,
|
||||
/// The server identity that received it (master or a derived child),
|
||||
/// x-only hex.
|
||||
pub recipient_hex: &'a str,
|
||||
/// The payer's sanitized memo (subject tag), if any.
|
||||
pub memo: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// What the payment resolved to.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct MatchResult {
|
||||
/// The invoice it satisfied, if any.
|
||||
pub invoice_id: Option<String>,
|
||||
/// The tenant user it credits, if the endpub belongs to one.
|
||||
pub user_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Resolve `incoming` against the open invoices and endpubs, mark a matched
|
||||
/// invoice paid, and link the payment row to the invoice + user. Returns what
|
||||
/// it matched.
|
||||
pub async fn match_payment(
|
||||
pool: &SqlitePool,
|
||||
default_mode: MatchMode,
|
||||
incoming: &IncomingPayment<'_>,
|
||||
) -> Result<MatchResult, sqlx::Error> {
|
||||
let default = mode_str(default_mode);
|
||||
|
||||
// 5b: which tenant user does this endpub credit? Independent of invoices.
|
||||
let user_id = endpub::user_for_pubkey(pool, incoming.recipient_hex)
|
||||
.await?
|
||||
.map(|(user, _epoch)| user);
|
||||
|
||||
// Invoice resolution, first hit wins across the three scoped modes.
|
||||
let invoice_id = resolve_derived(pool, default, incoming.recipient_hex).await?;
|
||||
let invoice_id = match invoice_id {
|
||||
Some(id) => Some(id),
|
||||
None => match incoming.memo {
|
||||
Some(memo) => resolve_memo(pool, default, memo).await?,
|
||||
None => None,
|
||||
},
|
||||
};
|
||||
let invoice_id = match invoice_id {
|
||||
Some(id) => Some(id),
|
||||
None => resolve_amount(pool, default, incoming.amount).await?,
|
||||
};
|
||||
|
||||
if let Some(id) = &invoice_id {
|
||||
invoice::mark_paid(pool, id, incoming.slate_id).await?;
|
||||
}
|
||||
|
||||
// Link the payment row to whatever it resolved to (both optional).
|
||||
sqlx::query("UPDATE payment SET invoice_id = ?2, user_id = ?3 WHERE id = ?1")
|
||||
.bind(incoming.slate_id)
|
||||
.bind(&invoice_id)
|
||||
.bind(&user_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(MatchResult {
|
||||
invoice_id,
|
||||
user_id,
|
||||
})
|
||||
}
|
||||
|
||||
fn mode_str(mode: MatchMode) -> &'static str {
|
||||
match mode {
|
||||
MatchMode::Memo => "memo",
|
||||
MatchMode::Derived => "derived",
|
||||
MatchMode::Amount => "amount",
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_derived(
|
||||
pool: &SqlitePool,
|
||||
default: &str,
|
||||
recipient_hex: &str,
|
||||
) -> Result<Option<String>, sqlx::Error> {
|
||||
sqlx::query_scalar(
|
||||
"SELECT id FROM invoice \
|
||||
WHERE recipient_pubkey = ?1 AND status = 'open' \
|
||||
AND COALESCE(match_mode, ?2) = 'derived' \
|
||||
ORDER BY created_at LIMIT 1",
|
||||
)
|
||||
.bind(recipient_hex)
|
||||
.bind(default)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn resolve_memo(
|
||||
pool: &SqlitePool,
|
||||
default: &str,
|
||||
memo: &str,
|
||||
) -> Result<Option<String>, sqlx::Error> {
|
||||
sqlx::query_scalar(
|
||||
"SELECT id FROM invoice \
|
||||
WHERE ref = ?1 AND status = 'open' \
|
||||
AND COALESCE(match_mode, ?2) = 'memo' \
|
||||
ORDER BY created_at LIMIT 1",
|
||||
)
|
||||
.bind(memo)
|
||||
.bind(default)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn resolve_amount(
|
||||
pool: &SqlitePool,
|
||||
default: &str,
|
||||
amount: u64,
|
||||
) -> Result<Option<String>, sqlx::Error> {
|
||||
sqlx::query_scalar(
|
||||
"SELECT id FROM invoice \
|
||||
WHERE expected_amount = ?1 AND status = 'open' \
|
||||
AND COALESCE(match_mode, ?2) = 'amount' \
|
||||
AND (expiry IS NULL OR expiry > strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) \
|
||||
ORDER BY created_at LIMIT 1",
|
||||
)
|
||||
.bind(amount as i64)
|
||||
.bind(default)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db;
|
||||
use crate::invoice::{AmountSpec, NewInvoice};
|
||||
|
||||
async fn pool() -> SqlitePool {
|
||||
db::test_pool().await
|
||||
}
|
||||
|
||||
const MASTER: [u8; 32] = [11u8; 32];
|
||||
const MASTER_PUB: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
|
||||
/// Insert a payment row the way the ingest adapter does, so matching has a
|
||||
/// row to link.
|
||||
async fn insert_payment(pool: &SqlitePool, slate_id: &str, amount: u64, recipient: &str) {
|
||||
sqlx::query(
|
||||
"INSERT INTO payment (id, amount, payer, slate_id, recipient, status, created_at) \
|
||||
VALUES (?1, ?2, 'payerhex', ?1, ?3, 'received', \
|
||||
strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))",
|
||||
)
|
||||
.bind(slate_id)
|
||||
.bind(amount as i64)
|
||||
.bind(recipient)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn new(amount: AmountSpec, order_ref: Option<&str>, mode: Option<MatchMode>) -> NewInvoice {
|
||||
NewInvoice {
|
||||
order_ref: order_ref.map(|s| s.to_string()),
|
||||
amount,
|
||||
memo: None,
|
||||
match_mode: mode,
|
||||
expiry_secs: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn memo_mode_matches_by_order_ref() {
|
||||
let pool = pool().await;
|
||||
let inv = invoice::create(
|
||||
&pool,
|
||||
new(
|
||||
AmountSpec::Grin(100),
|
||||
Some("order-42"),
|
||||
Some(MatchMode::Memo),
|
||||
),
|
||||
&MASTER,
|
||||
MASTER_PUB,
|
||||
MatchMode::Memo,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
insert_payment(&pool, "slate-a", 100, MASTER_PUB).await;
|
||||
|
||||
let result = match_payment(
|
||||
&pool,
|
||||
MatchMode::Memo,
|
||||
&IncomingPayment {
|
||||
slate_id: "slate-a",
|
||||
amount: 100,
|
||||
recipient_hex: MASTER_PUB,
|
||||
memo: Some("order-42"),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.invoice_id.as_deref(), Some(inv.id.as_str()));
|
||||
assert_eq!(
|
||||
invoice::get(&pool, &inv.id)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.status(),
|
||||
invoice::InvoiceStatus::Paid
|
||||
);
|
||||
// The payment row is linked back.
|
||||
let linked: Option<String> =
|
||||
sqlx::query_scalar("SELECT invoice_id FROM payment WHERE id = 'slate-a'")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(linked.as_deref(), Some(inv.id.as_str()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn derived_mode_matches_by_recipient_identity() {
|
||||
let pool = pool().await;
|
||||
let inv = invoice::create(
|
||||
&pool,
|
||||
new(AmountSpec::Grin(100), None, Some(MatchMode::Derived)),
|
||||
&MASTER,
|
||||
MASTER_PUB,
|
||||
MatchMode::Memo,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let recipient = inv.recipient_pubkey.clone().unwrap();
|
||||
insert_payment(&pool, "slate-b", 999, &recipient).await;
|
||||
|
||||
// Even with a mismatched amount and no memo, the derived identity is
|
||||
// unambiguous.
|
||||
let result = match_payment(
|
||||
&pool,
|
||||
MatchMode::Memo,
|
||||
&IncomingPayment {
|
||||
slate_id: "slate-b",
|
||||
amount: 999,
|
||||
recipient_hex: &recipient,
|
||||
memo: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.invoice_id.as_deref(), Some(inv.id.as_str()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn amount_mode_matches_exact_amount_oldest_first() {
|
||||
let pool = pool().await;
|
||||
let first = invoice::create(
|
||||
&pool,
|
||||
new(
|
||||
AmountSpec::Grin(2_000_000_000),
|
||||
None,
|
||||
Some(MatchMode::Amount),
|
||||
),
|
||||
&MASTER,
|
||||
MASTER_PUB,
|
||||
MatchMode::Amount,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
// A second same-amount invoice; the oldest open one wins.
|
||||
let _second = invoice::create(
|
||||
&pool,
|
||||
new(
|
||||
AmountSpec::Grin(2_000_000_000),
|
||||
None,
|
||||
Some(MatchMode::Amount),
|
||||
),
|
||||
&MASTER,
|
||||
MASTER_PUB,
|
||||
MatchMode::Amount,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
insert_payment(&pool, "slate-c", 2_000_000_000, MASTER_PUB).await;
|
||||
|
||||
let result = match_payment(
|
||||
&pool,
|
||||
MatchMode::Amount,
|
||||
&IncomingPayment {
|
||||
slate_id: "slate-c",
|
||||
amount: 2_000_000_000,
|
||||
recipient_hex: MASTER_PUB,
|
||||
memo: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.invoice_id.as_deref(), Some(first.id.as_str()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mode_scoping_prevents_cross_mode_amount_collision() {
|
||||
let pool = pool().await;
|
||||
// A derived-mode invoice with the same amount must NOT be matched by an
|
||||
// amount-only payment on the master identity.
|
||||
let _derived = invoice::create(
|
||||
&pool,
|
||||
new(AmountSpec::Grin(500), None, Some(MatchMode::Derived)),
|
||||
&MASTER,
|
||||
MASTER_PUB,
|
||||
MatchMode::Amount,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
insert_payment(&pool, "slate-d", 500, MASTER_PUB).await;
|
||||
|
||||
let result = match_payment(
|
||||
&pool,
|
||||
MatchMode::Amount,
|
||||
&IncomingPayment {
|
||||
slate_id: "slate-d",
|
||||
amount: 500,
|
||||
recipient_hex: MASTER_PUB,
|
||||
memo: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
// No amount-mode invoice exists, so nothing matches.
|
||||
assert_eq!(result.invoice_id, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn credits_a_user_via_the_endpub_and_composes_with_invoices() {
|
||||
let pool = pool().await;
|
||||
let (_user, ep) = endpub::create_user(&pool, &MASTER, Some("alice".into()), None)
|
||||
.await
|
||||
.unwrap();
|
||||
insert_payment(&pool, "slate-e", 7, &ep.pubkey).await;
|
||||
|
||||
let result = match_payment(
|
||||
&pool,
|
||||
MatchMode::Memo,
|
||||
&IncomingPayment {
|
||||
slate_id: "slate-e",
|
||||
amount: 7,
|
||||
recipient_hex: &ep.pubkey,
|
||||
memo: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.user_id.as_deref(), Some("alice"));
|
||||
assert_eq!(result.invoice_id, None);
|
||||
|
||||
// The payment is credited to the user (balance reflects it).
|
||||
let balances = endpub::list_with_balances(&pool).await.unwrap();
|
||||
assert_eq!(balances[0].user_id, "alice");
|
||||
assert_eq!(balances[0].balance, 7);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fiat_quoted_invoice_matches_a_synthetic_payment_of_the_quoted_amount() {
|
||||
use crate::rates::Oracle;
|
||||
|
||||
let pool = pool().await;
|
||||
// Inject a fixed rate (no network): 0.02 USD/GRIN, so 10.00 USD is
|
||||
// 500 GRIN = 500_000_000_000 nanogrin.
|
||||
let oracle = Oracle::fixed(&["usd"], 0.02, 900);
|
||||
let quote = oracle.quote("10.00", "USD").await.unwrap();
|
||||
assert_eq!(quote.nanogrin, 500_000_000_000);
|
||||
|
||||
// Create the fiat invoice priced by the oracle, amount-matched.
|
||||
let inv = invoice::create(
|
||||
&pool,
|
||||
NewInvoice {
|
||||
order_ref: None,
|
||||
amount: AmountSpec::FiatQuoted {
|
||||
amount: "10.00".into(),
|
||||
currency: "USD".into(),
|
||||
nanogrin: quote.nanogrin,
|
||||
rate: crate::rates::format_rate(quote.fiat_per_grin),
|
||||
source: quote.source.to_string(),
|
||||
},
|
||||
memo: None,
|
||||
match_mode: Some(MatchMode::Amount),
|
||||
expiry_secs: Some(900),
|
||||
},
|
||||
&MASTER,
|
||||
MASTER_PUB,
|
||||
MatchMode::Amount,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
// The gap M5 left is filled: expected_amount is the locked nanogrin, and
|
||||
// the quote (rate + source) is stored.
|
||||
assert_eq!(inv.expected_amount, Some(500_000_000_000));
|
||||
assert_eq!(inv.fiat_amount.as_deref(), Some("10.00"));
|
||||
assert_eq!(inv.fiat_currency.as_deref(), Some("USD"));
|
||||
assert_eq!(inv.quote_rate.as_deref(), Some("0.02"));
|
||||
assert_eq!(inv.quote_source.as_deref(), Some("coingecko"));
|
||||
|
||||
// A payment of exactly the quoted amount matches by amount.
|
||||
insert_payment(&pool, "slate-fiat", 500_000_000_000, MASTER_PUB).await;
|
||||
let result = match_payment(
|
||||
&pool,
|
||||
MatchMode::Amount,
|
||||
&IncomingPayment {
|
||||
slate_id: "slate-fiat",
|
||||
amount: 500_000_000_000,
|
||||
recipient_hex: MASTER_PUB,
|
||||
memo: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.invoice_id.as_deref(), Some(inv.id.as_str()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expired_fiat_quote_is_not_matched_and_forces_a_requote() {
|
||||
let pool = pool().await;
|
||||
// A fiat quote whose lock window already elapsed (expiry in the past).
|
||||
let inv = invoice::create(
|
||||
&pool,
|
||||
NewInvoice {
|
||||
order_ref: None,
|
||||
amount: AmountSpec::FiatQuoted {
|
||||
amount: "10.00".into(),
|
||||
currency: "usd".into(),
|
||||
nanogrin: 500_000_000_000,
|
||||
rate: "0.02".into(),
|
||||
source: "coingecko".into(),
|
||||
},
|
||||
memo: None,
|
||||
match_mode: Some(MatchMode::Amount),
|
||||
expiry_secs: Some(-1),
|
||||
},
|
||||
&MASTER,
|
||||
MASTER_PUB,
|
||||
MatchMode::Amount,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
insert_payment(&pool, "slate-late", 500_000_000_000, MASTER_PUB).await;
|
||||
let result = match_payment(
|
||||
&pool,
|
||||
MatchMode::Amount,
|
||||
&IncomingPayment {
|
||||
slate_id: "slate-late",
|
||||
amount: 500_000_000_000,
|
||||
recipient_hex: MASTER_PUB,
|
||||
memo: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
// The stale-locked quote does not match; the checkout must re-quote.
|
||||
assert_eq!(result.invoice_id, None);
|
||||
assert_eq!(
|
||||
invoice::get(&pool, &inv.id)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.status(),
|
||||
invoice::InvoiceStatus::Expired
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unmatched_payment_returns_empty() {
|
||||
let pool = pool().await;
|
||||
insert_payment(&pool, "slate-f", 1, MASTER_PUB).await;
|
||||
let result = match_payment(
|
||||
&pool,
|
||||
MatchMode::Memo,
|
||||
&IncomingPayment {
|
||||
slate_id: "slate-f",
|
||||
amount: 1,
|
||||
recipient_hex: MASTER_PUB,
|
||||
memo: Some("no-such-order"),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result, MatchResult::default());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
//! Server-rendered QR codes as SVG, zero JavaScript.
|
||||
//!
|
||||
//! The QR is always generated at error-correction level **H** (tolerates
|
||||
//! ~30% occlusion) so an optional centered logo, sized to ~22% of the code,
|
||||
//! never breaks scannability. The logo is a white rounded backing rectangle
|
||||
//! plus one `<image>` element referencing a statically served asset (the
|
||||
//! Goblin mark by default, or the operator's own via `GP_QR_LOGO`). With no
|
||||
//! logo it is a plain black-on-white QR.
|
||||
//!
|
||||
//! Rendering is hand-rolled (one `<path>` of the dark modules) so the crate
|
||||
//! needs only the `qrcode` matrix, not its image/SVG feature or any raster
|
||||
//! dependency, and we keep full control of the logo overlay.
|
||||
|
||||
use qrcode::{Color, EcLevel, QrCode};
|
||||
|
||||
/// Logo size as a fraction of the QR width (safe under ECC level H).
|
||||
pub const LOGO_FRACTION: f64 = 0.22;
|
||||
/// Quiet zone in modules on every side (the QR spec's required margin).
|
||||
const QUIET: u32 = 4;
|
||||
|
||||
/// Failed to build a QR (e.g. the payload exceeds the largest QR version).
|
||||
#[derive(Debug)]
|
||||
pub struct QrError(pub String);
|
||||
|
||||
impl std::fmt::Display for QrError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "qr error: {}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for QrError {}
|
||||
|
||||
/// Render `data` as an SVG string at ECC level H. When `logo_href` is set, a
|
||||
/// white rounded rectangle plus a centered `<image>` are overlaid. The SVG
|
||||
/// scales to its container; a `viewBox` in module units keeps it crisp.
|
||||
pub fn svg(data: &str, logo_href: Option<&str>) -> Result<String, QrError> {
|
||||
let code = QrCode::with_error_correction_level(data.as_bytes(), EcLevel::H)
|
||||
.map_err(|e| QrError(e.to_string()))?;
|
||||
let width = code.width() as u32;
|
||||
let colors = code.to_colors();
|
||||
let dim = width + 2 * QUIET;
|
||||
|
||||
// One path for every dark module (each a 1x1 unit square), offset by the
|
||||
// quiet zone.
|
||||
let mut path = String::new();
|
||||
for y in 0..width {
|
||||
for x in 0..width {
|
||||
if colors[(y * width + x) as usize] == Color::Dark {
|
||||
let px = x + QUIET;
|
||||
let py = y + QUIET;
|
||||
path.push_str(&format!("M{px} {py}h1v1h-1z"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut svg = format!(
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {dim} {dim}\" \
|
||||
shape-rendering=\"crispEdges\" role=\"img\" aria-label=\"Payment QR code\" \
|
||||
class=\"qr\">\
|
||||
<rect width=\"{dim}\" height=\"{dim}\" fill=\"#ffffff\"/>\
|
||||
<path d=\"{path}\" fill=\"#000000\"/>"
|
||||
);
|
||||
|
||||
if let Some(href) = logo_href {
|
||||
// Center a logo sized to LOGO_FRACTION of the code (module units),
|
||||
// on a slightly larger white rounded backing so it reads cleanly.
|
||||
let logo = (dim as f64 * LOGO_FRACTION).round();
|
||||
let pad = 1.0_f64;
|
||||
let back = logo + 2.0 * pad;
|
||||
let center = dim as f64 / 2.0;
|
||||
let back_x = center - back / 2.0;
|
||||
let back_y = center - back / 2.0;
|
||||
let logo_x = center - logo / 2.0;
|
||||
let logo_y = center - logo / 2.0;
|
||||
let radius = back * 0.18;
|
||||
svg.push_str(&format!(
|
||||
"<rect x=\"{back_x:.2}\" y=\"{back_y:.2}\" width=\"{back:.2}\" height=\"{back:.2}\" \
|
||||
rx=\"{radius:.2}\" ry=\"{radius:.2}\" fill=\"#ffffff\"/>\
|
||||
<image href=\"{href}\" x=\"{logo_x:.2}\" y=\"{logo_y:.2}\" \
|
||||
width=\"{logo:.2}\" height=\"{logo:.2}\" preserveAspectRatio=\"xMidYMid meet\"/>"
|
||||
));
|
||||
}
|
||||
|
||||
svg.push_str("</svg>");
|
||||
Ok(svg)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn generates_valid_svg_at_ecc_h() {
|
||||
let out = svg("grin1qtestaddressdata", None).unwrap();
|
||||
assert!(out.starts_with("<svg"));
|
||||
assert!(out.ends_with("</svg>"));
|
||||
assert!(out.contains("viewBox"));
|
||||
// No script, no external CSS: zero JS by construction.
|
||||
assert!(!out.contains("<script"));
|
||||
assert!(out.contains("fill=\"#000000\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embeds_a_center_logo_when_requested() {
|
||||
let plain = svg("nprofile1qtest", None).unwrap();
|
||||
let logoed = svg("nprofile1qtest", Some("/static/goblin-mark.svg")).unwrap();
|
||||
assert!(!plain.contains("<image"));
|
||||
assert!(logoed.contains("<image"));
|
||||
assert!(logoed.contains("/static/goblin-mark.svg"));
|
||||
// The backing rounded rect is present.
|
||||
assert!(logoed.contains("rx="));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_payloads_still_render() {
|
||||
// A long nprofile-plus-relays string must not overflow QR capacity at
|
||||
// ECC H (this is well within version-40 limits).
|
||||
let data = "nprofile1".to_string() + &"a".repeat(300);
|
||||
assert!(svg(&data, None).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quiet_zone_is_present() {
|
||||
let out = svg("x", None).unwrap();
|
||||
// viewBox dimension exceeds the module count by 2*QUIET.
|
||||
let code = QrCode::with_error_correction_level("x".as_bytes(), EcLevel::H).unwrap();
|
||||
let dim = code.width() as u32 + 2 * QUIET;
|
||||
assert!(out.contains(&format!("viewBox=\"0 0 {dim} {dim}\"")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
//! Conversion rates: the optional, configurable price oracle.
|
||||
//!
|
||||
//! A store priced in fiat (cryptodrip.com prices in USD) sends GoblinPay a
|
||||
//! `{fiat amount, currency}` at checkout. This module quotes the equivalent
|
||||
//! Grin amount, locks it for an expiry window, and hands back the nanogrin the
|
||||
//! invoice's `expected_amount` is set to, so a fiat invoice then participates
|
||||
//! in amount-matching exactly like a Grin-denominated one. Grin-denominated
|
||||
//! invoices never touch this module.
|
||||
//!
|
||||
//! Transport (owner ruling, same as the M4 node client): the oracle HTTP goes
|
||||
//! DIRECT over normal HTTP (reqwest with the process-installed rustls `ring`
|
||||
//! provider, no aws-lc-rs), NEVER through the Nym tunnel. The mixnet in
|
||||
//! gp-nostr carries only the Nostr gift-wrap layer; the price fetch is a server
|
||||
//! concern that rides clearnet, mirroring the wallet<->node reads. This crate
|
||||
//! has no Nym linkage at all, so the direct path is structural, not configured.
|
||||
//!
|
||||
//! Design:
|
||||
//! - **Source** (`GP_RATE_SOURCE`, default `coingecko`): where the GRIN price
|
||||
//! comes from. CoinGecko lists GRIN under id `grin` and prices many fiats in
|
||||
//! one call (`/simple/price?ids=grin&vs_currencies=usd,eur,...`).
|
||||
//! - **Rate cache** (`GP_RATE_CACHE_TTL`, default 60s): a fetched rate is
|
||||
//! reused for the TTL so concurrent checkouts do not hammer the source.
|
||||
//! - **Quote lock** (`GP_QUOTE_TTL`, default 900s): a created invoice locks its
|
||||
//! Grin amount for this window (its `expiry`); an amount-match past the lock
|
||||
//! re-quotes rather than honouring a stale rate.
|
||||
//! - **Stale fallback** (`GP_RATE_STALE_MAX`, default 0 = off): if a live fetch
|
||||
//! fails but the last cached rate is within this bound, serve it (flagged
|
||||
//! `stale`) instead of failing the checkout. 0 keeps the strict fail-fast.
|
||||
//!
|
||||
//! Testing: the conversion math, the CoinGecko parser (against a recorded
|
||||
//! response fixture), the quote-lock predicate, and the cache-freshness logic
|
||||
//! are all pure and unit-tested here. No test touches the network; the live
|
||||
//! fetch path is exercised in the supervised integration round, the same
|
||||
//! precedent as the M4 confirmation "found" path.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::config::{Config, RateSource};
|
||||
|
||||
/// The CoinGecko coin id for GRIN.
|
||||
const COINGECKO_GRIN_ID: &str = "grin";
|
||||
/// CoinGecko simple-price endpoint base (host-only kept for the log line).
|
||||
const COINGECKO_BASE: &str = "https://api.coingecko.com/api/v3/simple/price";
|
||||
/// Per-request timeout for the oracle fetch (a single small JSON GET).
|
||||
const FETCH_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Why a quote could not be produced. Mapped to a clear HTTP error by the
|
||||
/// create-invoice handler so an unpriceable invoice is never silently created.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RateError {
|
||||
/// The requested currency is not in `GP_RATE_CURRENCIES` (a 400: the caller
|
||||
/// must send a supported currency). Checked before any network call.
|
||||
UnsupportedCurrency(String),
|
||||
/// The fiat amount could not be parsed as a non-negative decimal (a 400).
|
||||
BadAmount(String),
|
||||
/// No fresh rate and no usable stale fallback (source unreachable or the
|
||||
/// response had no price for the currency): a 502, fail fast.
|
||||
SourceUnavailable(String),
|
||||
/// Misconfiguration (an unknown source reached the oracle): a 500.
|
||||
Config(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RateError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
RateError::UnsupportedCurrency(c) => {
|
||||
write!(f, "currency `{c}` is not enabled (see GP_RATE_CURRENCIES)")
|
||||
}
|
||||
RateError::BadAmount(a) => write!(f, "fiat amount `{a}` is not a valid decimal"),
|
||||
RateError::SourceUnavailable(m) => write!(f, "price oracle unavailable: {m}"),
|
||||
RateError::Config(m) => write!(f, "rate oracle misconfigured: {m}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for RateError {}
|
||||
|
||||
/// A locked quote: the priced Grin amount plus the rate and source it was
|
||||
/// derived from, echoed onto the invoice for the receipt/audit trail.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Quote {
|
||||
/// The locked Grin amount in nanogrin (the invoice `expected_amount`).
|
||||
pub nanogrin: u64,
|
||||
/// The currency the quote is in (lowercased ISO code).
|
||||
pub currency: String,
|
||||
/// The rate used: fiat units per one GRIN (the price of 1 GRIN).
|
||||
pub fiat_per_grin: f64,
|
||||
/// The source the rate came from (e.g. `coingecko`).
|
||||
pub source: &'static str,
|
||||
/// True when served from a stale cache entry (a fallback, not a fresh fetch).
|
||||
pub stale: bool,
|
||||
}
|
||||
|
||||
/// Parse a fiat amount decimal string into an `f64`, rejecting anything that is
|
||||
/// not a finite, non-negative number.
|
||||
pub fn parse_fiat_amount(amount: &str) -> Result<f64, RateError> {
|
||||
let trimmed = amount.trim();
|
||||
let value: f64 = trimmed
|
||||
.parse()
|
||||
.map_err(|_| RateError::BadAmount(amount.to_string()))?;
|
||||
if !value.is_finite() || value < 0.0 {
|
||||
return Err(RateError::BadAmount(amount.to_string()));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
/// Convert a fiat amount to nanogrin at a given rate (fiat units per one GRIN),
|
||||
/// rounding to the nearest nanogrin (1 GRIN = 1e9 nanogrin).
|
||||
///
|
||||
/// `grin = fiat / fiat_per_grin`, then `nanogrin = round(grin * 1e9)`. Pure and
|
||||
/// deterministic for a fixed `(fiat, rate)`, so the rounding is unit-tested.
|
||||
pub fn fiat_to_nanogrin(fiat_amount: f64, fiat_per_grin: f64) -> Result<u64, RateError> {
|
||||
if !fiat_per_grin.is_finite() || fiat_per_grin <= 0.0 {
|
||||
return Err(RateError::SourceUnavailable(format!(
|
||||
"non-positive rate {fiat_per_grin}"
|
||||
)));
|
||||
}
|
||||
if !fiat_amount.is_finite() || fiat_amount < 0.0 {
|
||||
return Err(RateError::BadAmount(fiat_amount.to_string()));
|
||||
}
|
||||
let nano = (fiat_amount / fiat_per_grin * 1e9).round();
|
||||
if !nano.is_finite() || nano < 0.0 || nano > u64::MAX as f64 {
|
||||
return Err(RateError::BadAmount(format!(
|
||||
"amount {fiat_amount} at rate {fiat_per_grin} overflows nanogrin"
|
||||
)));
|
||||
}
|
||||
Ok(nano as u64)
|
||||
}
|
||||
|
||||
/// Format a rate for storage/display: fiat-per-GRIN to a fixed precision,
|
||||
/// trailing zeros trimmed. Used for the invoice `quote_rate` column.
|
||||
pub fn format_rate(fiat_per_grin: f64) -> String {
|
||||
let s = format!("{fiat_per_grin:.10}");
|
||||
let trimmed = s.trim_end_matches('0').trim_end_matches('.');
|
||||
if trimmed.is_empty() {
|
||||
"0".to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a CoinGecko `/simple/price` response, returning the fiat-per-GRIN
|
||||
/// price for `currency` (case-insensitive). The response shape is
|
||||
/// `{"grin":{"usd":0.021,"eur":0.018}}`.
|
||||
pub fn parse_coingecko(json: &str, currency: &str) -> Result<f64, RateError> {
|
||||
let value: serde_json::Value = serde_json::from_str(json)
|
||||
.map_err(|e| RateError::SourceUnavailable(format!("bad JSON from coingecko: {e}")))?;
|
||||
let cur = currency.to_lowercase();
|
||||
value
|
||||
.get(COINGECKO_GRIN_ID)
|
||||
.and_then(|m| m.get(&cur))
|
||||
.and_then(|v| v.as_f64())
|
||||
.filter(|p| p.is_finite() && *p > 0.0)
|
||||
.ok_or_else(|| {
|
||||
RateError::SourceUnavailable(format!("coingecko returned no `{cur}` price for grin"))
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a quote locked at `quoted_at_unix` for `ttl_secs` is still valid at
|
||||
/// `now_unix`. The pure predicate behind the invoice `expiry` column: a quote
|
||||
/// is honoured only inside its lock window; past it, the amount-match fails and
|
||||
/// the checkout re-quotes.
|
||||
pub fn quote_valid(quoted_at_unix: i64, ttl_secs: i64, now_unix: i64) -> bool {
|
||||
now_unix >= quoted_at_unix && now_unix < quoted_at_unix.saturating_add(ttl_secs)
|
||||
}
|
||||
|
||||
/// One cached rate for a currency: the price and when it was fetched.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct CachedRate {
|
||||
fiat_per_grin: f64,
|
||||
fetched: Instant,
|
||||
}
|
||||
|
||||
/// The configurable price oracle. Holds the supported currency set, the cache,
|
||||
/// and the lock/TTL knobs; the live fetch reuses one reqwest client.
|
||||
pub struct Oracle {
|
||||
source: RateSource,
|
||||
/// Supported fiat currencies (lowercased ISO codes).
|
||||
currencies: Vec<String>,
|
||||
cache_ttl: Duration,
|
||||
stale_max: Duration,
|
||||
/// The invoice quote-lock window in seconds (`GP_QUOTE_TTL`).
|
||||
quote_ttl_secs: i64,
|
||||
cache: Mutex<HashMap<String, CachedRate>>,
|
||||
/// A shared HTTP client (DIRECT, never Nym). `None` for a fixed/test oracle
|
||||
/// whose cache is pre-seeded so it never fetches.
|
||||
client: Option<reqwest::Client>,
|
||||
}
|
||||
|
||||
impl Oracle {
|
||||
/// Build the oracle from the resolved config: the live CoinGecko client with
|
||||
/// the configured currency set and lock/TTL windows.
|
||||
pub fn from_config(cfg: &Config) -> Oracle {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(FETCH_TIMEOUT)
|
||||
// CoinGecko 403s the default reqwest agent from datacenter IPs; a
|
||||
// browser-style UA is accepted (verified from the us-east host).
|
||||
.user_agent("Mozilla/5.0 (compatible; GoblinPay/0.1)")
|
||||
.build()
|
||||
.ok();
|
||||
Oracle {
|
||||
source: cfg.rate_source,
|
||||
currencies: cfg.rate_currencies.clone(),
|
||||
cache_ttl: Duration::from_secs(cfg.rate_cache_ttl.max(0) as u64),
|
||||
stale_max: Duration::from_secs(cfg.rate_stale_max.max(0) as u64),
|
||||
quote_ttl_secs: cfg.quote_ttl,
|
||||
cache: Mutex::new(HashMap::new()),
|
||||
client,
|
||||
}
|
||||
}
|
||||
|
||||
/// A network-free oracle with a fixed rate for every supported currency, for
|
||||
/// tests and air-gapped/offline operation: the cache is pre-seeded fresh so
|
||||
/// `quote` never fetches. `quote_ttl_secs` sets the lock window.
|
||||
pub fn fixed(currencies: &[&str], fiat_per_grin: f64, quote_ttl_secs: i64) -> Oracle {
|
||||
let mut cache = HashMap::new();
|
||||
let now = Instant::now();
|
||||
for c in currencies {
|
||||
cache.insert(
|
||||
c.to_lowercase(),
|
||||
CachedRate {
|
||||
fiat_per_grin,
|
||||
fetched: now,
|
||||
},
|
||||
);
|
||||
}
|
||||
Oracle {
|
||||
source: RateSource::CoinGecko,
|
||||
currencies: currencies.iter().map(|c| c.to_lowercase()).collect(),
|
||||
// A very long freshness so the seeded entry is always used.
|
||||
cache_ttl: Duration::from_secs(u32::MAX as u64),
|
||||
stale_max: Duration::ZERO,
|
||||
quote_ttl_secs,
|
||||
cache: Mutex::new(cache),
|
||||
client: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The quote-lock window in seconds (the fiat invoice's expiry).
|
||||
pub fn quote_ttl_secs(&self) -> i64 {
|
||||
self.quote_ttl_secs
|
||||
}
|
||||
|
||||
/// Whether a currency is enabled (case-insensitive).
|
||||
pub fn supports(&self, currency: &str) -> bool {
|
||||
let cur = currency.to_lowercase();
|
||||
self.currencies.contains(&cur)
|
||||
}
|
||||
|
||||
/// Quote a `{fiat amount, currency}` into a locked Grin amount.
|
||||
///
|
||||
/// Fails fast when the currency is not enabled (no network call), the amount
|
||||
/// is malformed, or no fresh/stale rate can be sourced. On success the
|
||||
/// returned [`Quote`] carries the nanogrin the invoice `expected_amount` is
|
||||
/// set to plus the rate/source for the audit trail.
|
||||
pub async fn quote(&self, fiat_amount: &str, currency: &str) -> Result<Quote, RateError> {
|
||||
if !self.supports(currency) {
|
||||
return Err(RateError::UnsupportedCurrency(currency.to_string()));
|
||||
}
|
||||
let amount = parse_fiat_amount(fiat_amount)?;
|
||||
let cur = currency.to_lowercase();
|
||||
|
||||
let (fiat_per_grin, stale) = self.rate_for(&cur).await?;
|
||||
let nanogrin = fiat_to_nanogrin(amount, fiat_per_grin)?;
|
||||
Ok(Quote {
|
||||
nanogrin,
|
||||
currency: cur,
|
||||
fiat_per_grin,
|
||||
source: self.source.as_str(),
|
||||
stale,
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve a currency's fiat-per-GRIN rate: a fresh cache hit, else a live
|
||||
/// fetch, else a stale-cache fallback within `GP_RATE_STALE_MAX`. Returns
|
||||
/// `(rate, stale)`.
|
||||
async fn rate_for(&self, cur: &str) -> Result<(f64, bool), RateError> {
|
||||
let now = Instant::now();
|
||||
// Fresh cache hit.
|
||||
if let Some(entry) = self.cache_get(cur) {
|
||||
if is_fresh(now.saturating_duration_since(entry.fetched), self.cache_ttl) {
|
||||
return Ok((entry.fiat_per_grin, false));
|
||||
}
|
||||
}
|
||||
// Live fetch (DIRECT).
|
||||
match self.fetch(cur).await {
|
||||
Ok(rate) => {
|
||||
self.cache_put(cur, rate, now);
|
||||
Ok((rate, false))
|
||||
}
|
||||
Err(fetch_err) => {
|
||||
// Stale fallback within the bounded window, if any.
|
||||
if let Some(entry) = self.cache_get(cur) {
|
||||
if !self.stale_max.is_zero()
|
||||
&& is_fresh(now.saturating_duration_since(entry.fetched), self.stale_max)
|
||||
{
|
||||
log::warn!(
|
||||
"rates: {} fetch failed, serving stale {cur} rate: {fetch_err}",
|
||||
self.source.as_str()
|
||||
);
|
||||
return Ok((entry.fiat_per_grin, true));
|
||||
}
|
||||
}
|
||||
Err(fetch_err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The live, DIRECT HTTP fetch for one currency's GRIN price. Never called
|
||||
/// by the fixed/test oracle (its cache is always fresh).
|
||||
async fn fetch(&self, cur: &str) -> Result<f64, RateError> {
|
||||
let client = self
|
||||
.client
|
||||
.as_ref()
|
||||
.ok_or_else(|| RateError::SourceUnavailable("HTTP client unavailable".into()))?;
|
||||
match self.source {
|
||||
RateSource::CoinGecko => {
|
||||
let url = format!("{COINGECKO_BASE}?ids={COINGECKO_GRIN_ID}&vs_currencies={cur}");
|
||||
let resp = client.get(&url).send().await.map_err(|e| {
|
||||
RateError::SourceUnavailable(format!("coingecko request failed: {e}"))
|
||||
})?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(RateError::SourceUnavailable(format!(
|
||||
"coingecko HTTP {}",
|
||||
resp.status().as_u16()
|
||||
)));
|
||||
}
|
||||
let body = resp.text().await.map_err(|e| {
|
||||
RateError::SourceUnavailable(format!("coingecko body read failed: {e}"))
|
||||
})?;
|
||||
parse_coingecko(&body, cur)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cache_get(&self, cur: &str) -> Option<CachedRate> {
|
||||
self.cache.lock().ok().and_then(|m| m.get(cur).copied())
|
||||
}
|
||||
|
||||
fn cache_put(&self, cur: &str, fiat_per_grin: f64, fetched: Instant) {
|
||||
if let Ok(mut m) = self.cache.lock() {
|
||||
m.insert(
|
||||
cur.to_string(),
|
||||
CachedRate {
|
||||
fiat_per_grin,
|
||||
fetched,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an entry aged `age` is still fresh under `ttl`. A zero TTL means
|
||||
/// "always refetch" (never fresh).
|
||||
fn is_fresh(age: Duration, ttl: Duration) -> bool {
|
||||
!ttl.is_zero() && age <= ttl
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// A REAL CoinGecko `/simple/price?ids=grin&vs_currencies=usd,eur,gbp`
|
||||
// response, captured read-only 2026-07-01. GRIN is listed under id `grin`.
|
||||
// The parser is asserted against this exact wire shape so a production
|
||||
// response and this test agree; no test hits the live oracle.
|
||||
const COINGECKO_FIXTURE: &str =
|
||||
r#"{"grin":{"usd":0.02097549,"eur":0.01841713,"gbp":0.01577731}}"#;
|
||||
|
||||
#[test]
|
||||
fn parses_recorded_coingecko_fixture() {
|
||||
assert_eq!(
|
||||
parse_coingecko(COINGECKO_FIXTURE, "usd").unwrap(),
|
||||
0.02097549
|
||||
);
|
||||
assert_eq!(
|
||||
parse_coingecko(COINGECKO_FIXTURE, "eur").unwrap(),
|
||||
0.01841713
|
||||
);
|
||||
assert_eq!(
|
||||
parse_coingecko(COINGECKO_FIXTURE, "gbp").unwrap(),
|
||||
0.01577731
|
||||
);
|
||||
// Case-insensitive currency selection.
|
||||
assert_eq!(
|
||||
parse_coingecko(COINGECKO_FIXTURE, "USD").unwrap(),
|
||||
0.02097549
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coingecko_missing_currency_is_source_error() {
|
||||
// A currency not present in the response is a source error, not a panic.
|
||||
assert!(matches!(
|
||||
parse_coingecko(COINGECKO_FIXTURE, "jpy"),
|
||||
Err(RateError::SourceUnavailable(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_coingecko("not json", "usd"),
|
||||
Err(RateError::SourceUnavailable(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversion_rounds_to_nearest_nanogrin() {
|
||||
// Clean case: 10.00 USD at 0.02 USD/GRIN = 500 GRIN exactly.
|
||||
assert_eq!(fiat_to_nanogrin(10.0, 0.02).unwrap(), 500_000_000_000);
|
||||
// Rounding case: 1.00 at 0.03 = 33.3333... GRIN -> 33_333_333_333 nano.
|
||||
assert_eq!(fiat_to_nanogrin(1.0, 0.03).unwrap(), 33_333_333_333);
|
||||
// A tiny amount still rounds to the nearest nanogrin.
|
||||
assert_eq!(fiat_to_nanogrin(0.00000000002, 0.02).unwrap(), 1);
|
||||
// Zero fiat is a zero-nanogrin quote, not an error.
|
||||
assert_eq!(fiat_to_nanogrin(0.0, 0.02).unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversion_rejects_bad_inputs() {
|
||||
assert!(matches!(
|
||||
fiat_to_nanogrin(10.0, 0.0),
|
||||
Err(RateError::SourceUnavailable(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
fiat_to_nanogrin(10.0, -1.0),
|
||||
Err(RateError::SourceUnavailable(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
fiat_to_nanogrin(-1.0, 0.02),
|
||||
Err(RateError::BadAmount(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
fiat_to_nanogrin(f64::NAN, 0.02),
|
||||
Err(RateError::BadAmount(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_fiat_amount_strings() {
|
||||
assert_eq!(parse_fiat_amount("19.99").unwrap(), 19.99);
|
||||
assert_eq!(parse_fiat_amount(" 5 ").unwrap(), 5.0);
|
||||
assert_eq!(parse_fiat_amount("0").unwrap(), 0.0);
|
||||
assert!(matches!(
|
||||
parse_fiat_amount("abc"),
|
||||
Err(RateError::BadAmount(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_fiat_amount("-3.00"),
|
||||
Err(RateError::BadAmount(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_formatting_trims_zeros() {
|
||||
assert_eq!(format_rate(0.02097549), "0.02097549");
|
||||
assert_eq!(format_rate(0.02), "0.02");
|
||||
assert_eq!(format_rate(1.0), "1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quote_lock_expires_after_ttl() {
|
||||
// Locked at t=1000 for 900s: valid inside the window, rejected past it.
|
||||
assert!(quote_valid(1000, 900, 1000)); // at lock time
|
||||
assert!(quote_valid(1000, 900, 1899)); // last valid second
|
||||
assert!(!quote_valid(1000, 900, 1900)); // TTL elapsed -> re-quote
|
||||
assert!(!quote_valid(1000, 900, 2500)); // long past
|
||||
assert!(!quote_valid(1000, 900, 999)); // before the lock (clock skew)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_freshness_respects_ttl() {
|
||||
assert!(is_fresh(Duration::from_secs(30), Duration::from_secs(60)));
|
||||
assert!(is_fresh(Duration::from_secs(60), Duration::from_secs(60)));
|
||||
assert!(!is_fresh(Duration::from_secs(61), Duration::from_secs(60)));
|
||||
// A zero TTL is never fresh (always refetch).
|
||||
assert!(!is_fresh(Duration::ZERO, Duration::ZERO));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fixed_oracle_quotes_without_network() {
|
||||
// 0.02 USD per GRIN, so 10.00 USD = 500 GRIN.
|
||||
let oracle = Oracle::fixed(&["usd", "eur"], 0.02, 900);
|
||||
let q = oracle.quote("10.00", "usd").await.unwrap();
|
||||
assert_eq!(q.nanogrin, 500_000_000_000);
|
||||
assert_eq!(q.currency, "usd");
|
||||
assert_eq!(q.fiat_per_grin, 0.02);
|
||||
assert!(!q.stale);
|
||||
assert_eq!(oracle.quote_ttl_secs(), 900);
|
||||
// Case-insensitive on the way in, lowercased out.
|
||||
let q2 = oracle.quote("10.00", "USD").await.unwrap();
|
||||
assert_eq!(q2.nanogrin, 500_000_000_000);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fixed_oracle_rejects_unsupported_currency_before_any_fetch() {
|
||||
let oracle = Oracle::fixed(&["usd"], 0.02, 900);
|
||||
assert_eq!(
|
||||
oracle.quote("10.00", "jpy").await,
|
||||
Err(RateError::UnsupportedCurrency("jpy".into()))
|
||||
);
|
||||
assert!(!oracle.supports("jpy"));
|
||||
assert!(oracle.supports("USD"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fixed_oracle_rejects_bad_amount() {
|
||||
let oracle = Oracle::fixed(&["usd"], 0.02, 900);
|
||||
assert!(matches!(
|
||||
oracle.quote("not-a-number", "usd").await,
|
||||
Err(RateError::BadAmount(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
//! 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:
|
||||
//! 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
|
||||
//! parameters and where its payment webhooks go.
|
||||
|
||||
use crate::config::MatchMode;
|
||||
use crate::invoice::{AmountSpec, NewInvoice};
|
||||
|
||||
/// A store's request to create an invoice, uniform across connectors.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CreateInvoiceRequest {
|
||||
/// The store's order reference (also the memo/subject match key).
|
||||
pub order_ref: Option<String>,
|
||||
/// The amount, exact Grin or a fiat quote.
|
||||
pub amount: AmountSpec,
|
||||
/// A human memo for the checkout page.
|
||||
pub memo: Option<String>,
|
||||
/// Per-invoice matching-mode override; `None` uses the global default.
|
||||
pub match_mode: Option<MatchMode>,
|
||||
/// Expiry in seconds from now; `None` for no expiry.
|
||||
pub expiry_secs: Option<i64>,
|
||||
}
|
||||
|
||||
/// The uniform connector contract. Implementors translate a store request into
|
||||
/// invoice parameters and advertise where payment webhooks should be sent.
|
||||
pub trait StoreConnector: Send + Sync {
|
||||
/// Stable connector id (e.g. `rest`, `woocommerce`, `medusa`).
|
||||
fn id(&self) -> &str;
|
||||
|
||||
/// Map a store request into invoice-creation parameters. The default is
|
||||
/// the identity mapping; a connector overrides only to impose its own
|
||||
/// policy (a forced matching mode, a default expiry, and so on).
|
||||
fn new_invoice(&self, req: CreateInvoiceRequest) -> NewInvoice {
|
||||
NewInvoice {
|
||||
order_ref: req.order_ref,
|
||||
amount: req.amount,
|
||||
memo: req.memo,
|
||||
match_mode: req.match_mode,
|
||||
expiry_secs: req.expiry_secs,
|
||||
}
|
||||
}
|
||||
|
||||
/// The webhook endpoint payment events for this store are delivered to, if
|
||||
/// it consumes webhooks.
|
||||
fn webhook_url(&self) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The built-in generic REST connector: the identity request mapping plus the
|
||||
/// operator's configured webhook endpoint. WooCommerce and Medusa speak this
|
||||
/// same REST + webhook contract, so server-side they reuse it unchanged.
|
||||
pub struct RestConnector {
|
||||
webhook_url: Option<String>,
|
||||
}
|
||||
|
||||
impl RestConnector {
|
||||
pub fn new(webhook_url: Option<String>) -> RestConnector {
|
||||
RestConnector { webhook_url }
|
||||
}
|
||||
}
|
||||
|
||||
impl StoreConnector for RestConnector {
|
||||
fn id(&self) -> &str {
|
||||
"rest"
|
||||
}
|
||||
|
||||
fn webhook_url(&self) -> Option<&str> {
|
||||
self.webhook_url.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rest_connector_maps_request_identically() {
|
||||
let conn = RestConnector::new(Some("https://store.example/hook".into()));
|
||||
assert_eq!(conn.id(), "rest");
|
||||
assert_eq!(conn.webhook_url(), Some("https://store.example/hook"));
|
||||
let req = CreateInvoiceRequest {
|
||||
order_ref: Some("order-9".into()),
|
||||
amount: AmountSpec::Grin(42),
|
||||
memo: Some("m".into()),
|
||||
match_mode: Some(MatchMode::Derived),
|
||||
expiry_secs: Some(600),
|
||||
};
|
||||
let inv = conn.new_invoice(req);
|
||||
assert_eq!(inv.order_ref.as_deref(), Some("order-9"));
|
||||
assert_eq!(inv.match_mode, Some(MatchMode::Derived));
|
||||
assert_eq!(inv.expiry_secs, Some(600));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
//! HTTP webhook notifications (milestone 6): the signed, idempotent, retried
|
||||
//! payload a store backend (WooCommerce, or any REST consumer) receives on a
|
||||
//! payment event. This is the contract the connector plugins depend on, so
|
||||
//! the field names, the signature scheme, and the headers are fixed here.
|
||||
//!
|
||||
//! ## Body (`application/json`)
|
||||
//!
|
||||
//! ```json
|
||||
//! {
|
||||
//! "event_id": "5f3c…", // 128-bit hex, the idempotency key
|
||||
//! "event_type": "payment.received", // (payment.confirmed once node-confirmed)
|
||||
//! "created_at": "2026-07-01T12:00:00Z",
|
||||
//! "payment": {
|
||||
//! "slate_id": "…",
|
||||
//! "amount": 2000000000, // nanogrin (integer)
|
||||
//! "amount_grin": "2", // human decimal string
|
||||
//! "status": "received",
|
||||
//! "payer": "…hex…", // sender pubkey, or null
|
||||
//! "confirmed_height": null // set once confirmed on chain
|
||||
//! },
|
||||
//! "invoice_id": "…", // or null
|
||||
//! "order_ref": "order-42", // or null
|
||||
//! "user_id": "…" // multi-tenant crediting (5b), or null
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! ## Signature
|
||||
//!
|
||||
//! `X-GoblinPay-Signature: sha256=<hex>` where `<hex>` is
|
||||
//! `HMAC-SHA256(secret, raw_body_bytes)`. The receiver recomputes the HMAC
|
||||
//! over the exact bytes it received and compares in constant time.
|
||||
//! `X-GoblinPay-Delivery: <event_id>` lets the receiver dedupe retries.
|
||||
//!
|
||||
//! Sending, retries, and backoff are persisted in `webhook_delivery`, so a
|
||||
//! crash mid-retry resumes; the HTTP transport itself lives in gp-server.
|
||||
|
||||
use hmac::{Hmac, Mac};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::Sha256;
|
||||
use sqlx::SqlitePool;
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
use crate::ids;
|
||||
|
||||
/// HTTP header carrying the HMAC signature.
|
||||
pub const SIGNATURE_HEADER: &str = "X-GoblinPay-Signature";
|
||||
/// HTTP header carrying the idempotency key (the event id).
|
||||
pub const DELIVERY_HEADER: &str = "X-GoblinPay-Delivery";
|
||||
|
||||
/// Base retry backoff (seconds); doubles each attempt up to [`BACKOFF_CAP`].
|
||||
const BACKOFF_BASE: i64 = 30;
|
||||
/// Maximum retry backoff (seconds).
|
||||
const BACKOFF_CAP: i64 = 3600;
|
||||
/// Give up after this many attempts.
|
||||
pub const MAX_ATTEMPTS: i64 = 12;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
/// The payment slice of the payload.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PaymentPayload {
|
||||
pub slate_id: String,
|
||||
pub amount: u64,
|
||||
pub amount_grin: String,
|
||||
pub status: String,
|
||||
pub payer: Option<String>,
|
||||
pub confirmed_height: Option<u64>,
|
||||
}
|
||||
|
||||
/// The full webhook payload (the JSON body).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WebhookPayload {
|
||||
pub event_id: String,
|
||||
pub event_type: String,
|
||||
pub created_at: String,
|
||||
pub payment: PaymentPayload,
|
||||
pub invoice_id: Option<String>,
|
||||
pub order_ref: Option<String>,
|
||||
pub user_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Format nanogrin as a trimmed decimal Grin string (1 grin = 1e9 nanogrin).
|
||||
pub fn nanogrin_to_grin(nano: u64) -> String {
|
||||
let whole = nano / 1_000_000_000;
|
||||
let frac = nano % 1_000_000_000;
|
||||
if frac == 0 {
|
||||
whole.to_string()
|
||||
} else {
|
||||
let frac = format!("{frac:09}");
|
||||
format!("{whole}.{}", frac.trim_end_matches('0'))
|
||||
}
|
||||
}
|
||||
|
||||
impl WebhookPayload {
|
||||
/// Build a `payment.received` payload with a fresh idempotency key.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn received(
|
||||
slate_id: String,
|
||||
amount: u64,
|
||||
payer: Option<String>,
|
||||
invoice_id: Option<String>,
|
||||
order_ref: Option<String>,
|
||||
user_id: Option<String>,
|
||||
) -> WebhookPayload {
|
||||
WebhookPayload {
|
||||
event_id: ids::random_id(),
|
||||
event_type: "payment.received".into(),
|
||||
created_at: now_iso8601(),
|
||||
payment: PaymentPayload {
|
||||
slate_id: slate_id.clone(),
|
||||
amount,
|
||||
amount_grin: nanogrin_to_grin(amount),
|
||||
status: "received".into(),
|
||||
payer,
|
||||
confirmed_height: None,
|
||||
},
|
||||
invoice_id,
|
||||
order_ref,
|
||||
user_id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize to the exact JSON body that gets signed and stored.
|
||||
pub fn to_body(&self) -> String {
|
||||
serde_json::to_string(self).expect("payload serializes")
|
||||
}
|
||||
}
|
||||
|
||||
/// `sha256=<hex(HMAC-SHA256(secret, body))>`, the value of the signature
|
||||
/// header.
|
||||
pub fn sign(secret: &str, body: &[u8]) -> String {
|
||||
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC accepts any key size");
|
||||
mac.update(body);
|
||||
let digest = mac.finalize().into_bytes();
|
||||
format!("sha256={}", hex::encode(digest))
|
||||
}
|
||||
|
||||
/// Verify a signature header against the body in constant time. Accepts the
|
||||
/// full `sha256=<hex>` form (case-insensitive scheme, lower-hex digest).
|
||||
pub fn verify(secret: &str, body: &[u8], header: &str) -> bool {
|
||||
let expected = sign(secret, body);
|
||||
// Compare the whole `sha256=<hex>` string in constant time. Equal length
|
||||
// for a well-formed header; a length mismatch is a plain reject.
|
||||
let a = expected.as_bytes();
|
||||
let b = header.trim().as_bytes();
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
a.ct_eq(b).into()
|
||||
}
|
||||
|
||||
/// Retry backoff for the Nth attempt (attempt counter starts at 1 after the
|
||||
/// first failure): `min(BASE * 2^(attempts-1), CAP)`.
|
||||
pub fn backoff_secs(attempts: i64) -> i64 {
|
||||
if attempts <= 0 {
|
||||
return 0;
|
||||
}
|
||||
let shift = (attempts - 1).min(20) as u32;
|
||||
BACKOFF_BASE.saturating_mul(1i64 << shift).min(BACKOFF_CAP)
|
||||
}
|
||||
|
||||
/// A persisted delivery awaiting (re)send.
|
||||
#[derive(Debug, Clone, sqlx::FromRow)]
|
||||
pub struct Delivery {
|
||||
pub id: String,
|
||||
pub url: String,
|
||||
pub body: String,
|
||||
pub attempts: i64,
|
||||
}
|
||||
|
||||
/// Persist a payload for delivery to `url`, due immediately. Returns the
|
||||
/// event id (idempotency key). No-op-safe: the event id is unique.
|
||||
pub async fn enqueue(
|
||||
pool: &SqlitePool,
|
||||
url: &str,
|
||||
payload: &WebhookPayload,
|
||||
) -> Result<String, sqlx::Error> {
|
||||
let body = payload.to_body();
|
||||
sqlx::query(
|
||||
"INSERT INTO webhook_delivery \
|
||||
(id, payment_id, event_type, url, body, attempts, delivered, next_attempt_at, \
|
||||
created_at, updated_at) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, 0, 0, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), \
|
||||
strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))",
|
||||
)
|
||||
.bind(&payload.event_id)
|
||||
.bind(&payload.payment.slate_id)
|
||||
.bind(&payload.event_type)
|
||||
.bind(url)
|
||||
.bind(&body)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(payload.event_id.clone())
|
||||
}
|
||||
|
||||
/// Deliveries that are due (undelivered and past their next-attempt time and
|
||||
/// under the attempt ceiling).
|
||||
pub async fn due(pool: &SqlitePool, limit: i64) -> Result<Vec<Delivery>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Delivery>(
|
||||
"SELECT id, url, body, attempts FROM webhook_delivery \
|
||||
WHERE delivered = 0 AND attempts < ?2 \
|
||||
AND next_attempt_at <= strftime('%Y-%m-%dT%H:%M:%SZ', 'now') \
|
||||
ORDER BY next_attempt_at LIMIT ?1",
|
||||
)
|
||||
.bind(limit)
|
||||
.bind(MAX_ATTEMPTS)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Mark a delivery succeeded.
|
||||
pub async fn mark_delivered(pool: &SqlitePool, id: &str) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
"UPDATE webhook_delivery SET delivered = 1, attempts = attempts + 1, last_error = NULL, \
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?1",
|
||||
)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record a failed attempt and schedule the next one with backoff.
|
||||
pub async fn mark_failed(pool: &SqlitePool, id: &str, error: &str) -> Result<(), sqlx::Error> {
|
||||
// The new attempt count decides the backoff, computed in Rust and applied
|
||||
// as a relative SQL offset.
|
||||
let attempts: i64 =
|
||||
sqlx::query_scalar("SELECT attempts + 1 FROM webhook_delivery WHERE id = ?1")
|
||||
.bind(id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
let backoff = format!("+{} seconds", backoff_secs(attempts));
|
||||
sqlx::query(
|
||||
"UPDATE webhook_delivery SET attempts = ?2, last_error = ?3, \
|
||||
next_attempt_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now', ?4), \
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?1",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(attempts)
|
||||
.bind(error)
|
||||
.bind(backoff)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Current UTC time as ISO-8601 seconds (`YYYY-MM-DDTHH:MM:SSZ`), computed
|
||||
/// from the Unix epoch without pulling in a date library.
|
||||
fn now_iso8601() -> String {
|
||||
let secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0) as i64;
|
||||
let days = secs.div_euclid(86_400);
|
||||
let tod = secs.rem_euclid(86_400);
|
||||
let (h, m, s) = (tod / 3600, (tod % 3600) / 60, tod % 60);
|
||||
let (y, mo, d) = civil_from_days(days);
|
||||
format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
|
||||
}
|
||||
|
||||
/// Days since the Unix epoch to a civil (year, month, day). Howard Hinnant's
|
||||
/// algorithm; avoids a chrono/time dependency for one timestamp.
|
||||
fn civil_from_days(z: i64) -> (i64, i64, i64) {
|
||||
let z = z + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z.rem_euclid(146_097);
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
(if m <= 2 { y + 1 } else { y }, m, d)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db;
|
||||
|
||||
async fn pool() -> SqlitePool {
|
||||
db::test_pool().await
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sign_and_verify_round_trip() {
|
||||
let secret = "s3cr3t";
|
||||
let body = br#"{"event_id":"abc","amount":1}"#;
|
||||
let sig = sign(secret, body);
|
||||
assert!(sig.starts_with("sha256="));
|
||||
assert_eq!(sig.len(), "sha256=".len() + 64);
|
||||
assert!(verify(secret, body, &sig));
|
||||
// A tampered body fails.
|
||||
assert!(!verify(secret, br#"{"event_id":"abc","amount":2}"#, &sig));
|
||||
// A wrong secret fails.
|
||||
assert!(!verify("other", body, &sig));
|
||||
// Garbage header fails without panicking.
|
||||
assert!(!verify(secret, body, "sha256=deadbeef"));
|
||||
assert!(!verify(secret, body, ""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_matches_a_known_vector() {
|
||||
// HMAC-SHA256("key", "The quick brown fox jumps over the lazy dog")
|
||||
// = f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8
|
||||
let sig = sign("key", b"The quick brown fox jumps over the lazy dog");
|
||||
assert_eq!(
|
||||
sig,
|
||||
"sha256=f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grin_formatting() {
|
||||
assert_eq!(nanogrin_to_grin(0), "0");
|
||||
assert_eq!(nanogrin_to_grin(1_000_000_000), "1");
|
||||
assert_eq!(nanogrin_to_grin(2_500_000_000), "2.5");
|
||||
assert_eq!(nanogrin_to_grin(1_234_567_890), "1.23456789");
|
||||
assert_eq!(nanogrin_to_grin(1), "0.000000001");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backoff_grows_and_caps() {
|
||||
assert_eq!(backoff_secs(0), 0);
|
||||
assert_eq!(backoff_secs(1), 30);
|
||||
assert_eq!(backoff_secs(2), 60);
|
||||
assert_eq!(backoff_secs(3), 120);
|
||||
assert_eq!(backoff_secs(100), BACKOFF_CAP, "must cap");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamp_is_iso8601() {
|
||||
let ts = now_iso8601();
|
||||
assert_eq!(ts.len(), 20, "YYYY-MM-DDTHH:MM:SSZ");
|
||||
assert!(ts.ends_with('Z'));
|
||||
assert!(ts.contains('T'));
|
||||
// A known epoch second: 2021-01-01T00:00:00Z = 1609459200.
|
||||
assert_eq!(civil_from_days(1_609_459_200 / 86_400), (2021, 1, 1));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enqueue_deliver_and_idempotency() {
|
||||
let pool = pool().await;
|
||||
let payload = WebhookPayload::received(
|
||||
"slate-1".into(),
|
||||
2_000_000_000,
|
||||
Some("payerhex".into()),
|
||||
Some("inv-1".into()),
|
||||
Some("order-1".into()),
|
||||
None,
|
||||
);
|
||||
let id = enqueue(&pool, "https://store.example/hook", &payload)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(id, payload.event_id);
|
||||
|
||||
// It is due immediately.
|
||||
let due_now = due(&pool, 10).await.unwrap();
|
||||
assert_eq!(due_now.len(), 1);
|
||||
assert_eq!(due_now[0].id, id);
|
||||
// The stored body verifies under the same secret.
|
||||
assert!(verify(
|
||||
"hooksecret",
|
||||
due_now[0].body.as_bytes(),
|
||||
&sign("hooksecret", due_now[0].body.as_bytes())
|
||||
));
|
||||
|
||||
// A failure reschedules it into the future (no longer due now).
|
||||
mark_failed(&pool, &id, "connection refused").await.unwrap();
|
||||
assert!(due(&pool, 10).await.unwrap().is_empty());
|
||||
|
||||
// Delivery marks it done and it never comes due again.
|
||||
mark_delivered(&pool, &id).await.unwrap();
|
||||
assert!(due(&pool, 10).await.unwrap().is_empty());
|
||||
let delivered: i64 =
|
||||
sqlx::query_scalar("SELECT delivered FROM webhook_delivery WHERE id = ?1")
|
||||
.bind(&id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(delivered, 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "gp-goblin-sender"
|
||||
description = "Milestone-2 gate helper: builds and finalizes slatepacks with Goblin's actual wallet stack"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
# Test-only tool, never deployed. It is the SENDER half of the slatepack
|
||||
# round-trip gate in gp-wallet/tests/goblin_roundtrip.rs, kept in its own
|
||||
# binary because Goblin's fork (heed / lmdb-master-sys) and upstream
|
||||
# grin-wallet (lmdb-zero / liblmdb-sys) bundle two incompatible LMDB C
|
||||
# libraries that collide when linked into one executable.
|
||||
|
||||
[dependencies]
|
||||
# Goblin's actual wallet stack (grin-wallet fork + vendored grin node
|
||||
# crates), as renamed path dependencies. Requires the goblin checkout as a
|
||||
# sibling of the GoblinPay directory.
|
||||
goblin_libwallet = { package = "grin_wallet_libwallet", path = "../../../goblin/wallet/libwallet" }
|
||||
goblin_impls = { package = "grin_wallet_impls", path = "../../../goblin/wallet/impls" }
|
||||
goblin_core = { package = "grin_core", path = "../../../goblin/node/core" }
|
||||
goblin_keychain = { package = "grin_keychain", path = "../../../goblin/node/keychain" }
|
||||
goblin_util = { package = "grin_util", path = "../../../goblin/node/util" }
|
||||
rand = "0.6"
|
||||
serde_json = { workspace = true }
|
||||
@@ -0,0 +1,464 @@
|
||||
//! Milestone-2 gate helper: the SENDER half of the slatepack round-trip,
|
||||
//! running Goblin's actual wallet stack (the grin-wallet fork vendored at
|
||||
//! goblin/wallet over grin_core 5.4.1).
|
||||
//!
|
||||
//! Two subcommands, driven by gp-wallet/tests/goblin_roundtrip.rs:
|
||||
//!
|
||||
//! gen <workdir> <amount_nanogrin> [recipient_slatepack_address]
|
||||
//! Creates a throwaway wallet from a fresh random mnemonic under
|
||||
//! <workdir>/sender-wallet, injects one spendable output (valid keys and
|
||||
//! commitment, never on chain, which offline finalization never checks),
|
||||
//! runs init_send_tx, and writes:
|
||||
//! <workdir>/s1.armor S1 slatepack (plain, or encrypted to the
|
||||
//! recipient address when one is given)
|
||||
//! <workdir>/meta.json {"slate_id": "...", "amount": N}
|
||||
//!
|
||||
//! check <workdir> <s2_file>
|
||||
//! Reopens the same wallet, parses the S2 reply, finalizes the
|
||||
//! transaction (full offline validation: sums, signatures, range
|
||||
//! proofs), asserts slate id / kernel consistency, and writes
|
||||
//! <workdir>/result.json
|
||||
//! Exits nonzero on any mismatch.
|
||||
//!
|
||||
//! Everything offline: no node, no chain, mainnet parameters. Only freshly
|
||||
//! generated random test mnemonics, never any real seed.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use rand::RngCore;
|
||||
|
||||
use goblin_core::core::{Transaction, TxKernel};
|
||||
use goblin_core::global as gglobal;
|
||||
use goblin_impls::{DefaultLCProvider, DefaultWalletImpl};
|
||||
use goblin_keychain::{ExtKeychain, Keychain};
|
||||
use goblin_libwallet::api_impl::owner as gowner;
|
||||
use goblin_libwallet::{
|
||||
InitTxArgs, NodeVersionInfo, OutputData, OutputStatus, Slate, SlateState, SlatepackAddress,
|
||||
WalletInst,
|
||||
};
|
||||
use goblin_util::secp::key::SecretKey;
|
||||
use goblin_util::secp::pedersen;
|
||||
use goblin_util::Mutex;
|
||||
use goblin_util::ZeroingString;
|
||||
|
||||
const TIP_HEIGHT: u64 = 10;
|
||||
const PASSWORD: &str = "gate-sender-pw";
|
||||
|
||||
type Error = Box<dyn std::error::Error>;
|
||||
type Provider = DefaultLCProvider<StubNode, ExtKeychain>;
|
||||
type WalletBox = Box<dyn WalletInst<'static, Provider, StubNode, ExtKeychain>>;
|
||||
type Instance = Arc<Mutex<WalletBox>>;
|
||||
|
||||
/// Offline stand-in for a Grin node: the send path only ever asks for the
|
||||
/// chain tip. Everything else is unreachable here.
|
||||
#[derive(Clone)]
|
||||
struct StubNode;
|
||||
|
||||
fn offline<T>(what: &str) -> Result<T, goblin_libwallet::Error> {
|
||||
Err(goblin_libwallet::Error::ClientCallback(format!(
|
||||
"offline gate stub: {what}"
|
||||
)))
|
||||
}
|
||||
|
||||
impl goblin_libwallet::NodeClient for StubNode {
|
||||
fn node_url(&self) -> &str {
|
||||
"http://127.0.0.1:13413"
|
||||
}
|
||||
fn set_node_url(&mut self, _: &str) {}
|
||||
fn node_api_secret(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
fn set_node_api_secret(&mut self, _: Option<String>) {}
|
||||
fn post_tx(&self, _: &Transaction, _: bool) -> Result<(), goblin_libwallet::Error> {
|
||||
offline("post_tx")
|
||||
}
|
||||
fn get_version_info(&mut self) -> Option<NodeVersionInfo> {
|
||||
None
|
||||
}
|
||||
fn get_chain_tip(&self) -> Result<(u64, String), goblin_libwallet::Error> {
|
||||
Ok((TIP_HEIGHT, "0".repeat(64)))
|
||||
}
|
||||
fn get_kernel(
|
||||
&mut self,
|
||||
_: &pedersen::Commitment,
|
||||
_: Option<u64>,
|
||||
_: Option<u64>,
|
||||
) -> Result<Option<(TxKernel, u64, u64)>, goblin_libwallet::Error> {
|
||||
offline("get_kernel")
|
||||
}
|
||||
fn get_outputs_from_node(
|
||||
&self,
|
||||
wallet_outputs: Vec<pedersen::Commitment>,
|
||||
) -> Result<HashMap<pedersen::Commitment, (String, u64, u64)>, goblin_libwallet::Error> {
|
||||
// Goblin's fork refreshes outputs from the node before selecting
|
||||
// inputs (updater::refresh_outputs inside add_inputs_to_slate, a
|
||||
// deviation from upstream). Report every wallet output as unspent
|
||||
// on chain so the injected input stays spendable.
|
||||
Ok(wallet_outputs
|
||||
.into_iter()
|
||||
.map(|c| {
|
||||
let hex: String = c.0.iter().map(|b| format!("{b:02x}")).collect();
|
||||
(c, (hex, 1, 1))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
fn get_outputs_by_pmmr_index(
|
||||
&self,
|
||||
_: u64,
|
||||
_: Option<u64>,
|
||||
_: u64,
|
||||
) -> Result<
|
||||
(
|
||||
u64,
|
||||
u64,
|
||||
Vec<(pedersen::Commitment, pedersen::RangeProof, bool, u64, u64)>,
|
||||
),
|
||||
goblin_libwallet::Error,
|
||||
> {
|
||||
offline("get_outputs_by_pmmr_index")
|
||||
}
|
||||
fn height_range_to_pmmr_indices(
|
||||
&self,
|
||||
_: u64,
|
||||
_: Option<u64>,
|
||||
) -> Result<(u64, u64), goblin_libwallet::Error> {
|
||||
offline("height_range_to_pmmr_indices")
|
||||
}
|
||||
}
|
||||
|
||||
struct Sender {
|
||||
instance: Instance,
|
||||
mask: Option<SecretKey>,
|
||||
}
|
||||
|
||||
impl Sender {
|
||||
/// Create a fresh wallet from a random mnemonic (gen phase).
|
||||
fn create(dir: &Path) -> Result<Sender, Error> {
|
||||
let mut entropy = [0u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut entropy);
|
||||
let mnemonic = goblin_keychain::mnemonic::from_entropy(&entropy)
|
||||
.map_err(|e| format!("mnemonic generation failed: {e:?}"))?;
|
||||
Self::open(dir, Some(mnemonic))
|
||||
}
|
||||
|
||||
/// Open the wallet, creating it first when a mnemonic is given.
|
||||
fn open(dir: &Path, create_mnemonic: Option<String>) -> Result<Sender, Error> {
|
||||
let mut wallet = Box::new(DefaultWalletImpl::<StubNode>::new(StubNode)?) as WalletBox;
|
||||
let mask = {
|
||||
let lc = wallet.lc_provider()?;
|
||||
lc.set_top_level_directory(
|
||||
dir.to_str()
|
||||
.ok_or_else(|| format!("non-UTF8 dir {dir:?}"))?,
|
||||
)?;
|
||||
if let Some(mnemonic) = create_mnemonic {
|
||||
lc.create_wallet(
|
||||
None,
|
||||
Some(ZeroingString::from(mnemonic)),
|
||||
32,
|
||||
ZeroingString::from(PASSWORD),
|
||||
false,
|
||||
)?;
|
||||
}
|
||||
lc.open_wallet(None, ZeroingString::from(PASSWORD), true, false)?
|
||||
};
|
||||
Ok(Sender {
|
||||
instance: Arc::new(Mutex::new(wallet)),
|
||||
mask,
|
||||
})
|
||||
}
|
||||
|
||||
/// Give the wallet one ordinary spendable output so init_send_tx has
|
||||
/// coins to select. Valid keys and commitment, never on chain.
|
||||
fn inject_funds(&self, value: u64) -> Result<(), Error> {
|
||||
let mut w_lock = self.instance.lock();
|
||||
let lc = w_lock.lc_provider()?;
|
||||
let w = lc.wallet_inst()?;
|
||||
let parent = w.parent_key_id();
|
||||
let key_id = w.next_child(self.mask.as_ref())?;
|
||||
let n_child = u32::from(key_id.to_path().path[2]);
|
||||
let mut batch = w.batch(self.mask.as_ref())?;
|
||||
batch.save(OutputData {
|
||||
root_key_id: parent.clone(),
|
||||
key_id,
|
||||
n_child,
|
||||
commit: None,
|
||||
mmr_index: None,
|
||||
value,
|
||||
status: OutputStatus::Unspent,
|
||||
height: 1,
|
||||
lock_height: 0,
|
||||
is_coinbase: false,
|
||||
tx_log_entry: None,
|
||||
})?;
|
||||
batch.save_last_confirmed_height(&parent, TIP_HEIGHT)?;
|
||||
batch.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn init_send(
|
||||
&self,
|
||||
amount: u64,
|
||||
proof_recipient: Option<SlatepackAddress>,
|
||||
) -> Result<Slate, Error> {
|
||||
let mut w_lock = self.instance.lock();
|
||||
let lc = w_lock.lc_provider()?;
|
||||
let w = lc.wallet_inst()?;
|
||||
let args = InitTxArgs {
|
||||
amount,
|
||||
minimum_confirmations: 1,
|
||||
max_outputs: 500,
|
||||
num_change_outputs: 1,
|
||||
selection_strategy_is_use_all: false,
|
||||
// When set, init_send_tx puts a PaymentInfo on the slate (our
|
||||
// sender address + this recipient address, no receiver signature
|
||||
// yet), which is exactly the payment-proof request the receiver
|
||||
// fills in during receive_tx.
|
||||
payment_proof_recipient_address: proof_recipient,
|
||||
..Default::default()
|
||||
};
|
||||
let slate = gowner::init_send_tx(w, self.mask.as_ref(), args, false)?;
|
||||
// Lock before transmitting S1, exactly like Goblin does
|
||||
// (goblin/src/wallet/wallet.rs calls api.tx_lock_outputs right after
|
||||
// init_send_tx). Locking also records the change output in the
|
||||
// wallet DB; finalize's repopulate_tx silently drops the change
|
||||
// output when it is missing, which breaks the kernel sums.
|
||||
gowner::tx_lock_outputs(w, self.mask.as_ref(), &slate)?;
|
||||
Ok(slate)
|
||||
}
|
||||
|
||||
fn armor(&self, slate: &Slate, recipients: Vec<SlatepackAddress>) -> Result<String, Error> {
|
||||
Ok(gowner::create_slatepack_message(
|
||||
self.instance.clone(),
|
||||
self.mask.as_ref(),
|
||||
slate,
|
||||
Some(0),
|
||||
recipients,
|
||||
)?)
|
||||
}
|
||||
|
||||
fn parse_s2(&self, armor: &str) -> Result<Slate, Error> {
|
||||
Ok(gowner::slate_from_slatepack_message(
|
||||
self.instance.clone(),
|
||||
self.mask.as_ref(),
|
||||
armor.trim().to_string(),
|
||||
vec![],
|
||||
)?)
|
||||
}
|
||||
|
||||
fn finalize(&self, slate: &Slate) -> Result<Slate, Error> {
|
||||
let mut w_lock = self.instance.lock();
|
||||
let lc = w_lock.lc_provider()?;
|
||||
let w = lc.wallet_inst()?;
|
||||
Ok(gowner::finalize_tx(w, self.mask.as_ref(), slate)?)
|
||||
}
|
||||
|
||||
fn calc_excess(&self, slate: &Slate) -> Result<pedersen::Commitment, Error> {
|
||||
let mut w_lock = self.instance.lock();
|
||||
let lc = w_lock.lc_provider()?;
|
||||
let w = lc.wallet_inst()?;
|
||||
let keychain = w.keychain(self.mask.as_ref())?;
|
||||
Ok(slate.calc_excess(keychain.secp())?)
|
||||
}
|
||||
}
|
||||
|
||||
fn wallet_dir(workdir: &Path) -> std::path::PathBuf {
|
||||
workdir.join("sender-wallet")
|
||||
}
|
||||
|
||||
fn cmd_gen(workdir: &Path, amount: u64, recipient: Option<&str>) -> Result<(), Error> {
|
||||
let dir = wallet_dir(workdir);
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
|
||||
let sender = Sender::create(&dir)?;
|
||||
// Amount plus generous room for the fee, in one output.
|
||||
sender.inject_funds(amount + 1_000_000_000)?;
|
||||
|
||||
let slate = sender.init_send(amount, None)?;
|
||||
if slate.state != SlateState::Standard1 {
|
||||
return Err(format!("expected S1 out of init_send_tx, got {:?}", slate.state).into());
|
||||
}
|
||||
|
||||
let recipients = match recipient {
|
||||
Some(addr) => vec![SlatepackAddress::try_from(addr)
|
||||
.map_err(|e| format!("recipient address `{addr}` rejected: {e}"))?],
|
||||
None => vec![],
|
||||
};
|
||||
let armor = sender.armor(&slate, recipients)?;
|
||||
|
||||
std::fs::write(workdir.join("s1.armor"), &armor)?;
|
||||
let meta = serde_json::json!({
|
||||
"slate_id": slate.id.to_string(),
|
||||
"amount": amount,
|
||||
});
|
||||
std::fs::write(workdir.join("meta.json"), meta.to_string())?;
|
||||
println!("{meta}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Like `gen`, but the S1 REQUESTS a payment proof to `recipient` (the
|
||||
/// receiver's slatepack address). Exercises gp-wallet's receiver-side proof
|
||||
/// path. Armor is plain (proof and armor encryption are orthogonal).
|
||||
fn cmd_genproof(workdir: &Path, amount: u64, recipient: &str) -> Result<(), Error> {
|
||||
let dir = wallet_dir(workdir);
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
|
||||
let proof_addr = SlatepackAddress::try_from(recipient)
|
||||
.map_err(|e| format!("proof recipient address `{recipient}` rejected: {e}"))?;
|
||||
|
||||
let sender = Sender::create(&dir)?;
|
||||
sender.inject_funds(amount + 1_000_000_000)?;
|
||||
|
||||
let slate = sender.init_send(amount, Some(proof_addr))?;
|
||||
if slate.state != SlateState::Standard1 {
|
||||
return Err(format!("expected S1 out of init_send_tx, got {:?}", slate.state).into());
|
||||
}
|
||||
if slate.payment_proof.is_none() {
|
||||
return Err("init_send_tx did not attach a payment-proof request".into());
|
||||
}
|
||||
let armor = sender.armor(&slate, vec![])?;
|
||||
|
||||
std::fs::write(workdir.join("s1.armor"), &armor)?;
|
||||
let meta = serde_json::json!({
|
||||
"slate_id": slate.id.to_string(),
|
||||
"amount": amount,
|
||||
"proof_recipient": recipient,
|
||||
});
|
||||
std::fs::write(workdir.join("meta.json"), meta.to_string())?;
|
||||
println!("{meta}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_check(workdir: &Path, s2_file: &Path) -> Result<(), Error> {
|
||||
let meta: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(workdir.join("meta.json"))?)?;
|
||||
let slate_id = meta["slate_id"]
|
||||
.as_str()
|
||||
.ok_or("meta.json missing slate_id")?
|
||||
.to_string();
|
||||
|
||||
let sender = Sender::open(&wallet_dir(workdir), None)?;
|
||||
let s2 = sender.parse_s2(&std::fs::read_to_string(s2_file)?)?;
|
||||
|
||||
if s2.id.to_string() != slate_id {
|
||||
return Err(format!("S2 slate id {} != sent {}", s2.id, slate_id).into());
|
||||
}
|
||||
if s2.state != SlateState::Standard2 {
|
||||
return Err(format!("expected S2, got {:?}", s2.state).into());
|
||||
}
|
||||
// Compact slates: only the receiver's participant entry travels back in
|
||||
// S2; the sender's own entry is restored from the stored context during
|
||||
// finalize.
|
||||
if s2.participant_data.len() != 1 {
|
||||
return Err(format!("S2 has {} participants, want 1", s2.participant_data.len()).into());
|
||||
}
|
||||
|
||||
// The real crypto gate: finalizing validates the receiver's output,
|
||||
// range proof, and partial signature against consensus rules, offline.
|
||||
let final_slate = sender.finalize(&s2)?;
|
||||
if final_slate.state != SlateState::Standard3 {
|
||||
return Err(format!("expected S3 after finalize, got {:?}", final_slate.state).into());
|
||||
}
|
||||
let tx = final_slate
|
||||
.tx
|
||||
.clone()
|
||||
.ok_or("final slate carries no transaction")?;
|
||||
if tx.kernels().len() != 1 || tx.inputs().len() != 1 || tx.outputs().len() != 2 {
|
||||
return Err(format!(
|
||||
"unexpected tx shape: {} kernels, {} inputs, {} outputs",
|
||||
tx.kernels().len(),
|
||||
tx.inputs().len(),
|
||||
tx.outputs().len()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let kernel = &tx.kernels()[0];
|
||||
kernel
|
||||
.verify()
|
||||
.map_err(|e| format!("kernel signature invalid: {e}"))?;
|
||||
let excess = sender.calc_excess(&final_slate)?;
|
||||
if kernel.excess != excess {
|
||||
return Err("kernel excess inconsistent with slate".into());
|
||||
}
|
||||
|
||||
let excess_hex: String = excess.0.iter().map(|b| format!("{b:02x}")).collect();
|
||||
let result = serde_json::json!({
|
||||
"slate_id": final_slate.id.to_string(),
|
||||
"state": "Standard3",
|
||||
"kernel_verified": true,
|
||||
"kernel_excess": excess_hex,
|
||||
"kernels": 1,
|
||||
"inputs": 1,
|
||||
"outputs": 2,
|
||||
});
|
||||
std::fs::write(workdir.join("result.json"), result.to_string())?;
|
||||
println!("{result}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Harness self-test: the fork wallet receives its own S1 (self-spend) and
|
||||
/// finalizes, without gp-wallet involved. Proves the injected-funds harness
|
||||
/// is sound independently of any cross-stack question.
|
||||
fn cmd_selfcheck(workdir: &Path) -> Result<(), Error> {
|
||||
use goblin_libwallet::api_impl::foreign as gforeign;
|
||||
|
||||
let dir = wallet_dir(workdir);
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let sender = Sender::create(&dir)?;
|
||||
let amount = 2_000_000_000u64;
|
||||
sender.inject_funds(amount + 1_000_000_000)?;
|
||||
|
||||
let s1 = sender.init_send(amount, None)?;
|
||||
let s1_armor = sender.armor(&s1, vec![])?;
|
||||
|
||||
// Receive with the same fork stack (self-spend), through the armor.
|
||||
let parsed = sender.parse_s2(&s1_armor)?; // generic slatepack parse
|
||||
let s2 = {
|
||||
let mut w_lock = sender.instance.lock();
|
||||
let lc = w_lock.lc_provider()?;
|
||||
let w = lc.wallet_inst()?;
|
||||
gforeign::receive_tx(w, sender.mask.as_ref(), &parsed, None, false)?
|
||||
};
|
||||
let s2_armor = sender.armor(&s2, vec![])?;
|
||||
|
||||
let s2_back = sender.parse_s2(&s2_armor)?;
|
||||
let final_slate = sender.finalize(&s2_back)?;
|
||||
if final_slate.state != SlateState::Standard3 {
|
||||
return Err(format!("selfcheck: expected S3, got {:?}", final_slate.state).into());
|
||||
}
|
||||
println!("selfcheck ok: {} {:?}", final_slate.id, final_slate.state);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run() -> Result<(), Error> {
|
||||
gglobal::init_global_chain_type(gglobal::ChainTypes::Mainnet);
|
||||
gglobal::set_local_chain_type(gglobal::ChainTypes::Mainnet);
|
||||
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
match args.get(1).map(String::as_str) {
|
||||
Some("gen") if args.len() == 4 || args.len() == 5 => {
|
||||
let amount: u64 = args[3].parse()?;
|
||||
cmd_gen(Path::new(&args[2]), amount, args.get(4).map(String::as_str))
|
||||
}
|
||||
Some("genproof") if args.len() == 5 => {
|
||||
let amount: u64 = args[3].parse()?;
|
||||
cmd_genproof(Path::new(&args[2]), amount, &args[4])
|
||||
}
|
||||
Some("check") if args.len() == 4 => cmd_check(Path::new(&args[2]), Path::new(&args[3])),
|
||||
Some("selfcheck") if args.len() == 3 => cmd_selfcheck(Path::new(&args[2])),
|
||||
_ => Err(
|
||||
"usage: gp-goblin-sender gen <workdir> <amount_nanogrin> [recipient] \
|
||||
| genproof <workdir> <amount_nanogrin> <recipient> \
|
||||
| check <workdir> <s2_file> | selfcheck <workdir>"
|
||||
.into(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
if let Err(e) = run() {
|
||||
eprintln!("gp-goblin-sender: {e} ({e:?})");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
[package]
|
||||
name = "gp-nostr"
|
||||
description = "Nostr transport and secure handoff for GoblinPay (identity, gift wrap, ingest, Nym)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
gp-core = { path = "../gp-core" }
|
||||
|
||||
# Nostr: the same SDK line Goblin ships (relay pool, NIP-44 v2, NIP-49
|
||||
# ncryptsec, NIP-59 gift wrap). Deliberately NO `nip06` feature: the identity
|
||||
# is a random standalone nsec, never derived from any mnemonic (two-secrets
|
||||
# rule).
|
||||
nostr-sdk = { version = "0.44", features = ["nip44", "nip49", "nip59"] }
|
||||
nostr-relay-pool = "0.44"
|
||||
async-wsocket = "0.13"
|
||||
tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] }
|
||||
|
||||
# NIP-44 v3 (the companion crate, M0). Path dep on the local checkout, PINNED:
|
||||
# working tree is on branch `v3` at rev e3dfa5e ("Document the v3 API in the
|
||||
# README"). It provides encrypt_v3/decrypt_v3 with the authenticated
|
||||
# kind/scope context binding the NIP-17 extension rides on; v2 stays on
|
||||
# nostr-sdk. Do not float the checkout without re-running the wrap tests.
|
||||
nip44 = { path = "../../../nip44" }
|
||||
# The nip44 crate speaks secp256k1 0.31 types (nostr-sdk is on 0.29; the two
|
||||
# versions coexist, conversion goes through raw bytes). `global-context` gives
|
||||
# the shared `SECP256K1` context and `hashes` the SHA-256 used to sign the
|
||||
# server receipt (BIP-340 Schnorr over the receipt digest, same key as the
|
||||
# Nostr identity).
|
||||
secp256k1 = { version = "0.31", features = ["global-context", "hashes"] }
|
||||
|
||||
# Nym mixnet, linked IN-PROCESS via smolmix (TCP/UDP tunnel over the mixnet
|
||||
# with an AUTO-SELECTED IPR exit; no sidecar, no SOCKS5 loopback, no
|
||||
# single-exit SPOF). Path dep into the local nym checkout, PINNED at rev
|
||||
# f6ed17d949cc19fee0fb51db3cb65771fd510d5b ("http-api-client: preconfigured
|
||||
# webpki roots on Android" — the Android patch is irrelevant server-side, but
|
||||
# the pin is what Goblin G14 validated; do not float it silently).
|
||||
smolmix = { path = "../../../nym/smolmix/core" }
|
||||
# mix-dns wire codec. Already in the dependency graph via nym-http-api-client
|
||||
# (smolmix -> nym-sdk), so we reuse it instead of vendoring a DNS
|
||||
# encode/parse (same justification as Goblin).
|
||||
hickory-proto = { version = "0.26", default-features = false, features = ["std"] }
|
||||
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "time", "macros", "sync"] }
|
||||
log = "0.4"
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
# mix-dns transaction ids.
|
||||
rand = "0.9"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
||||
@@ -0,0 +1,309 @@
|
||||
//! The server's Nostr payment identity: a random standalone nsec or an
|
||||
//! imported one, NEVER derived from the Grin mnemonic (the two-secrets rule:
|
||||
//! the mnemonic is the money secret, the nsec is the payment identity; losing
|
||||
//! one must never compromise or resurrect the other). Mirrors Goblin's
|
||||
//! `nostr/identity.rs`, trimmed to what a headless daemon needs.
|
||||
//!
|
||||
//! Resolution order (see [`load_or_create`]):
|
||||
//! 1. `GP_NSEC` — plaintext key from the environment (mounted-file variant
|
||||
//! supported by gp-core). Used as-is, never persisted.
|
||||
//! 2. `GP_NCRYPTSEC` — NIP-49 encrypted key, unlocked with the wallet
|
||||
//! password. Never persisted.
|
||||
//! 3. Neither set — load `<data_dir>/nostr/identity.json`, or generate a
|
||||
//! fresh RANDOM key and persist it NIP-49 encrypted (wallet password),
|
||||
//! file mode 0600.
|
||||
|
||||
use std::fmt;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use gp_core::config::Config;
|
||||
use nostr_sdk::nips::nip49::{EncryptedSecretKey, KeySecurity};
|
||||
use nostr_sdk::{FromBech32, Keys, SecretKey, ToBech32};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// NIP-49 scrypt work factor (~64 MiB, interactive-grade; same as Goblin).
|
||||
const NCRYPTSEC_LOG_N: u8 = 16;
|
||||
|
||||
/// Identity file stored at `<data_dir>/nostr/identity.json`. Only the
|
||||
/// encrypted key and the public key: a headless till has no NIP-05 name, no
|
||||
/// contact metadata.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct ServerIdentity {
|
||||
pub ver: u8,
|
||||
/// NIP-49 encrypted secret key (bech32 ncryptsec).
|
||||
pub ncryptsec: String,
|
||||
/// Public key, bech32 npub (plaintext for logs and the QR).
|
||||
pub npub: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum IdentityError {
|
||||
/// Missing or inconsistent configuration (fail fast at startup).
|
||||
Config(String),
|
||||
/// Key parse/encrypt/decrypt failure (includes wrong password).
|
||||
Key(String),
|
||||
/// Filesystem failure persisting or reading the identity file.
|
||||
Io(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for IdentityError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
IdentityError::Config(m) => write!(f, "identity config error: {m}"),
|
||||
IdentityError::Key(m) => write!(f, "identity key error: {m}"),
|
||||
IdentityError::Io(m) => write!(f, "identity io error: {m}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for IdentityError {}
|
||||
|
||||
impl ServerIdentity {
|
||||
pub const FILE_NAME: &'static str = "identity.json";
|
||||
|
||||
/// Identity file path for a data dir.
|
||||
pub fn path(data_dir: &Path) -> PathBuf {
|
||||
data_dir.join("nostr").join(Self::FILE_NAME)
|
||||
}
|
||||
|
||||
/// Load the identity file if it exists and parses.
|
||||
pub fn load(data_dir: &Path) -> Option<ServerIdentity> {
|
||||
let raw = fs::read_to_string(Self::path(data_dir)).ok()?;
|
||||
serde_json::from_str(&raw).ok()
|
||||
}
|
||||
|
||||
/// Persist with owner-only permissions (the ncryptsec blob must not be
|
||||
/// world readable: a local attacker could grind the password offline).
|
||||
pub fn save(&self, data_dir: &Path) -> Result<(), IdentityError> {
|
||||
let dir = data_dir.join("nostr");
|
||||
fs::create_dir_all(&dir).map_err(|e| IdentityError::Io(format!("create {dir:?}: {e}")))?;
|
||||
restrict(&dir, 0o700)?;
|
||||
let raw = serde_json::to_string_pretty(self)
|
||||
.map_err(|e| IdentityError::Io(format!("serialize identity: {e}")))?;
|
||||
let path = Self::path(data_dir);
|
||||
fs::write(&path, raw).map_err(|e| IdentityError::Io(format!("write {path:?}: {e}")))?;
|
||||
restrict(&path, 0o600)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Unlock the stored key with the wallet password.
|
||||
pub fn unlock(&self, password: &str) -> Result<Keys, IdentityError> {
|
||||
decrypt_ncryptsec(&self.ncryptsec, password)
|
||||
}
|
||||
|
||||
fn from_keys(keys: &Keys, password: &str) -> Result<ServerIdentity, IdentityError> {
|
||||
let encrypted = EncryptedSecretKey::new(
|
||||
keys.secret_key(),
|
||||
password,
|
||||
NCRYPTSEC_LOG_N,
|
||||
KeySecurity::Medium,
|
||||
)
|
||||
.map_err(|e| IdentityError::Key(format!("encrypt failed: {e}")))?;
|
||||
Ok(ServerIdentity {
|
||||
ver: 1,
|
||||
ncryptsec: encrypted
|
||||
.to_bech32()
|
||||
.map_err(|e| IdentityError::Key(format!("bech32 failed: {e}")))?,
|
||||
npub: keys
|
||||
.public_key()
|
||||
.to_bech32()
|
||||
.map_err(|e| IdentityError::Key(format!("bech32 failed: {e}")))?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the identity keys from the configuration (see the module doc for
|
||||
/// the order). Fails fast on a missing wallet password whenever the at-rest
|
||||
/// encryption needs one.
|
||||
pub fn load_or_create(cfg: &Config) -> Result<Keys, IdentityError> {
|
||||
// 1. Plaintext nsec from the environment: authoritative, not persisted.
|
||||
if let Some(nsec) = &cfg.nsec {
|
||||
let secret = SecretKey::parse(nsec.reveal().trim())
|
||||
.map_err(|e| IdentityError::Key(format!("invalid GP_NSEC: {e}")))?;
|
||||
return Ok(Keys::new(secret));
|
||||
}
|
||||
|
||||
let password = cfg
|
||||
.wallet_password
|
||||
.as_ref()
|
||||
.ok_or_else(|| {
|
||||
IdentityError::Config(
|
||||
"GP_WALLET_PASSWORD (or _FILE) is required to unlock or persist the \
|
||||
Nostr identity (set GP_NSEC to bypass at-rest encryption)"
|
||||
.into(),
|
||||
)
|
||||
})?
|
||||
.reveal()
|
||||
.to_string();
|
||||
|
||||
// 2. NIP-49 encrypted key from the environment: unlocked, not persisted.
|
||||
if let Some(ncryptsec) = &cfg.ncryptsec {
|
||||
return decrypt_ncryptsec(ncryptsec.reveal().trim(), &password);
|
||||
}
|
||||
|
||||
// 3. Persisted identity, or a fresh RANDOM key (never seed-derived).
|
||||
let data_dir = Path::new(&cfg.data_dir);
|
||||
if let Some(identity) = ServerIdentity::load(data_dir) {
|
||||
return identity.unlock(&password);
|
||||
}
|
||||
let keys = Keys::generate();
|
||||
ServerIdentity::from_keys(&keys, &password)?.save(data_dir)?;
|
||||
Ok(keys)
|
||||
}
|
||||
|
||||
fn decrypt_ncryptsec(ncryptsec: &str, password: &str) -> Result<Keys, IdentityError> {
|
||||
let encrypted = EncryptedSecretKey::from_bech32(ncryptsec)
|
||||
.map_err(|e| IdentityError::Key(format!("invalid ncryptsec: {e}")))?;
|
||||
let secret = encrypted
|
||||
.decrypt(password)
|
||||
.map_err(|_| IdentityError::Key("wrong password for ncryptsec".into()))?;
|
||||
Ok(Keys::new(secret))
|
||||
}
|
||||
|
||||
/// chmod, failing fast (Unix only; the daemon targets Linux servers).
|
||||
fn restrict(path: &Path, mode: u32) -> Result<(), IdentityError> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(mode))
|
||||
.map_err(|e| IdentityError::Io(format!("chmod {path:?}: {e}")))
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = (path, mode);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
use gp_core::config::Secret;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Self-cleaning unique temp dir (no extra dev-deps).
|
||||
struct TempDir(PathBuf);
|
||||
|
||||
impl TempDir {
|
||||
fn new(tag: &str) -> TempDir {
|
||||
static N: AtomicU32 = AtomicU32::new(0);
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"gp-nostr-id-{tag}-{}-{}",
|
||||
std::process::id(),
|
||||
N.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
fs::create_dir_all(&path).unwrap();
|
||||
TempDir(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn cfg(dir: &TempDir) -> Config {
|
||||
Config {
|
||||
data_dir: dir.0.to_str().unwrap().to_string(),
|
||||
wallet_password: Some(Secret::new("hunter2".into())),
|
||||
..Config::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generates_persists_and_reloads_the_same_key() {
|
||||
let dir = TempDir::new("gen");
|
||||
let cfg = cfg(&dir);
|
||||
let first = load_or_create(&cfg).unwrap();
|
||||
let second = load_or_create(&cfg).unwrap();
|
||||
assert_eq!(first.public_key(), second.public_key());
|
||||
|
||||
// Encrypted at rest: no bech32 nsec in the file.
|
||||
let raw = fs::read_to_string(ServerIdentity::path(&dir.0)).unwrap();
|
||||
let nsec = first.secret_key().to_bech32().unwrap();
|
||||
assert!(!raw.contains(&nsec), "identity file leaks the nsec");
|
||||
assert!(raw.contains("ncryptsec1"), "key must be NIP-49 encrypted");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn identity_file_is_owner_only() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = TempDir::new("perm");
|
||||
load_or_create(&cfg(&dir)).unwrap();
|
||||
let meta = fs::metadata(ServerIdentity::path(&dir.0)).unwrap();
|
||||
assert_eq!(
|
||||
meta.permissions().mode() & 0o077,
|
||||
0,
|
||||
"identity.json must be 0600"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_password_fails_and_never_regenerates() {
|
||||
let dir = TempDir::new("wrongpw");
|
||||
let mut c = cfg(&dir);
|
||||
let keys = load_or_create(&c).unwrap();
|
||||
c.wallet_password = Some(Secret::new("not-it".into()));
|
||||
// A wrong password must be a hard error, not a silent fresh identity
|
||||
// (payers hold the old npub; regenerating would strand their sends).
|
||||
assert!(load_or_create(&c).is_err());
|
||||
c.wallet_password = Some(Secret::new("hunter2".into()));
|
||||
assert_eq!(load_or_create(&c).unwrap().public_key(), keys.public_key());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_nsec_without_persisting() {
|
||||
let dir = TempDir::new("nsec");
|
||||
let external = Keys::generate();
|
||||
let mut c = cfg(&dir);
|
||||
c.nsec = Some(Secret::new(external.secret_key().to_bech32().unwrap()));
|
||||
c.wallet_password = None; // not needed on this path
|
||||
let keys = load_or_create(&c).unwrap();
|
||||
assert_eq!(keys.public_key(), external.public_key());
|
||||
assert!(
|
||||
!ServerIdentity::path(&dir.0).exists(),
|
||||
"env-provided keys must not be written to disk"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_ncryptsec_from_env() {
|
||||
let dir = TempDir::new("ncryptsec");
|
||||
let external = Keys::generate();
|
||||
let encrypted = EncryptedSecretKey::new(
|
||||
external.secret_key(),
|
||||
"hunter2",
|
||||
NCRYPTSEC_LOG_N,
|
||||
KeySecurity::Medium,
|
||||
)
|
||||
.unwrap();
|
||||
let mut c = cfg(&dir);
|
||||
c.ncryptsec = Some(Secret::new(encrypted.to_bech32().unwrap()));
|
||||
let keys = load_or_create(&c).unwrap();
|
||||
assert_eq!(keys.public_key(), external.public_key());
|
||||
assert!(!ServerIdentity::path(&dir.0).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_password_fails_fast() {
|
||||
let dir = TempDir::new("nopw");
|
||||
let mut c = cfg(&dir);
|
||||
c.wallet_password = None;
|
||||
let err = load_or_create(&c).unwrap_err();
|
||||
assert!(err.to_string().contains("GP_WALLET_PASSWORD"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_identities_are_independent() {
|
||||
// Fresh entropy every time — nothing chains identities to each other
|
||||
// (or to any wallet seed; there is no derivation path at all).
|
||||
let a = load_or_create(&cfg(&TempDir::new("ind-a"))).unwrap();
|
||||
let b = load_or_create(&cfg(&TempDir::new("ind-b"))).unwrap();
|
||||
assert_ne!(a.public_key(), b.public_key());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,692 @@
|
||||
//! The guarded ingest pipeline: what to do with an incoming gift wrap.
|
||||
//! Mirrors the shape of `goblin/src/nostr/ingest.rs` (a pure, unit-tested
|
||||
//! `decide()` plus dedupe and rate limiting around it), simplified for a
|
||||
//! receive-only payment server:
|
||||
//!
|
||||
//! - The accept policy is fixed to **auto-receive everyone** — a public till
|
||||
//! takes payments from strangers by design.
|
||||
//! - Only Standard1 sends are processed, and that invariant is enforced by
|
||||
//! the WALLET (`gp_wallet::receive_slatepack` rejects everything else), so
|
||||
//! the policy here reasons about the message, not slate internals.
|
||||
//! - There is no finalize/post path, no payment requests, no contacts: a
|
||||
//! reply-to-us (S2) or an invoice would target a sender wallet we do not
|
||||
//! have. They drop.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use log::{info, warn};
|
||||
use nostr_sdk::{Event, EventBuilder, Keys, Kind, PublicKey, Tag, UnsignedEvent};
|
||||
|
||||
use crate::{
|
||||
protocol, unix_time, IncomingContext, KeyDirectory, MasterDirectory, ReceiveError,
|
||||
SlatepackReceiver,
|
||||
};
|
||||
|
||||
/// Rate limit for incoming wraps per sender (events/hour). A payment server
|
||||
/// has no contact book, so everyone gets Goblin's unknown-sender budget.
|
||||
const RATE_PER_SENDER_PER_HOUR: usize = 10;
|
||||
/// Global ceiling on gift-wrap decrypt attempts per minute across ALL
|
||||
/// senders (Goblin's fresh-keypair-spam bound: the per-sender limit only
|
||||
/// applies after the expensive decrypt reveals the sender).
|
||||
const GLOBAL_UNWRAP_PER_MIN: usize = 120;
|
||||
/// Cap on remembered rate-limiter senders before pruning.
|
||||
const RATE_MAP_CAP: usize = 10_000;
|
||||
|
||||
/// What the pipeline should do with a validated incoming message.
|
||||
/// Pure policy — unit tested, no side effects.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum IngestDecision {
|
||||
/// A fresh payment message: receive it and reply S2 automatically.
|
||||
AutoReceive,
|
||||
/// Drop silently (reason for logging only).
|
||||
Drop(&'static str),
|
||||
}
|
||||
|
||||
/// Inputs for the policy decision.
|
||||
pub struct IngestContext<'a> {
|
||||
/// Seal-verified sender public key, hex.
|
||||
pub sender: &'a str,
|
||||
/// The sender is ourselves (wrap-to-self copy).
|
||||
pub is_self: bool,
|
||||
/// The rumor is a kind 14 DM within the size cap.
|
||||
pub rumor_is_dm: bool,
|
||||
/// The rumor content carries exactly one slatepack armor block.
|
||||
pub has_slatepack: bool,
|
||||
/// This wrap/rumor was already processed.
|
||||
pub duplicate: bool,
|
||||
}
|
||||
|
||||
/// Pure policy function (auto-receive everyone, mirroring Goblin's shape).
|
||||
pub fn decide(ctx: &IngestContext) -> IngestDecision {
|
||||
if ctx.duplicate {
|
||||
return IngestDecision::Drop("already processed");
|
||||
}
|
||||
if ctx.is_self {
|
||||
return IngestDecision::Drop("own message");
|
||||
}
|
||||
if !ctx.rumor_is_dm {
|
||||
return IngestDecision::Drop("not a kind 14 DM");
|
||||
}
|
||||
if !ctx.has_slatepack {
|
||||
return IngestDecision::Drop("no slatepack payload");
|
||||
}
|
||||
IngestDecision::AutoReceive
|
||||
}
|
||||
|
||||
/// A reply ready to be encrypted and dispatched: the identity it is sent FROM
|
||||
/// (the master key, or the derived child the payer addressed), the payer, and
|
||||
/// the unsigned kind-14 rumor carrying the S2 armor. Version choice + gift
|
||||
/// wrapping happen at the send site (they depend on the payer's advertised
|
||||
/// 10050).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PendingReply {
|
||||
pub from: Keys,
|
||||
pub payer: PublicKey,
|
||||
pub rumor: UnsignedEvent,
|
||||
}
|
||||
|
||||
/// Outcome of handling one gift wrap event.
|
||||
#[derive(Debug)]
|
||||
pub enum IngestOutcome {
|
||||
/// A payment was received; dispatch the reply. Boxed: the reply carries a
|
||||
/// full key pair, far larger than the other (unit/string) variants.
|
||||
Received {
|
||||
slate_id: String,
|
||||
amount: u64,
|
||||
reply: Box<PendingReply>,
|
||||
},
|
||||
/// Dropped permanently (marked processed).
|
||||
Dropped(&'static str),
|
||||
/// Rate limited — NOT marked processed, a legitimate burst retries later.
|
||||
RateLimited,
|
||||
/// Transient receive failure — NOT marked processed, the next catch-up
|
||||
/// retries (an incoming payment is never silently lost on a hiccup).
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// The ingest state machine: dedupe, rate limits, unwrap, policy, handoff.
|
||||
pub struct Ingest<R> {
|
||||
keys: Keys,
|
||||
receiver: R,
|
||||
/// Resolves an incoming wrap's `p` tag to the identity we hold for it
|
||||
/// (master, or a per-invoice / per-user derived child).
|
||||
directory: Arc<dyn KeyDirectory>,
|
||||
/// Processed markers: wrap ids, rumor ids, `slate:<id>` markers.
|
||||
seen: Mutex<HashSet<String>>,
|
||||
/// Per-sender sliding-window rate state (unix seconds of accepted events;
|
||||
/// the `"\0global"` key carries the global unwrap ceiling).
|
||||
rate: Mutex<HashMap<String, Vec<i64>>>,
|
||||
}
|
||||
|
||||
impl<R: SlatepackReceiver> Ingest<R> {
|
||||
/// Ingest for the single master identity (the milestone-3 default).
|
||||
pub fn new(keys: Keys, receiver: R) -> Ingest<R> {
|
||||
let directory = Arc::new(MasterDirectory(keys.clone()));
|
||||
Ingest::with_directory(keys, receiver, directory)
|
||||
}
|
||||
|
||||
/// Ingest with a multi-identity directory (master + derived children), so a
|
||||
/// payment to a per-invoice or per-user endpub unwraps and its reply is
|
||||
/// signed by that same identity.
|
||||
pub fn with_directory(keys: Keys, receiver: R, directory: Arc<dyn KeyDirectory>) -> Ingest<R> {
|
||||
Ingest {
|
||||
keys,
|
||||
receiver,
|
||||
directory,
|
||||
seen: Mutex::new(HashSet::new()),
|
||||
rate: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The wallet-handoff seam (for reconcile + status updates).
|
||||
pub fn receiver(&self) -> &R {
|
||||
&self.receiver
|
||||
}
|
||||
|
||||
/// The identities we watch (for the relay subscription filter).
|
||||
pub fn watched(&self) -> Vec<PublicKey> {
|
||||
self.directory.watched()
|
||||
}
|
||||
|
||||
/// Resolve a recipient pubkey (hex) to the keys we hold, for reconcile.
|
||||
pub fn resolve(&self, recipient_hex: &str) -> Option<Keys> {
|
||||
self.directory.resolve(recipient_hex)
|
||||
}
|
||||
|
||||
/// Build the S2 reply rumor from `from` to `payer` (also used by
|
||||
/// reconcile). The rumor author is the identity that received the payment,
|
||||
/// so the payer's wallet associates the reply with what it paid.
|
||||
pub fn build_reply(&self, from: Keys, payer: PublicKey, s2_armor: &str) -> PendingReply {
|
||||
let mut tags = protocol::build_rumor_tags(None);
|
||||
tags.push(Tag::public_key(payer));
|
||||
let rumor = EventBuilder::new(
|
||||
Kind::PrivateDirectMessage,
|
||||
protocol::build_payment_content(s2_armor),
|
||||
)
|
||||
.tags(tags)
|
||||
.build(from.public_key());
|
||||
PendingReply { from, payer, rumor }
|
||||
}
|
||||
|
||||
/// Full guarded pipeline for one incoming gift wrap event, mirroring
|
||||
/// Goblin's `handle_wrap` step for step (minus contacts/requests).
|
||||
pub async fn handle_wrap(&self, event: &Event) -> IngestOutcome {
|
||||
// 0. Only gift wraps.
|
||||
if event.kind != Kind::GiftWrap {
|
||||
return IngestOutcome::Dropped("not a gift wrap");
|
||||
}
|
||||
let wrap_id = event.id.to_hex();
|
||||
// 1. Cheap size cap before any crypto.
|
||||
if event.content.len() > protocol::MAX_WRAP_CONTENT {
|
||||
self.mark(&wrap_id);
|
||||
return IngestOutcome::Dropped("oversized wrap");
|
||||
}
|
||||
// 2. Wrap-level dedupe.
|
||||
if self.is_seen(&wrap_id) {
|
||||
return IngestOutcome::Dropped("already processed");
|
||||
}
|
||||
// 2.5 Global decrypt ceiling (fresh-keypair spam bound). Not marked
|
||||
// processed — a genuine backlog re-attempts once the window reopens.
|
||||
if !self.allow_global_unwrap() {
|
||||
return IngestOutcome::RateLimited;
|
||||
}
|
||||
// 2.7 Resolve WHICH of our identities this wrap addresses, from its
|
||||
// public `p` tag (how relays route NIP-59), then unwrap with that
|
||||
// key. The master identity, a per-invoice derived child, or a
|
||||
// per-user endpub all resolve here; anything else is not for us.
|
||||
let recipient_hex = event.tags.iter().find_map(|t| {
|
||||
let parts = t.as_slice();
|
||||
if parts.first().map(|s| s.as_str()) == Some("p") {
|
||||
parts.get(1).cloned()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
let recipient_keys = match recipient_hex
|
||||
.as_deref()
|
||||
.and_then(|h| self.directory.resolve(h))
|
||||
{
|
||||
Some(keys) => keys,
|
||||
None => {
|
||||
self.mark(&wrap_id);
|
||||
return IngestOutcome::Dropped("not a watched identity");
|
||||
}
|
||||
};
|
||||
let recipient_hex = recipient_keys.public_key().to_hex();
|
||||
// 3. Unwrap (version-dispatching; seal signature verified, rumor
|
||||
// author must equal the seal signer — enforced inside).
|
||||
let unwrapped = match crate::wrap::unwrap_gift_wrap(&recipient_keys, event) {
|
||||
Ok(u) => u,
|
||||
Err(_) => {
|
||||
self.mark(&wrap_id);
|
||||
return IngestOutcome::Dropped("unwrap failed");
|
||||
}
|
||||
};
|
||||
let sender_hex = unwrapped.sender.to_hex();
|
||||
let mut rumor = unwrapped.rumor;
|
||||
let rumor_id = rumor.id().to_hex();
|
||||
// 4. Policy over the message shape.
|
||||
let armor = protocol::extract_slatepack(&rumor.content);
|
||||
let decision = decide(&IngestContext {
|
||||
sender: &sender_hex,
|
||||
is_self: unwrapped.sender == self.keys.public_key(),
|
||||
rumor_is_dm: rumor.kind == Kind::PrivateDirectMessage
|
||||
&& rumor.content.len() <= protocol::MAX_RUMOR_CONTENT,
|
||||
has_slatepack: armor.is_some(),
|
||||
duplicate: self.is_seen(&rumor_id),
|
||||
});
|
||||
let reason = match decision {
|
||||
IngestDecision::AutoReceive => None,
|
||||
IngestDecision::Drop(reason) => Some(reason),
|
||||
};
|
||||
if let Some(reason) = reason {
|
||||
self.mark(&wrap_id);
|
||||
self.mark(&rumor_id);
|
||||
return IngestOutcome::Dropped(reason);
|
||||
}
|
||||
// 5. Rate limit per sender. Deliberately NOT marked processed:
|
||||
// legitimate bursts can retry later (Goblin's rule).
|
||||
if !self.allow_sender(&sender_hex) {
|
||||
warn!("ingest: rate limited sender {}…", &sender_hex[..8]);
|
||||
return IngestOutcome::RateLimited;
|
||||
}
|
||||
// 6. Hand the armor to the wallet (parse, S1-only check, receive_tx,
|
||||
// persist, match to an invoice/user — all enforced on the wallet
|
||||
// + core side). The memo (subject tag) and the receiving identity
|
||||
// are what the matching layer keys off.
|
||||
let armor = armor.expect("checked by decide");
|
||||
let memo = protocol::extract_subject(&rumor.tags);
|
||||
let ctx = IncomingContext {
|
||||
payer_hex: &sender_hex,
|
||||
recipient_hex: &recipient_hex,
|
||||
memo: memo.as_deref(),
|
||||
};
|
||||
match self.receiver.receive(&armor, &ctx).await {
|
||||
Ok(payment) => {
|
||||
// Durable: commit dedupe markers before the reply leg, so a
|
||||
// crash there cannot re-trigger a second receive on catch-up
|
||||
// (grin's TransactionAlreadyReceived also backstops this).
|
||||
self.mark(&wrap_id);
|
||||
self.mark(&rumor_id);
|
||||
self.mark(&format!("slate:{}", payment.slate_id));
|
||||
info!(
|
||||
"ingest: received slate {} ({} nanogrin) from {}…",
|
||||
payment.slate_id,
|
||||
payment.amount,
|
||||
&sender_hex[..8]
|
||||
);
|
||||
let reply = self.build_reply(recipient_keys, unwrapped.sender, &payment.s2_armor);
|
||||
IngestOutcome::Received {
|
||||
slate_id: payment.slate_id,
|
||||
amount: payment.amount,
|
||||
reply: Box::new(reply),
|
||||
}
|
||||
}
|
||||
Err(ReceiveError::Duplicate) => {
|
||||
self.mark(&wrap_id);
|
||||
self.mark(&rumor_id);
|
||||
IngestOutcome::Dropped("slate already received")
|
||||
}
|
||||
Err(ReceiveError::Rejected(m)) => {
|
||||
self.mark(&wrap_id);
|
||||
self.mark(&rumor_id);
|
||||
warn!("ingest: rejected slatepack from {}…: {m}", &sender_hex[..8]);
|
||||
IngestOutcome::Dropped("invalid slatepack")
|
||||
}
|
||||
// Transient: leave UNMARKED so the next catch-up retries.
|
||||
Err(ReceiveError::Failed(m)) => IngestOutcome::Failed(m),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_seen(&self, key: &str) -> bool {
|
||||
self.seen.lock().expect("seen lock").contains(key)
|
||||
}
|
||||
|
||||
fn mark(&self, key: &str) {
|
||||
self.seen.lock().expect("seen lock").insert(key.to_string());
|
||||
}
|
||||
|
||||
/// Sliding-window per-sender rate limiter (Goblin's `allow_sender`).
|
||||
fn allow_sender(&self, sender: &str) -> bool {
|
||||
let now = unix_time();
|
||||
let mut rate = self.rate.lock().expect("rate lock");
|
||||
let hits = rate.entry(sender.to_string()).or_default();
|
||||
hits.retain(|t| now - *t < 3600);
|
||||
if hits.len() >= RATE_PER_SENDER_PER_HOUR {
|
||||
return false;
|
||||
}
|
||||
hits.push(now);
|
||||
if rate.len() > RATE_MAP_CAP {
|
||||
rate.retain(|_, v| v.iter().any(|t| now - *t < 3600));
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Global unwrap ceiling (Goblin's `allow_global_unwrap`).
|
||||
fn allow_global_unwrap(&self) -> bool {
|
||||
let now = unix_time();
|
||||
let mut rate = self.rate.lock().expect("rate lock");
|
||||
let hits = rate.entry("\0global".to_string()).or_default();
|
||||
hits.retain(|t| now - *t < 60);
|
||||
if hits.len() >= GLOBAL_UNWRAP_PER_MIN {
|
||||
return false;
|
||||
}
|
||||
hits.push(now);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Mutex as StdMutex;
|
||||
|
||||
use nostr_sdk::EventBuilder;
|
||||
|
||||
use super::*;
|
||||
use crate::{ReceivedPayment, UnrepliedPayment};
|
||||
|
||||
const ALICE: &str = "91cf9dbbea5e6511fd2bbb190b112055ee4131c5d2bbb9faedf3ee8cbeac0d05";
|
||||
|
||||
fn ctx<'a>(sender: &'a str) -> IngestContext<'a> {
|
||||
IngestContext {
|
||||
sender,
|
||||
is_self: false,
|
||||
rumor_is_dm: true,
|
||||
has_slatepack: true,
|
||||
duplicate: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_payment_auto_receives_from_anyone() {
|
||||
assert_eq!(decide(&ctx(ALICE)), IngestDecision::AutoReceive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicates_own_messages_and_junk_drop() {
|
||||
let mut c = ctx(ALICE);
|
||||
c.duplicate = true;
|
||||
assert!(matches!(decide(&c), IngestDecision::Drop(_)));
|
||||
|
||||
let mut c = ctx(ALICE);
|
||||
c.is_self = true;
|
||||
assert!(matches!(decide(&c), IngestDecision::Drop(_)));
|
||||
|
||||
let mut c = ctx(ALICE);
|
||||
c.rumor_is_dm = false;
|
||||
assert!(matches!(decide(&c), IngestDecision::Drop(_)));
|
||||
|
||||
let mut c = ctx(ALICE);
|
||||
c.has_slatepack = false;
|
||||
assert!(matches!(decide(&c), IngestDecision::Drop(_)));
|
||||
}
|
||||
|
||||
/// A directory over an explicit set of identities, for the derived-key test.
|
||||
struct MultiDirectory(Vec<Keys>);
|
||||
|
||||
impl KeyDirectory for MultiDirectory {
|
||||
fn resolve(&self, recipient_hex: &str) -> Option<Keys> {
|
||||
self.0
|
||||
.iter()
|
||||
.find(|k| k.public_key().to_hex() == recipient_hex)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn watched(&self) -> Vec<PublicKey> {
|
||||
self.0.iter().map(|k| k.public_key()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Scripted stand-in for the wallet handoff. Captures the last context so
|
||||
/// tests can assert the recipient identity and memo were threaded through.
|
||||
struct StubReceiver {
|
||||
outcomes: StdMutex<Vec<Result<ReceivedPayment, ReceiveError>>>,
|
||||
calls: StdMutex<usize>,
|
||||
last_recipient: StdMutex<Option<String>>,
|
||||
last_memo: StdMutex<Option<String>>,
|
||||
}
|
||||
|
||||
impl StubReceiver {
|
||||
fn new(outcomes: Vec<Result<ReceivedPayment, ReceiveError>>) -> StubReceiver {
|
||||
StubReceiver {
|
||||
outcomes: StdMutex::new(outcomes),
|
||||
calls: StdMutex::new(0),
|
||||
last_recipient: StdMutex::new(None),
|
||||
last_memo: StdMutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn calls(&self) -> usize {
|
||||
*self.calls.lock().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl SlatepackReceiver for StubReceiver {
|
||||
async fn receive(
|
||||
&self,
|
||||
_s1_armor: &str,
|
||||
ctx: &IncomingContext<'_>,
|
||||
) -> Result<ReceivedPayment, ReceiveError> {
|
||||
*self.calls.lock().unwrap() += 1;
|
||||
*self.last_recipient.lock().unwrap() = Some(ctx.recipient_hex.to_string());
|
||||
*self.last_memo.lock().unwrap() = ctx.memo.map(|m| m.to_string());
|
||||
self.outcomes.lock().unwrap().remove(0)
|
||||
}
|
||||
|
||||
async fn mark_replied(&self, _slate_id: &str) {}
|
||||
|
||||
async fn unreplied(&self) -> Vec<UnrepliedPayment> {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
const PACK: &str = "BEGINSLATEPACK. 4H1qx1wHe668tFW yC2gfL8PPd8kSgv \
|
||||
pcXQhyRkHbyKHZg GN75o7uWoT3dkib. ENDSLATEPACK.";
|
||||
|
||||
fn payment_wrap(payer: &Keys, server: &Keys, version: crate::wrap::WrapVersion) -> Event {
|
||||
payment_wrap_noted(payer, server, version, "test")
|
||||
}
|
||||
|
||||
/// Like [`payment_wrap`] but with a distinct note, so rumors built within
|
||||
/// the same second (same content, seconds-resolution `created_at`) do not
|
||||
/// collide on the rumor id — distinct real payments always differ by
|
||||
/// their slatepack.
|
||||
fn payment_wrap_noted(
|
||||
payer: &Keys,
|
||||
server: &Keys,
|
||||
version: crate::wrap::WrapVersion,
|
||||
note: &str,
|
||||
) -> Event {
|
||||
let mut tags = protocol::build_rumor_tags(Some(note));
|
||||
tags.push(Tag::public_key(server.public_key()));
|
||||
let rumor = EventBuilder::new(
|
||||
Kind::PrivateDirectMessage,
|
||||
protocol::build_payment_content(PACK),
|
||||
)
|
||||
.tags(tags)
|
||||
.build(payer.public_key());
|
||||
crate::wrap::gift_wrap(payer, &server.public_key(), rumor, version).unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pipeline_receives_replies_and_dedupes() {
|
||||
let payer = Keys::generate();
|
||||
let server = Keys::generate();
|
||||
let ingest = Ingest::new(
|
||||
server.clone(),
|
||||
StubReceiver::new(vec![Ok(ReceivedPayment {
|
||||
slate_id: "slate-1".into(),
|
||||
amount: 42,
|
||||
s2_armor: "BEGINSLATEPACK. reply. ENDSLATEPACK.".into(),
|
||||
})]),
|
||||
);
|
||||
let wrap = payment_wrap(&payer, &server, crate::wrap::WrapVersion::V3);
|
||||
|
||||
let outcome = ingest.handle_wrap(&wrap).await;
|
||||
let reply = match outcome {
|
||||
IngestOutcome::Received {
|
||||
slate_id,
|
||||
amount,
|
||||
reply,
|
||||
} => {
|
||||
assert_eq!(slate_id, "slate-1");
|
||||
assert_eq!(amount, 42);
|
||||
reply
|
||||
}
|
||||
other => panic!("expected Received, got {other:?}"),
|
||||
};
|
||||
assert_eq!(reply.payer, payer.public_key());
|
||||
assert_eq!(reply.rumor.kind, Kind::PrivateDirectMessage);
|
||||
assert!(protocol::extract_slatepack(&reply.rumor.content).is_some());
|
||||
|
||||
// The same wrap again drops without another wallet call.
|
||||
assert!(matches!(
|
||||
ingest.handle_wrap(&wrap).await,
|
||||
IngestOutcome::Dropped(_)
|
||||
));
|
||||
assert_eq!(ingest.receiver().calls(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn transient_failure_is_retryable_permanent_rejection_is_not() {
|
||||
let payer = Keys::generate();
|
||||
let server = Keys::generate();
|
||||
let ingest = Ingest::new(
|
||||
server.clone(),
|
||||
StubReceiver::new(vec![
|
||||
Err(ReceiveError::Failed("wallet hiccup".into())),
|
||||
Ok(ReceivedPayment {
|
||||
slate_id: "slate-2".into(),
|
||||
amount: 7,
|
||||
s2_armor: "BEGINSLATEPACK. r. ENDSLATEPACK.".into(),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
let wrap = payment_wrap(&payer, &server, crate::wrap::WrapVersion::V2);
|
||||
|
||||
// Transient failure leaves the wrap unmarked...
|
||||
assert!(matches!(
|
||||
ingest.handle_wrap(&wrap).await,
|
||||
IngestOutcome::Failed(_)
|
||||
));
|
||||
// ...so the catch-up retry succeeds.
|
||||
assert!(matches!(
|
||||
ingest.handle_wrap(&wrap).await,
|
||||
IngestOutcome::Received { .. }
|
||||
));
|
||||
|
||||
// A rejected slatepack is a permanent drop.
|
||||
let ingest = Ingest::new(
|
||||
server.clone(),
|
||||
StubReceiver::new(vec![Err(ReceiveError::Rejected("not S1".into()))]),
|
||||
);
|
||||
let wrap = payment_wrap(&payer, &server, crate::wrap::WrapVersion::V2);
|
||||
assert!(matches!(
|
||||
ingest.handle_wrap(&wrap).await,
|
||||
IngestOutcome::Dropped(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
ingest.handle_wrap(&wrap).await,
|
||||
IngestOutcome::Dropped("already processed")
|
||||
));
|
||||
assert_eq!(ingest.receiver().calls(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn non_payment_messages_never_reach_the_wallet() {
|
||||
let payer = Keys::generate();
|
||||
let server = Keys::generate();
|
||||
let ingest = Ingest::new(server.clone(), StubReceiver::new(vec![]));
|
||||
|
||||
// A DM without a slatepack.
|
||||
let rumor = EventBuilder::new(Kind::PrivateDirectMessage, "just chatting")
|
||||
.tags([Tag::public_key(server.public_key())])
|
||||
.build(payer.public_key());
|
||||
let wrap = crate::wrap::gift_wrap(
|
||||
&payer,
|
||||
&server.public_key(),
|
||||
rumor,
|
||||
crate::wrap::WrapVersion::V2,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
ingest.handle_wrap(&wrap).await,
|
||||
IngestOutcome::Dropped("no slatepack payload")
|
||||
));
|
||||
|
||||
// A non-gift-wrap event.
|
||||
let plain = EventBuilder::new(Kind::TextNote, "hello")
|
||||
.sign_with_keys(&payer)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
ingest.handle_wrap(&plain).await,
|
||||
IngestOutcome::Dropped("not a gift wrap")
|
||||
));
|
||||
|
||||
// A wrap addressed to someone else: its `p` tag resolves to no
|
||||
// identity we watch, so it drops before any decrypt attempt.
|
||||
let other = Keys::generate();
|
||||
let wrap = payment_wrap(&payer, &other, crate::wrap::WrapVersion::V3);
|
||||
assert!(matches!(
|
||||
ingest.handle_wrap(&wrap).await,
|
||||
IngestOutcome::Dropped("not a watched identity")
|
||||
));
|
||||
assert_eq!(ingest.receiver().calls(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn per_sender_rate_limit_kicks_in() {
|
||||
let payer = Keys::generate();
|
||||
let server = Keys::generate();
|
||||
let outcomes = (0..RATE_PER_SENDER_PER_HOUR)
|
||||
.map(|i| {
|
||||
Ok(ReceivedPayment {
|
||||
slate_id: format!("slate-{i}"),
|
||||
amount: 1,
|
||||
s2_armor: "BEGINSLATEPACK. r. ENDSLATEPACK.".into(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let ingest = Ingest::new(server.clone(), StubReceiver::new(outcomes));
|
||||
|
||||
for i in 0..RATE_PER_SENDER_PER_HOUR {
|
||||
let wrap = payment_wrap_noted(
|
||||
&payer,
|
||||
&server,
|
||||
crate::wrap::WrapVersion::V2,
|
||||
&format!("payment {i}"),
|
||||
);
|
||||
assert!(matches!(
|
||||
ingest.handle_wrap(&wrap).await,
|
||||
IngestOutcome::Received { .. }
|
||||
));
|
||||
}
|
||||
// One more within the hour: rate limited, wallet untouched.
|
||||
let wrap = payment_wrap_noted(
|
||||
&payer,
|
||||
&server,
|
||||
crate::wrap::WrapVersion::V2,
|
||||
"one too many",
|
||||
);
|
||||
assert!(matches!(
|
||||
ingest.handle_wrap(&wrap).await,
|
||||
IngestOutcome::RateLimited
|
||||
));
|
||||
assert_eq!(ingest.receiver().calls(), RATE_PER_SENDER_PER_HOUR);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn derived_identity_is_resolved_and_reply_signed_from_it() {
|
||||
// A payment addressed to a derived child (not the master) unwraps via
|
||||
// the directory, threads the recipient + memo to the wallet handoff,
|
||||
// and its reply is signed FROM the derived identity the payer paid.
|
||||
let payer = Keys::generate();
|
||||
let master = Keys::generate();
|
||||
let derived = Keys::generate(); // stands in for a per-invoice child
|
||||
let directory = Arc::new(MultiDirectory(vec![master.clone(), derived.clone()]));
|
||||
let ingest = Ingest::with_directory(
|
||||
master.clone(),
|
||||
StubReceiver::new(vec![Ok(ReceivedPayment {
|
||||
slate_id: "slate-x".into(),
|
||||
amount: 5,
|
||||
s2_armor: "BEGINSLATEPACK. r. ENDSLATEPACK.".into(),
|
||||
})]),
|
||||
directory,
|
||||
);
|
||||
|
||||
// Payer wraps to the DERIVED key with an order memo.
|
||||
let mut tags = protocol::build_rumor_tags(Some("order-99"));
|
||||
tags.push(Tag::public_key(derived.public_key()));
|
||||
let rumor = EventBuilder::new(
|
||||
Kind::PrivateDirectMessage,
|
||||
protocol::build_payment_content(PACK),
|
||||
)
|
||||
.tags(tags)
|
||||
.build(payer.public_key());
|
||||
let wrap = crate::wrap::gift_wrap(
|
||||
&payer,
|
||||
&derived.public_key(),
|
||||
rumor,
|
||||
crate::wrap::WrapVersion::V3,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let reply = match ingest.handle_wrap(&wrap).await {
|
||||
IngestOutcome::Received { reply, .. } => reply,
|
||||
other => panic!("expected Received, got {other:?}"),
|
||||
};
|
||||
// The reply comes FROM the derived identity, TO the payer.
|
||||
assert_eq!(reply.from.public_key(), derived.public_key());
|
||||
assert_eq!(reply.payer, payer.public_key());
|
||||
// The wallet handoff saw the derived recipient and the memo.
|
||||
assert_eq!(
|
||||
ingest.receiver().last_recipient.lock().unwrap().as_deref(),
|
||||
Some(derived.public_key().to_hex().as_str())
|
||||
);
|
||||
assert_eq!(
|
||||
ingest.receiver().last_memo.lock().unwrap().as_deref(),
|
||||
Some("order-99")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
//! Nostr transport and secure handoff for GoblinPay, mirroring Goblin's
|
||||
//! proven `src/nostr` + `src/nym` stack adapted to a headless daemon:
|
||||
//!
|
||||
//! - [`identity`]: a random standalone nsec (or an imported one), NIP-49
|
||||
//! encrypted at rest, deliberately independent of the Grin seed (the
|
||||
//! two-secrets rule).
|
||||
//! - [`wrap`]: NIP-59 gift wrap build/unwrap with the NIP-17 backward-compat
|
||||
//! extension — NIP-44 v3 (kind/scope context binding, via the companion
|
||||
//! `nip44` crate) negotiated per recipient, v2 via nostr-sdk as the
|
||||
//! mandatory baseline.
|
||||
//! - [`protocol`]: the Goblin payment message layout (kind-14 rumor carrying
|
||||
//! one slatepack armor block).
|
||||
//! - [`ingest`]: the guarded ingest pipeline (dedupe, rate limits, the pure
|
||||
//! `decide()` policy) handing S1 slatepacks to the wallet and building the
|
||||
//! S2 reply rumor.
|
||||
//! - [`service`]: the daemon loop — relay pool over the in-process Nym
|
||||
//! mixnet, kind-10050 publishing, catch-up + live subscription, reply
|
||||
//! dispatch, boot-time reconcile.
|
||||
//! - [`nym`]: the smolmix tunnel, mix-dns and the relay websocket transport,
|
||||
//! ported from Goblin (G14).
|
||||
//!
|
||||
//! Privacy: log lines carry short event/key prefixes and hosts only — never
|
||||
//! armor contents, full URLs, or secrets (Goblin's host-only level).
|
||||
|
||||
pub mod identity;
|
||||
pub mod ingest;
|
||||
pub mod nym;
|
||||
pub mod protocol;
|
||||
pub mod receipt;
|
||||
pub mod relays;
|
||||
pub mod service;
|
||||
pub mod wrap;
|
||||
|
||||
/// Re-exported so downstream crates (gp-server) can name the identity key types
|
||||
/// without depending on nostr-sdk directly.
|
||||
pub use nostr_sdk::{Keys, PublicKey};
|
||||
|
||||
/// What the wallet hands back for one received S1 slatepack. Mirrors
|
||||
/// `gp_wallet::Received`, redefined here so the transport crate never links
|
||||
/// the Grin stack (the wallet side plugs in through [`SlatepackReceiver`]).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReceivedPayment {
|
||||
/// Slate UUID, shared by S1, S2, and the final transaction.
|
||||
pub slate_id: String,
|
||||
/// Amount in nanogrin, as stated by the slate.
|
||||
pub amount: u64,
|
||||
/// The S2 reply slatepack armor for the payer to finalize.
|
||||
pub s2_armor: String,
|
||||
}
|
||||
|
||||
/// A payment whose S2 reply has not (verifiably) reached the payer yet,
|
||||
/// surfaced by the store for the boot-time reconcile pass.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UnrepliedPayment {
|
||||
/// Slate UUID.
|
||||
pub slate_id: String,
|
||||
/// Payer public key, hex.
|
||||
pub payer_hex: String,
|
||||
/// The stored S2 reply armor.
|
||||
pub s2_armor: String,
|
||||
/// Our identity that received it (master or a derived child), x-only hex,
|
||||
/// so the reply is re-sent from the right key.
|
||||
pub recipient_hex: String,
|
||||
}
|
||||
|
||||
/// Context threaded from the ingest pipeline into the wallet handoff: who paid,
|
||||
/// which of our identities received the payment (the master key or a per-invoice
|
||||
/// / per-user derived child), and the payer's memo. The recipient and memo are
|
||||
/// what the matching layer (milestone 5) keys off.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IncomingContext<'a> {
|
||||
/// Seal-verified sender public key, hex.
|
||||
pub payer_hex: &'a str,
|
||||
/// The identity that received it (x-only hex).
|
||||
pub recipient_hex: &'a str,
|
||||
/// The payer's sanitized memo (subject tag), if any.
|
||||
pub memo: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// Why a receive was refused.
|
||||
#[derive(Debug)]
|
||||
pub enum ReceiveError {
|
||||
/// This slate was already received — drop the wrap permanently.
|
||||
Duplicate,
|
||||
/// The slatepack is invalid (bad armor, wrong state, garbage) — drop the
|
||||
/// wrap permanently.
|
||||
Rejected(String),
|
||||
/// Transient failure (wallet/db hiccup) — leave the wrap unmarked so the
|
||||
/// next catch-up retries it (an incoming payment must never be silently
|
||||
/// lost on a momentary hiccup; Goblin's rule).
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ReceiveError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ReceiveError::Duplicate => write!(f, "slate already received"),
|
||||
ReceiveError::Rejected(m) => write!(f, "slatepack rejected: {m}"),
|
||||
ReceiveError::Failed(m) => write!(f, "receive failed: {m}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ReceiveError {}
|
||||
|
||||
/// The secure handoff seam into the wallet (gp-server implements it over
|
||||
/// `gp_wallet::GpWallet` + SQLite). Only armored slatepack strings cross the
|
||||
/// boundary, exactly like production Goblin.
|
||||
#[allow(async_fn_in_trait)] // consumed generically, never as `dyn`
|
||||
pub trait SlatepackReceiver: Send + Sync {
|
||||
/// Receive an S1 slatepack (parse, `receive_tx`, persist, match to an
|
||||
/// invoice/user) and return the S2 reply. `ctx` carries the payer, the
|
||||
/// receiving identity, and the memo the matching layer keys off.
|
||||
async fn receive(
|
||||
&self,
|
||||
s1_armor: &str,
|
||||
ctx: &IncomingContext<'_>,
|
||||
) -> Result<ReceivedPayment, ReceiveError>;
|
||||
|
||||
/// Mark a payment's S2 reply as dispatched (a relay accepted it).
|
||||
async fn mark_replied(&self, slate_id: &str);
|
||||
|
||||
/// Payments still awaiting their S2 dispatch (for boot-time reconcile).
|
||||
async fn unreplied(&self) -> Vec<UnrepliedPayment>;
|
||||
}
|
||||
|
||||
/// Resolves an incoming gift wrap's `p` tag (the recipient x-only hex) to the
|
||||
/// secret keys we hold for it, and lists the identities we currently watch.
|
||||
///
|
||||
/// The default is the master identity alone; gp-server supplies a DB-backed
|
||||
/// directory that also resolves per-invoice (matching mode 2) and per-user
|
||||
/// (5b) derived children, so a payment sent to any of those unwraps and its
|
||||
/// reply is signed by the same identity the payer addressed.
|
||||
pub trait KeyDirectory: Send + Sync {
|
||||
/// The keys for a recipient pubkey (hex), or `None` if we do not hold it.
|
||||
fn resolve(&self, recipient_hex: &str) -> Option<nostr_sdk::Keys>;
|
||||
/// Every pubkey we currently watch (always includes the master), for the
|
||||
/// relay subscription filter.
|
||||
fn watched(&self) -> Vec<nostr_sdk::PublicKey>;
|
||||
}
|
||||
|
||||
/// The default single-identity directory: the server master key only.
|
||||
pub struct MasterDirectory(pub nostr_sdk::Keys);
|
||||
|
||||
impl KeyDirectory for MasterDirectory {
|
||||
fn resolve(&self, recipient_hex: &str) -> Option<nostr_sdk::Keys> {
|
||||
if self.0.public_key().to_hex() == recipient_hex {
|
||||
Some(self.0.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn watched(&self) -> Vec<nostr_sdk::PublicKey> {
|
||||
vec![self.0.public_key()]
|
||||
}
|
||||
}
|
||||
|
||||
/// Build `Keys` from a raw 32-byte secret (used by DB-backed directories to
|
||||
/// reconstruct a derived child from its recomputed secret).
|
||||
pub fn keys_from_secret(secret: &[u8; 32]) -> Result<nostr_sdk::Keys, String> {
|
||||
let sk = nostr_sdk::SecretKey::from_slice(secret).map_err(|e| e.to_string())?;
|
||||
Ok(nostr_sdk::Keys::new(sk))
|
||||
}
|
||||
|
||||
/// Bech32 npub for a key pair (for logs and the merchant QR).
|
||||
pub fn npub(keys: &nostr_sdk::Keys) -> String {
|
||||
use nostr_sdk::ToBech32;
|
||||
keys.public_key().to_bech32().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Bech32 npub for a public key (checkout page display).
|
||||
pub fn npub_of(pk: nostr_sdk::PublicKey) -> String {
|
||||
use nostr_sdk::ToBech32;
|
||||
pk.to_bech32().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Parse a public key from a bech32 `npub` or a raw hex string (for the
|
||||
/// configured merchant identity).
|
||||
pub fn pubkey_from_str(s: &str) -> Option<nostr_sdk::PublicKey> {
|
||||
use nostr_sdk::FromBech32;
|
||||
let s = s.trim();
|
||||
nostr_sdk::PublicKey::from_bech32(s)
|
||||
.ok()
|
||||
.or_else(|| nostr_sdk::PublicKey::from_hex(s).ok())
|
||||
}
|
||||
|
||||
/// Bech32 `nprofile` for a public key plus its relay hints (the checkout QR
|
||||
/// payload; a Goblin wallet scans it to know where to send).
|
||||
pub fn nprofile(pk: nostr_sdk::PublicKey, relays: &[String]) -> String {
|
||||
use nostr_sdk::nips::nip19::Nip19Profile;
|
||||
use nostr_sdk::{RelayUrl, ToBech32};
|
||||
let urls: Vec<RelayUrl> = relays
|
||||
.iter()
|
||||
.filter_map(|r| RelayUrl::parse(r).ok())
|
||||
.collect();
|
||||
Nip19Profile::new(pk, urls).to_bech32().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Unix time in seconds (mirrors Goblin's helper).
|
||||
pub(crate) fn unix_time() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
//! mix-dns: hostname resolution THROUGH the mixnet (ported from
|
||||
//! `goblin/src/nym/dns.rs`). `Tunnel::tcp_connect` takes a `SocketAddr`, so
|
||||
//! DNS is our responsibility — and it MUST ride the tunnel: a clearnet lookup
|
||||
//! would leak exactly which relays the server contacts, defeating the mixnet.
|
||||
//! Raw A-record queries go as UDP datagrams over
|
||||
//! [`smolmix::Tunnel::udp_socket`] to public resolvers addressed BY IP.
|
||||
//! Responses land in a TTL-respecting in-memory cache. IPv4-only, like the
|
||||
//! Goblin original.
|
||||
//!
|
||||
//! Wire codec: hickory-proto — already in the dependency graph via
|
||||
//! nym-http-api-client, so no vendored encode/parse is needed.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
use std::sync::{LazyLock, RwLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use hickory_proto::op::{Message, MessageType, Query, ResponseCode};
|
||||
use hickory_proto::rr::{Name, RData, RecordType};
|
||||
use log::{debug, warn};
|
||||
use smolmix::Tunnel;
|
||||
|
||||
/// Public resolvers the tunnel queries, by IP (no bootstrap chicken-and-egg):
|
||||
/// Cloudflare primary, Quad9 fallback. The exit gateway only ever sees a DNS
|
||||
/// packet to a public resolver, never who asked.
|
||||
const RESOLVERS: [SocketAddr; 2] = [
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)), 53),
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(9, 9, 9, 9)), 53),
|
||||
];
|
||||
|
||||
/// Per-resolver answer wait. The mixnet adds multi-second round trips.
|
||||
const QUERY_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
/// TTL floor/ceiling for the cache: don't hammer resolvers for zero-TTL
|
||||
/// records, don't trust a stale record for more than an hour.
|
||||
const TTL_FLOOR_SECS: u32 = 60;
|
||||
const TTL_CEILING_SECS: u32 = 3600;
|
||||
|
||||
/// Cached answer for one host: addresses plus their expiry.
|
||||
type CachedAnswer = (Vec<Ipv4Addr>, Instant);
|
||||
|
||||
/// host → cached answer.
|
||||
static CACHE: LazyLock<RwLock<HashMap<String, CachedAnswer>>> =
|
||||
LazyLock::new(|| RwLock::new(HashMap::new()));
|
||||
|
||||
/// Resolve `host` to a socket address for `tcp_connect`, entirely over the
|
||||
/// mixnet. IP-literal hosts skip DNS; cached answers are honored until their
|
||||
/// (clamped) TTL lapses. Returns `None` when every resolver fails.
|
||||
pub async fn resolve(tunnel: &Tunnel, host: &str, port: u16) -> Option<SocketAddr> {
|
||||
// IP literals (v4 or v6) need no lookup at all.
|
||||
if let Ok(ip) = host.parse::<IpAddr>() {
|
||||
return Some(SocketAddr::new(ip, port));
|
||||
}
|
||||
if let Some(ip) = cached(host) {
|
||||
return Some(SocketAddr::new(IpAddr::V4(ip), port));
|
||||
}
|
||||
for resolver in RESOLVERS {
|
||||
match query_a(tunnel, host, resolver).await {
|
||||
Some((ips, ttl)) if !ips.is_empty() => {
|
||||
let ttl = ttl.clamp(TTL_FLOOR_SECS, TTL_CEILING_SECS);
|
||||
debug!(
|
||||
"mix-dns: resolved {host} -> {} (ttl {ttl}s, via {resolver}, {} record(s))",
|
||||
ips[0],
|
||||
ips.len()
|
||||
);
|
||||
let expiry = Instant::now() + Duration::from_secs(ttl as u64);
|
||||
CACHE
|
||||
.write()
|
||||
.expect("dns cache lock")
|
||||
.insert(host.to_string(), (ips.clone(), expiry));
|
||||
return Some(SocketAddr::new(IpAddr::V4(ips[0]), port));
|
||||
}
|
||||
_ => {
|
||||
warn!("mix-dns: no answer for {host} from {resolver}, trying next resolver");
|
||||
}
|
||||
}
|
||||
}
|
||||
warn!("mix-dns: resolution failed for {host} (all resolvers)");
|
||||
None
|
||||
}
|
||||
|
||||
/// A cached, unexpired address for `host`.
|
||||
fn cached(host: &str) -> Option<Ipv4Addr> {
|
||||
let cache = CACHE.read().expect("dns cache lock");
|
||||
let (ips, expiry) = cache.get(host)?;
|
||||
if Instant::now() < *expiry {
|
||||
ips.first().copied()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Cheap end-to-end liveness probe: one uncached A query for a stable name
|
||||
/// against the primary resolver. Used by the tunnel keepalive/watchdog — it
|
||||
/// exercises the full path (mixnet → IPR exit → internet and back) and, as a
|
||||
/// side effect, keeps the gateway connection and IPR session from idling out.
|
||||
pub async fn probe(tunnel: &Tunnel) -> bool {
|
||||
query_a(tunnel, "example.com", RESOLVERS[0]).await.is_some()
|
||||
}
|
||||
|
||||
/// One A query/response round trip over the tunnel against `resolver`.
|
||||
async fn query_a(
|
||||
tunnel: &Tunnel,
|
||||
host: &str,
|
||||
resolver: SocketAddr,
|
||||
) -> Option<(Vec<Ipv4Addr>, u32)> {
|
||||
let udp = match tunnel.udp_socket().await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
warn!("mix-dns: udp socket failed: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let id = rand::random::<u16>();
|
||||
let query = encode_query(id, host)?;
|
||||
if let Err(e) = udp.send_to(&query, resolver).await {
|
||||
warn!("mix-dns: send to {resolver} failed: {e}");
|
||||
return None;
|
||||
}
|
||||
let mut buf = vec![0u8; 1500];
|
||||
let (n, from) = match tokio::time::timeout(QUERY_TIMEOUT, udp.recv_from(&mut buf)).await {
|
||||
Ok(Ok(r)) => r,
|
||||
Ok(Err(e)) => {
|
||||
warn!("mix-dns: recv from {resolver} failed: {e}");
|
||||
return None;
|
||||
}
|
||||
Err(_) => {
|
||||
warn!("mix-dns: query to {resolver} timed out");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if from != resolver {
|
||||
warn!("mix-dns: dropping answer from unexpected source {from}");
|
||||
return None;
|
||||
}
|
||||
parse_response(id, &buf[..n])
|
||||
}
|
||||
|
||||
/// Encode a recursive A query for `host` with transaction id `id`.
|
||||
fn encode_query(id: u16, host: &str) -> Option<Vec<u8>> {
|
||||
let name = Name::from_ascii(host).ok()?;
|
||||
let mut msg = Message::query();
|
||||
msg.metadata.id = id;
|
||||
msg.metadata.recursion_desired = true;
|
||||
msg.add_query(Query::query(name, RecordType::A));
|
||||
msg.to_vec().ok()
|
||||
}
|
||||
|
||||
/// Parse a response to transaction `id`: all A records in the answer section
|
||||
/// plus the smallest TTL among them. `None` on id mismatch, non-response,
|
||||
/// error rcode or no A records (CNAMEs and other types are skipped).
|
||||
fn parse_response(id: u16, raw: &[u8]) -> Option<(Vec<Ipv4Addr>, u32)> {
|
||||
let msg = Message::from_vec(raw).ok()?;
|
||||
if msg.metadata.id != id
|
||||
|| msg.metadata.message_type != MessageType::Response
|
||||
|| msg.metadata.response_code != ResponseCode::NoError
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let mut ips = Vec::new();
|
||||
let mut ttl = u32::MAX;
|
||||
for record in &msg.answers {
|
||||
if let RData::A(a) = record.data {
|
||||
ips.push(a.0);
|
||||
ttl = ttl.min(record.ttl);
|
||||
}
|
||||
}
|
||||
if ips.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some((ips, ttl))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Query for `example.com` A/IN, id 0x1234, RD set — the canonical fixture
|
||||
/// (same bytes smolmix's own docs use).
|
||||
const QUERY_FIXTURE: &[u8] = b"\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00\
|
||||
\x07example\x03com\x00\x00\x01\x00\x01";
|
||||
|
||||
/// Response to `QUERY_FIXTURE`: flags 0x8180 (QR, RD, RA, NOERROR), one
|
||||
/// question, two answers — a CNAME (ttl 3600, rdata = compression pointer
|
||||
/// back to the qname) that must be skipped, then an A record for
|
||||
/// 93.184.216.34 with ttl 300.
|
||||
const RESPONSE_FIXTURE: &[u8] = b"\x12\x34\x81\x80\x00\x01\x00\x02\x00\x00\x00\x00\
|
||||
\x07example\x03com\x00\x00\x01\x00\x01\
|
||||
\xc0\x0c\x00\x05\x00\x01\x00\x00\x0e\x10\x00\x02\xc0\x0c\
|
||||
\xc0\x0c\x00\x01\x00\x01\x00\x00\x01\x2c\x00\x04\x5d\xb8\xd8\x22";
|
||||
|
||||
#[test]
|
||||
fn encode_query_matches_fixture() {
|
||||
let bytes = encode_query(0x1234, "example.com").unwrap();
|
||||
assert_eq!(bytes, QUERY_FIXTURE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_response_extracts_a_records_and_min_ttl() {
|
||||
let (ips, ttl) = parse_response(0x1234, RESPONSE_FIXTURE).unwrap();
|
||||
assert_eq!(ips, vec![Ipv4Addr::new(93, 184, 216, 34)]);
|
||||
// The CNAME's larger ttl (3600) must not win: only A records count.
|
||||
assert_eq!(ttl, 300);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_response_rejects_wrong_id() {
|
||||
assert!(parse_response(0x5678, RESPONSE_FIXTURE).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_response_rejects_query_and_garbage() {
|
||||
// A query (QR=0) is not an answer.
|
||||
assert!(parse_response(0x1234, QUERY_FIXTURE).is_none());
|
||||
// Truncated/garbage input parses to nothing.
|
||||
assert!(parse_response(0x1234, &RESPONSE_FIXTURE[..7]).is_none());
|
||||
assert!(parse_response(0x1234, b"\x00").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_response_rejects_error_rcode() {
|
||||
// Same fixture with rcode NXDOMAIN (flags 0x8183) and no answers.
|
||||
let nx: &[u8] = b"\x12\x34\x81\x83\x00\x01\x00\x00\x00\x00\x00\x00\
|
||||
\x07example\x03com\x00\x00\x01\x00\x01";
|
||||
assert!(parse_response(0x1234, nx).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ttl_clamp_bounds() {
|
||||
assert_eq!(5u32.clamp(TTL_FLOOR_SECS, TTL_CEILING_SECS), 60);
|
||||
assert_eq!(999_999u32.clamp(TTL_FLOOR_SECS, TTL_CEILING_SECS), 3600);
|
||||
assert_eq!(300u32.clamp(TTL_FLOOR_SECS, TTL_CEILING_SECS), 300);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//! Nym mixnet transport, ported from Goblin's proven `src/nym` (G14).
|
||||
//! Every relay websocket rides one in-process smolmix
|
||||
//! [`Tunnel`](smolmix::Tunnel) over the 5-hop mixnet to an auto-selected IPR
|
||||
//! exit. Hostnames resolve through the same tunnel ([`dns`], mix-dns), so
|
||||
//! neither payload nor destination ever touches the clearnet. For a payment
|
||||
//! server this is default-on: returning the S2 means outbound connections to
|
||||
//! the payer's relays, which over clearnet would link the merchant identity
|
||||
//! to a host IP.
|
||||
//!
|
||||
//! This tunnel carries ONLY the Nostr gift-wrap layer. The milestone-4
|
||||
//! node-confirmation reads (wallet -> node get_kernel/get_tip) deliberately do
|
||||
//! NOT ride it: node traffic is a server concern that goes DIRECT over normal
|
||||
//! HTTP (owner ruling), exactly like Goblin's own wallet -> node reads never
|
||||
//! ride the mixnet. Those reads live in `gp-wallet`, which has no Nym linkage,
|
||||
//! so the direct path is structural. Do not route node reads through here.
|
||||
|
||||
pub mod dns;
|
||||
pub mod nymproc;
|
||||
pub mod transport;
|
||||
|
||||
pub use nymproc::{is_ready, warm_up};
|
||||
pub use transport::NymWebSocketTransport;
|
||||
@@ -0,0 +1,192 @@
|
||||
//! In-process Nym mixnet tunnel (ported from `goblin/src/nym/nymproc.rs`).
|
||||
//! smolmix is linked directly — no sidecar subprocess, no loopback SOCKS5
|
||||
//! seam. One process-lifetime [`Tunnel`] carries every relay websocket as raw
|
||||
//! TCP over the mixnet to an AUTO-SELECTED IPR exit gateway: losing any one
|
||||
//! exit just re-selects, so there is no single-exit SPOF. Hostnames are
|
||||
//! resolved through the same tunnel by [`super::dns`] (mix-dns); nothing goes
|
||||
//! clearnet.
|
||||
//!
|
||||
//! Same liveness posture as Goblin: a fresh tunnel must pass an end-to-end
|
||||
//! probe before it is published (some exits accept the IPR handshake but
|
||||
//! never deliver data), and a keepalive watchdog rebuilds on sustained
|
||||
//! failure.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::RwLock;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use log::{error, info, warn};
|
||||
use smolmix::Tunnel;
|
||||
|
||||
/// The shared process-lifetime tunnel, set once the mixnet bootstrap finishes.
|
||||
static TUNNEL: RwLock<Option<Tunnel>> = RwLock::new(None);
|
||||
|
||||
/// Set once the tunnel is up (mirrors `TUNNEL`, but cheap to poll).
|
||||
static MIXNET_READY: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Guards the background bootstrap thread so `warm_up()` is idempotent.
|
||||
static STARTED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Pre-warm the mixnet tunnel in the background so relays are ready by first
|
||||
/// use. Idempotent — later calls (including the lazy-init path in
|
||||
/// [`wait_for_tunnel`]) are no-ops.
|
||||
pub fn warm_up() {
|
||||
if STARTED.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
thread::spawn(run_tunnel);
|
||||
}
|
||||
|
||||
/// Whether the mixnet tunnel is warm. Cheap and cached. Distinct from a
|
||||
/// relay being connected.
|
||||
pub fn is_ready() -> bool {
|
||||
MIXNET_READY.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// The shared tunnel, if it is up. Cloning is a cheap `Arc` bump.
|
||||
pub fn tunnel() -> Option<Tunnel> {
|
||||
TUNNEL.read().expect("tunnel lock").clone()
|
||||
}
|
||||
|
||||
/// Wait until the shared tunnel is up, starting the bootstrap if nothing has
|
||||
/// yet (lazy init on first use). Returns `None` once `timeout` lapses.
|
||||
pub async fn wait_for_tunnel(timeout: Duration) -> Option<Tunnel> {
|
||||
warm_up();
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
if let Some(t) = tunnel() {
|
||||
return Some(t);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return None;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the mixnet tunnel on a dedicated multi-thread tokio runtime, then
|
||||
/// keep the tunnel (its bridge + smoltcp reactor tasks) AND the runtime alive
|
||||
/// for the lifetime of the process. Retries with backoff on bootstrap failure
|
||||
/// (a dead gateway pick just re-selects on the next attempt). Blocks the
|
||||
/// calling thread.
|
||||
fn run_tunnel() {
|
||||
let rt = match tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(2)
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
error!("nym: could not build mixnet runtime: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
rt.block_on(async move {
|
||||
let mut delay = Duration::from_secs(5);
|
||||
loop {
|
||||
let started = Instant::now();
|
||||
info!("nym: starting in-process mixnet tunnel (smolmix, auto-selected exit)");
|
||||
match build_tunnel().await {
|
||||
Ok(tunnel) => {
|
||||
// Gate readiness on one end-to-end probe: some exits accept
|
||||
// the IPR handshake but never deliver data (seen live);
|
||||
// publishing such a tunnel would blackhole every consumer
|
||||
// until the watchdog caught it minutes later. Re-select
|
||||
// immediately instead.
|
||||
if !probe_fresh(&tunnel).await {
|
||||
error!(
|
||||
"nym: fresh tunnel failed its liveness probe (dead exit); re-selecting"
|
||||
);
|
||||
tunnel.shutdown().await;
|
||||
delay = (delay * 2).min(Duration::from_secs(60));
|
||||
continue;
|
||||
}
|
||||
info!(
|
||||
"nym: tunnel ready in ~{}ms (allocated ip {}, probe ok)",
|
||||
started.elapsed().as_millis(),
|
||||
tunnel.allocated_ips().ipv4
|
||||
);
|
||||
*TUNNEL.write().expect("tunnel lock") = Some(tunnel.clone());
|
||||
MIXNET_READY.store(true, Ordering::Relaxed);
|
||||
delay = Duration::from_secs(5);
|
||||
// Hold the tunnel warm for the whole process lifetime with
|
||||
// a cheap keepalive: the probe keeps the gateway
|
||||
// connection + IPR session from idling out while the relay
|
||||
// subscription rides it — and verifies the path end to
|
||||
// end. When the tunnel dies anyway (exit gateway gone),
|
||||
// rebuild with a freshly auto-selected exit: losing any
|
||||
// one exit must never take the server down.
|
||||
watch_tunnel(&tunnel).await;
|
||||
error!("nym: tunnel unresponsive; rebuilding with a fresh exit");
|
||||
MIXNET_READY.store(false, Ordering::Relaxed);
|
||||
*TUNNEL.write().expect("tunnel lock") = None;
|
||||
tunnel.shutdown().await;
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
"nym: mixnet tunnel failed to start: {e}; retrying in {}s",
|
||||
delay.as_secs()
|
||||
);
|
||||
tokio::time::sleep(delay).await;
|
||||
delay = (delay * 2).min(Duration::from_secs(60));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Two probe attempts before rejecting a fresh tunnel: mixnet UDP does lose
|
||||
/// the odd datagram, and one lost packet must not condemn a healthy exit.
|
||||
async fn probe_fresh(tunnel: &Tunnel) -> bool {
|
||||
for _ in 0..2 {
|
||||
if super::dns::probe(tunnel).await {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Keepalive period and the consecutive probe failures that declare death.
|
||||
const KEEPALIVE_PERIOD: Duration = Duration::from_secs(60);
|
||||
const KEEPALIVE_MAX_FAILS: u32 = 3;
|
||||
|
||||
/// Probe the tunnel every [`KEEPALIVE_PERIOD`] (one tiny DNS round trip over
|
||||
/// the mixnet); returns once [`KEEPALIVE_MAX_FAILS`] probes fail in a row.
|
||||
async fn watch_tunnel(tunnel: &Tunnel) {
|
||||
let mut fails = 0u32;
|
||||
loop {
|
||||
tokio::time::sleep(KEEPALIVE_PERIOD).await;
|
||||
if super::dns::probe(tunnel).await {
|
||||
fails = 0;
|
||||
} else {
|
||||
fails += 1;
|
||||
warn!("nym: tunnel keepalive probe failed ({fails}/{KEEPALIVE_MAX_FAILS})");
|
||||
if fails >= KEEPALIVE_MAX_FAILS {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the tunnel with an auto-selected IPR exit. Ephemeral in-memory keys
|
||||
/// (a fresh mixnet identity per run — no sqlite, no persisted gateway).
|
||||
///
|
||||
/// NEVER pin an exit here in shipped code: pinning turns off auto-selection
|
||||
/// and re-introduces the single-exit SPOF. `GP_NYM_IPR` exists for DEBUGGING
|
||||
/// only and defaults to unset.
|
||||
async fn build_tunnel() -> Result<Tunnel, smolmix::SmolmixError> {
|
||||
let mut builder = Tunnel::builder();
|
||||
if let Ok(pin) = std::env::var("GP_NYM_IPR") {
|
||||
if !pin.is_empty() {
|
||||
match pin.parse() {
|
||||
Ok(recipient) => {
|
||||
warn!("nym: GP_NYM_IPR set — pinning IPR exit (debug only, SPOF!)");
|
||||
builder = builder.ipr_address(recipient);
|
||||
}
|
||||
Err(e) => warn!("nym: ignoring invalid GP_NYM_IPR: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
builder.build().await
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
//! WebSocket transport for the Nostr relay pool routed through the
|
||||
//! in-process smolmix tunnel (ported from `goblin/src/nym/transport.rs`), so
|
||||
//! every relay connection traverses the 5-hop Nym mixnet. The relay host is
|
||||
//! resolved through the tunnel (mix-dns — the destination is never resolved
|
||||
//! on the clear), the TCP stream is opened via `tunnel.tcp_connect`, then the
|
||||
//! TLS (rustls, webpki roots) + websocket handshake runs over that tunneled
|
||||
//! stream. Nothing goes clearnet.
|
||||
|
||||
use std::fmt;
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
use std::time::Duration;
|
||||
|
||||
use async_wsocket::futures_util::{Sink, SinkExt, StreamExt};
|
||||
use async_wsocket::{ConnectionMode, Message};
|
||||
use nostr_relay_pool::transport::error::TransportError;
|
||||
use nostr_relay_pool::transport::websocket::{WebSocketSink, WebSocketStream, WebSocketTransport};
|
||||
use nostr_sdk::util::BoxedFuture;
|
||||
use nostr_sdk::Url;
|
||||
use tokio_tungstenite::tungstenite::Message as TgMessage;
|
||||
|
||||
/// Error type for transport failures outside the websocket layer.
|
||||
#[derive(Debug)]
|
||||
struct NymTransportError(String);
|
||||
|
||||
impl fmt::Display for NymTransportError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for NymTransportError {}
|
||||
|
||||
fn terr(msg: impl Into<String>) -> TransportError {
|
||||
TransportError::backend(NymTransportError(msg.into()))
|
||||
}
|
||||
|
||||
/// Nostr websocket transport over the in-process Nym mixnet tunnel.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct NymWebSocketTransport;
|
||||
|
||||
impl WebSocketTransport for NymWebSocketTransport {
|
||||
fn support_ping(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn connect<'a>(
|
||||
&'a self,
|
||||
url: &'a Url,
|
||||
_mode: &'a ConnectionMode,
|
||||
timeout: Duration,
|
||||
) -> BoxedFuture<'a, Result<(WebSocketSink, WebSocketStream), TransportError>> {
|
||||
Box::pin(async move {
|
||||
let host = url
|
||||
.host_str()
|
||||
.ok_or_else(|| terr("relay url has no host"))?
|
||||
.to_string();
|
||||
let port = url.port().unwrap_or(match url.scheme() {
|
||||
"ws" => 80,
|
||||
_ => 443,
|
||||
});
|
||||
|
||||
// The shared mixnet tunnel (lazy-started at server boot).
|
||||
let tunnel = super::nymproc::wait_for_tunnel(timeout)
|
||||
.await
|
||||
.ok_or_else(|| terr("nym tunnel not ready"))?;
|
||||
|
||||
// Resolve the relay host through the mixnet (mix-dns), so no
|
||||
// clearnet DNS leak, then dial through the same tunnel.
|
||||
let addr = tokio::time::timeout(timeout, super::dns::resolve(&tunnel, &host, port))
|
||||
.await
|
||||
.map_err(|_| terr("mix-dns resolve timeout"))?
|
||||
.ok_or_else(|| terr(format!("mix-dns could not resolve relay host {host}")))?;
|
||||
let stream = tokio::time::timeout(timeout, tunnel.tcp_connect(addr))
|
||||
.await
|
||||
.map_err(|_| terr("nym tunnel connect timeout"))?
|
||||
.map_err(|e| terr(format!("nym tunnel connect failed: {e}")))?;
|
||||
|
||||
// Perform TLS (for wss) + websocket handshake over the mixnet
|
||||
// stream (rustls webpki roots; the ring provider is installed
|
||||
// once at gp-server startup — the Build 65/66 rule).
|
||||
let (ws, _response) = tokio::time::timeout(
|
||||
timeout,
|
||||
tokio_tungstenite::client_async_tls(url.as_str(), stream),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| terr("websocket handshake timeout"))?
|
||||
.map_err(|e| terr(format!("websocket handshake failed: {e}")))?;
|
||||
|
||||
let (tx, rx) = ws.split();
|
||||
|
||||
let sink: WebSocketSink = Box::new(NymSink(tx)) as WebSocketSink;
|
||||
let stream: WebSocketStream = Box::pin(rx.filter_map(|msg| async move {
|
||||
match msg {
|
||||
Ok(tg) => tg_to_message(tg).map(Ok),
|
||||
Err(e) => Some(Err(TransportError::backend(e))),
|
||||
}
|
||||
})) as WebSocketStream;
|
||||
|
||||
Ok((sink, stream))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a tungstenite message into an async-wsocket pool message.
|
||||
/// Returns `None` for raw frames (never surfaced while reading).
|
||||
fn tg_to_message(msg: TgMessage) -> Option<Message> {
|
||||
match msg {
|
||||
TgMessage::Text(text) => Some(Message::Text(text.to_string())),
|
||||
TgMessage::Binary(data) => Some(Message::Binary(data.to_vec())),
|
||||
TgMessage::Ping(data) => Some(Message::Ping(data.to_vec())),
|
||||
TgMessage::Pong(data) => Some(Message::Pong(data.to_vec())),
|
||||
TgMessage::Close(_) => Some(Message::Close(None)),
|
||||
TgMessage::Frame(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sink adapter converting pool messages into tungstenite messages.
|
||||
struct NymSink<S>(S);
|
||||
|
||||
impl<S> Sink<Message> for NymSink<S>
|
||||
where
|
||||
S: Sink<TgMessage, Error = tokio_tungstenite::tungstenite::Error> + Send + Unpin,
|
||||
{
|
||||
type Error = TransportError;
|
||||
|
||||
fn poll_ready(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
||||
Pin::new(&mut self.0)
|
||||
.poll_ready_unpin(cx)
|
||||
.map_err(TransportError::backend)
|
||||
}
|
||||
|
||||
fn start_send(mut self: Pin<&mut Self>, item: Message) -> Result<(), Self::Error> {
|
||||
Pin::new(&mut self.0)
|
||||
.start_send_unpin(TgMessage::from(item))
|
||||
.map_err(TransportError::backend)
|
||||
}
|
||||
|
||||
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
||||
Pin::new(&mut self.0)
|
||||
.poll_flush_unpin(cx)
|
||||
.map_err(TransportError::backend)
|
||||
}
|
||||
|
||||
fn poll_close(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
||||
Pin::new(&mut self.0)
|
||||
.poll_close_unpin(cx)
|
||||
.map_err(TransportError::backend)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
//! Goblin payment message protocol over NIP-17 (kind 14 rumors), ported from
|
||||
//! `goblin/src/nostr/protocol.rs` minus the request/void control messages a
|
||||
//! receive-only server never sends or honors.
|
||||
//!
|
||||
//! Content layout: a one-line human readable preamble, a blank line and the
|
||||
//! raw slatepack armor. The per-payment note travels in the standard
|
||||
//! `subject` tag; a `goblin` tag marks the protocol version. Classification
|
||||
//! NEVER trusts tags — only the parsed slate (gp-wallet enforces S1).
|
||||
|
||||
use nostr_sdk::{Tag, TagKind, Tags};
|
||||
|
||||
/// Maximum gift wrap content size accepted before unwrapping.
|
||||
pub const MAX_WRAP_CONTENT: usize = 64 * 1024;
|
||||
/// Maximum rumor content size accepted after unwrapping.
|
||||
pub const MAX_RUMOR_CONTENT: usize = 32 * 1024;
|
||||
/// Maximum slatepack armor size accepted.
|
||||
pub const MAX_SLATEPACK: usize = 30 * 1024;
|
||||
/// Maximum note length in characters after sanitization.
|
||||
pub const MAX_NOTE_CHARS: usize = 256;
|
||||
/// Protocol marker tag name.
|
||||
pub const GOBLIN_TAG: &str = "goblin";
|
||||
/// Protocol version value.
|
||||
pub const PROTOCOL_VERSION: &str = "1";
|
||||
|
||||
/// Human readable preamble other NIP-17 clients render.
|
||||
pub const PREAMBLE: &str =
|
||||
"[Goblin] GRIN payment message — open in Goblin (https://goblin.st) to process.";
|
||||
|
||||
const ARMOR_BEGIN: &str = "BEGINSLATEPACK.";
|
||||
const ARMOR_END: &str = "ENDSLATEPACK.";
|
||||
|
||||
/// Sanitize a user note: strip control characters, collapse whitespace,
|
||||
/// trim and cap the length. Returns `None` when nothing readable remains.
|
||||
pub fn sanitize_note(raw: &str) -> Option<String> {
|
||||
let cleaned: String = raw
|
||||
.chars()
|
||||
.map(|c| if c.is_control() { ' ' } else { c })
|
||||
.collect();
|
||||
let collapsed = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
let trimmed = collapsed.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(trimmed.chars().take(MAX_NOTE_CHARS).collect())
|
||||
}
|
||||
|
||||
/// Build the kind-14 rumor content for a slatepack payment message.
|
||||
pub fn build_payment_content(slatepack: &str) -> String {
|
||||
format!("{}\n\n{}", PREAMBLE, slatepack.trim())
|
||||
}
|
||||
|
||||
/// Build rumor tags: protocol marker plus optional subject note.
|
||||
pub fn build_rumor_tags(note: Option<&str>) -> Vec<Tag> {
|
||||
let mut tags = vec![Tag::custom(
|
||||
TagKind::custom(GOBLIN_TAG),
|
||||
[PROTOCOL_VERSION.to_string()],
|
||||
)];
|
||||
if let Some(note) = note.and_then(sanitize_note) {
|
||||
tags.push(Tag::custom(TagKind::custom("subject"), [note]));
|
||||
}
|
||||
tags
|
||||
}
|
||||
|
||||
/// Extract exactly one slatepack armor block from rumor content.
|
||||
/// More than one block, none at all, or an oversized block returns `None`.
|
||||
/// (Same semantics as Goblin's non-greedy `BEGINSLATEPACK. .. ENDSLATEPACK.`
|
||||
/// regex, hand-rolled so this crate needs no regex dependency.)
|
||||
pub fn extract_slatepack(content: &str) -> Option<String> {
|
||||
if content.len() > MAX_RUMOR_CONTENT {
|
||||
return None;
|
||||
}
|
||||
let start = content.find(ARMOR_BEGIN)?;
|
||||
let end_rel = content[start..].find(ARMOR_END)?;
|
||||
let end = start + end_rel + ARMOR_END.len();
|
||||
// A second complete block after the first is ambiguous: refuse.
|
||||
let rest = &content[end..];
|
||||
if let Some(next) = rest.find(ARMOR_BEGIN) {
|
||||
if rest[next..].contains(ARMOR_END) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let armor = content[start..end].trim().to_string();
|
||||
if armor.len() > MAX_SLATEPACK {
|
||||
return None;
|
||||
}
|
||||
Some(armor)
|
||||
}
|
||||
|
||||
/// Read the sanitized subject (note) from rumor tags.
|
||||
pub fn extract_subject(tags: &Tags) -> Option<String> {
|
||||
for tag in tags.iter() {
|
||||
let parts = tag.as_slice();
|
||||
if parts.first().map(|s| s.as_str()) == Some("subject") {
|
||||
if let Some(value) = parts.get(1) {
|
||||
return sanitize_note(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const PACK: &str = "BEGINSLATEPACK. 4H1qx1wHe668tFW yC2gfL8PPd8kSgv \
|
||||
pcXQhyRkHbyKHZg GN75o7uWoT3dkib R2tj1fFGN2FoRLY oeBPyKizupksgRT \
|
||||
dXFdjEuMUuktR5r gCiVBSXcHSWW3KW Y56LTQ9z3QwUWmE 8sRtwR9Bn8oNN5K \
|
||||
zYbR6XLkP8cSC7. ENDSLATEPACK.";
|
||||
|
||||
#[test]
|
||||
fn extracts_single_slatepack() {
|
||||
let content = format!("{}\n\n{}", PREAMBLE, PACK);
|
||||
let got = extract_slatepack(&content).unwrap();
|
||||
assert!(got.starts_with("BEGINSLATEPACK."));
|
||||
assert!(got.ends_with("ENDSLATEPACK."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_no_slatepack() {
|
||||
assert!(extract_slatepack("hi there, no payment here").is_none());
|
||||
assert!(extract_slatepack("").is_none());
|
||||
assert!(extract_slatepack("BEGINSLATEPACK. truncated junk").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_two_slatepacks() {
|
||||
let content = format!("{} {}", PACK, PACK);
|
||||
assert!(extract_slatepack(&content).is_none());
|
||||
// But trailing garbage with only a BEGIN marker is not a second block.
|
||||
let content = format!("{} BEGINSLATEPACK. trailing junk", PACK);
|
||||
assert!(extract_slatepack(&content).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_oversize() {
|
||||
let huge = format!(
|
||||
"BEGINSLATEPACK. {} ENDSLATEPACK.",
|
||||
"A".repeat(MAX_SLATEPACK + 1)
|
||||
);
|
||||
assert!(extract_slatepack(&huge).is_none());
|
||||
let oversize_content = "x".repeat(MAX_RUMOR_CONTENT + 1);
|
||||
assert!(extract_slatepack(&oversize_content).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitizes_notes() {
|
||||
assert_eq!(sanitize_note(" lunch :) "), Some("lunch :)".to_string()));
|
||||
assert_eq!(
|
||||
sanitize_note("a\u{0000}b\u{001b}[31mc"),
|
||||
Some("a b [31mc".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
sanitize_note("multi space\n\nnewline"),
|
||||
Some("multi space newline".to_string())
|
||||
);
|
||||
assert_eq!(sanitize_note("\u{0007}\u{0008}"), None);
|
||||
assert_eq!(sanitize_note(""), None);
|
||||
let long = "y".repeat(MAX_NOTE_CHARS + 50);
|
||||
assert_eq!(
|
||||
sanitize_note(&long).unwrap().chars().count(),
|
||||
MAX_NOTE_CHARS
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_content_with_preamble() {
|
||||
let c = build_payment_content(PACK);
|
||||
assert!(c.starts_with(PREAMBLE));
|
||||
assert!(extract_slatepack(&c).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subject_round_trips_through_tags() {
|
||||
let tags = Tags::from_list(build_rumor_tags(Some(" order #42 ")));
|
||||
assert_eq!(extract_subject(&tags), Some("order #42".to_string()));
|
||||
let no_note = Tags::from_list(build_rumor_tags(None));
|
||||
assert_eq!(extract_subject(&no_note), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
//! Server-signed payment receipt: the DM-less trust primitive.
|
||||
//!
|
||||
//! A [`Receipt`] bundles what a store needs to trust a payment without relying
|
||||
//! on a webhook payload or a DM: the payment id, amount, the on-chain kernel
|
||||
//! excess, the confirmation height, and (when present) the receiver-side Grin
|
||||
//! payment proof. [`sign_receipt`] signs it with the server's Nostr identity
|
||||
//! key (BIP-340 Schnorr over SHA-256 of the canonical JSON, the same signature
|
||||
//! scheme Nostr events use), producing a [`SignedReceipt`] any party can verify
|
||||
//! against the server's known public key with [`verify_receipt`].
|
||||
//!
|
||||
//! The receipt is a plain serde object, independent of Nostr event framing, so
|
||||
//! a store backend (Eranos, WooCommerce, ...) can verify it with any BIP-340
|
||||
//! implementation. It is safe to expose publicly: it reveals only what the
|
||||
//! payer already told the merchant, and it is self-authenticating.
|
||||
|
||||
use nostr_sdk::Keys;
|
||||
use secp256k1::hashes::{sha256, Hash};
|
||||
use secp256k1::schnorr::Signature;
|
||||
use secp256k1::{Keypair, SecretKey, XOnlyPublicKey, SECP256K1};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Current receipt schema version.
|
||||
pub const RECEIPT_VERSION: u8 = 1;
|
||||
|
||||
/// The signed payload. Field order is fixed (serde serializes structs in
|
||||
/// declaration order with no whitespace), so signer and verifier hash exactly
|
||||
/// the same bytes.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Receipt {
|
||||
/// Schema version (`1`).
|
||||
pub version: u8,
|
||||
/// Payment identifier (the Grin slate UUID; also the public status token).
|
||||
pub payment_id: String,
|
||||
/// Amount in nanogrin.
|
||||
pub amount: u64,
|
||||
/// Tx kernel excess commitment, hex — the on-chain anchor.
|
||||
pub kernel_excess: String,
|
||||
/// Block height the kernel confirmed at, if confirmed.
|
||||
pub confirmed_height: Option<u64>,
|
||||
/// Confirmation depth at issue time, if confirmed.
|
||||
pub confirmations: Option<u64>,
|
||||
/// The receiver-side Grin payment proof (as its own JSON object), when the
|
||||
/// payer requested one. A store can verify this independently.
|
||||
pub proof: Option<serde_json::Value>,
|
||||
/// Issue time, ISO-8601 UTC.
|
||||
pub issued_at: String,
|
||||
/// The server identity npub-hex (x-only) this receipt is about. Bound into
|
||||
/// the signature so a receipt cannot be replayed under another identity.
|
||||
pub server_pubkey: String,
|
||||
}
|
||||
|
||||
/// A [`Receipt`] plus the server's BIP-340 Schnorr signature over it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SignedReceipt {
|
||||
/// The signed payload.
|
||||
pub receipt: Receipt,
|
||||
/// Signature, hex (64 bytes).
|
||||
pub sig: String,
|
||||
}
|
||||
|
||||
/// Receipt signing/verification errors.
|
||||
#[derive(Debug)]
|
||||
pub enum ReceiptError {
|
||||
/// The identity secret key could not be used for signing.
|
||||
Key(String),
|
||||
/// Canonical serialization failed.
|
||||
Serialize(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ReceiptError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ReceiptError::Key(m) => write!(f, "receipt key error: {m}"),
|
||||
ReceiptError::Serialize(m) => write!(f, "receipt serialize error: {m}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ReceiptError {}
|
||||
|
||||
/// Sign a receipt with the server identity keys. The receipt's `server_pubkey`
|
||||
/// is overwritten with the signer's x-only key so the object is internally
|
||||
/// consistent regardless of what the caller passed.
|
||||
pub fn sign_receipt(keys: &Keys, mut receipt: Receipt) -> Result<SignedReceipt, ReceiptError> {
|
||||
let sk = SecretKey::from_byte_array(keys.secret_key().to_secret_bytes())
|
||||
.map_err(|e| ReceiptError::Key(format!("bad identity secret key: {e}")))?;
|
||||
let keypair = Keypair::from_secret_key(SECP256K1, &sk);
|
||||
let (xonly, _parity) = keypair.x_only_public_key();
|
||||
receipt.server_pubkey = encode_hex(&xonly.serialize());
|
||||
|
||||
let digest = receipt_digest(&receipt)?;
|
||||
let sig = SECP256K1.sign_schnorr_no_aux_rand(&digest, &keypair);
|
||||
Ok(SignedReceipt {
|
||||
receipt,
|
||||
sig: encode_hex(&sig.to_byte_array()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Verify a signed receipt's signature against the public key embedded in the
|
||||
/// receipt (`receipt.server_pubkey`). Returns `false` on any malformed field
|
||||
/// or signature mismatch. Callers must still check the embedded key is the
|
||||
/// server they trust (or use [`verify_receipt_from`]).
|
||||
pub fn verify_receipt(signed: &SignedReceipt) -> bool {
|
||||
let Ok(digest) = receipt_digest(&signed.receipt) else {
|
||||
return false;
|
||||
};
|
||||
let Some(pk_bytes) = decode_fixed::<32>(&signed.receipt.server_pubkey) else {
|
||||
return false;
|
||||
};
|
||||
let Some(sig_bytes) = decode_fixed::<64>(&signed.sig) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(pubkey) = XOnlyPublicKey::from_byte_array(pk_bytes) else {
|
||||
return false;
|
||||
};
|
||||
let signature = Signature::from_byte_array(sig_bytes);
|
||||
SECP256K1
|
||||
.verify_schnorr(&signature, &digest, &pubkey)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
/// Verify a signed receipt AND that it was signed by `expected_pubkey_hex`
|
||||
/// (the x-only server identity a store trusts out of band).
|
||||
pub fn verify_receipt_from(signed: &SignedReceipt, expected_pubkey_hex: &str) -> bool {
|
||||
signed
|
||||
.receipt
|
||||
.server_pubkey
|
||||
.eq_ignore_ascii_case(expected_pubkey_hex.trim())
|
||||
&& verify_receipt(signed)
|
||||
}
|
||||
|
||||
/// SHA-256 of the canonical receipt JSON (the signed message).
|
||||
fn receipt_digest(receipt: &Receipt) -> Result<[u8; 32], ReceiptError> {
|
||||
let bytes = serde_json::to_vec(receipt).map_err(|e| ReceiptError::Serialize(e.to_string()))?;
|
||||
Ok(sha256::Hash::hash(&bytes).to_byte_array())
|
||||
}
|
||||
|
||||
fn encode_hex(bytes: &[u8]) -> String {
|
||||
let mut s = String::with_capacity(bytes.len() * 2);
|
||||
for b in bytes {
|
||||
s.push_str(&format!("{b:02x}"));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn decode_fixed<const N: usize>(hex: &str) -> Option<[u8; N]> {
|
||||
let hex = hex.trim();
|
||||
if hex.len() != N * 2 {
|
||||
return None;
|
||||
}
|
||||
let mut out = [0u8; N];
|
||||
for (i, byte) in out.iter_mut().enumerate() {
|
||||
*byte = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok()?;
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample() -> Receipt {
|
||||
Receipt {
|
||||
version: RECEIPT_VERSION,
|
||||
payment_id: "b6f7c2a0-1234-5678-9abc-def012345678".into(),
|
||||
amount: 2_500_000_000,
|
||||
kernel_excess: "09".repeat(33),
|
||||
confirmed_height: Some(3_900_000),
|
||||
confirmations: Some(11),
|
||||
proof: Some(serde_json::json!({
|
||||
"amount": 2_500_000_000u64,
|
||||
"kernel_excess": "09".repeat(33),
|
||||
})),
|
||||
issued_at: "2026-07-01T12:00:00Z".into(),
|
||||
server_pubkey: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sign_then_verify_round_trips() {
|
||||
let keys = Keys::generate();
|
||||
let signed = sign_receipt(&keys, sample()).unwrap();
|
||||
assert!(verify_receipt(&signed));
|
||||
// The embedded pubkey is the signer's x-only key.
|
||||
assert_eq!(signed.receipt.server_pubkey, keys.public_key().to_hex());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_from_matches_expected_key_only() {
|
||||
let keys = Keys::generate();
|
||||
let signed = sign_receipt(&keys, sample()).unwrap();
|
||||
assert!(verify_receipt_from(&signed, &keys.public_key().to_hex()));
|
||||
// A different expected key is rejected even though the sig is valid.
|
||||
let other = Keys::generate();
|
||||
assert!(!verify_receipt_from(&signed, &other.public_key().to_hex()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampering_any_field_breaks_verification() {
|
||||
let keys = Keys::generate();
|
||||
let signed = sign_receipt(&keys, sample()).unwrap();
|
||||
|
||||
let mut t = signed.clone();
|
||||
t.receipt.amount += 1;
|
||||
assert!(!verify_receipt(&t), "amount tamper must fail");
|
||||
|
||||
let mut t = signed.clone();
|
||||
t.receipt.confirmed_height = Some(999_999);
|
||||
assert!(!verify_receipt(&t), "height tamper must fail");
|
||||
|
||||
let mut t = signed.clone();
|
||||
t.receipt.kernel_excess = "0a".repeat(33);
|
||||
assert!(!verify_receipt(&t), "excess tamper must fail");
|
||||
|
||||
let mut t = signed.clone();
|
||||
t.receipt.proof = Some(serde_json::json!({"amount": 1}));
|
||||
assert!(!verify_receipt(&t), "proof tamper must fail");
|
||||
|
||||
let mut t = signed.clone();
|
||||
t.receipt.payment_id = "other".into();
|
||||
assert!(!verify_receipt(&t), "id tamper must fail");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_from_another_key_is_rejected() {
|
||||
let keys = Keys::generate();
|
||||
let mut signed = sign_receipt(&keys, sample()).unwrap();
|
||||
// Replace the embedded pubkey with a stranger's: the signature no
|
||||
// longer matches the (now different) advertised signer.
|
||||
let other = Keys::generate();
|
||||
signed.receipt.server_pubkey = other.public_key().to_hex();
|
||||
assert!(!verify_receipt(&signed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_fields_do_not_panic() {
|
||||
let keys = Keys::generate();
|
||||
let mut signed = sign_receipt(&keys, sample()).unwrap();
|
||||
signed.sig = "zz".into();
|
||||
assert!(!verify_receipt(&signed));
|
||||
signed.sig = String::new();
|
||||
assert!(!verify_receipt(&signed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_round_trips() {
|
||||
let keys = Keys::generate();
|
||||
let signed = sign_receipt(&keys, sample()).unwrap();
|
||||
let json = serde_json::to_string(&signed).unwrap();
|
||||
let back: SignedReceipt = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(signed, back);
|
||||
assert!(verify_receipt(&back));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//! Default relay set and helpers (mirrors `goblin/src/nostr/relays.rs`).
|
||||
|
||||
/// 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",
|
||||
];
|
||||
|
||||
/// 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()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resolves_defaults_and_overrides() {
|
||||
assert_eq!(resolve(&[]), DEFAULT_RELAYS.to_vec());
|
||||
let own = vec!["wss://relay.example".to_string()];
|
||||
assert_eq!(resolve(&own), own);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
//! The daemon service loop, adapted from `goblin/src/nostr/client.rs`
|
||||
//! (`run_service`): connect the relay pool over the in-process Nym mixnet,
|
||||
//! publish the kind 10050 inbox (with the NIP-17 `encryption` capability
|
||||
//! tag) and its kind 10002 mirror, catch up on missed gift wraps, subscribe
|
||||
//! live, and for every received payment dispatch the S2 reply to the payer's
|
||||
//! advertised relays (their 10050; our own set as the fallback), encrypted
|
||||
//! with the best mutual NIP-44 version.
|
||||
//!
|
||||
//! No UI, no contacts, no relay-pool gist (G10 is pending): the relay set is
|
||||
//! configuration plus defaults.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use log::{error, info, warn};
|
||||
use nostr_sdk::{
|
||||
Client, Event, EventBuilder, Filter, Keys, Kind, PublicKey, RelayPoolNotification, RelayUrl,
|
||||
Tag, TagKind, Timestamp,
|
||||
};
|
||||
|
||||
use crate::ingest::{Ingest, IngestOutcome, PendingReply};
|
||||
use crate::nym::NymWebSocketTransport;
|
||||
use crate::relays::MAX_DM_RELAYS;
|
||||
use crate::unix_time;
|
||||
use crate::wrap;
|
||||
use crate::{KeyDirectory, MasterDirectory, SlatepackReceiver};
|
||||
|
||||
/// Subscription look-back window: gift wrap timestamps are randomized up to
|
||||
/// 2 days into the past (NIP-59), use 3 (Goblin's constant). Cross-restart
|
||||
/// dedupe is the wallet's already-received guard plus the payment table.
|
||||
const LOOKBACK_SECS: i64 = 3 * 86_400;
|
||||
/// Catch-up fetch timeout.
|
||||
const FETCH_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
/// Send dispatch timeout.
|
||||
const SEND_TIMEOUT: Duration = Duration::from_secs(40);
|
||||
/// How long to wait for the mixnet tunnel before dialing relays anyway.
|
||||
const NYM_WARM_WAIT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Service configuration (already resolved from the environment).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ServiceOptions {
|
||||
/// Relay set to listen on and publish to.
|
||||
pub relays: Vec<String>,
|
||||
/// Route everything over the Nym mixnet (default on; clearnet is a
|
||||
/// debugging escape hatch only).
|
||||
pub nym: bool,
|
||||
/// Optional NIP-17 payment DMs (milestone 6, all off by default).
|
||||
pub notify: NotifyOptions,
|
||||
}
|
||||
|
||||
/// Optional payment-notification DMs (milestone 6). Both are off by default.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct NotifyOptions {
|
||||
/// Merchant public key for the confirmed-payment DM.
|
||||
pub merchant: Option<PublicKey>,
|
||||
/// Send the merchant a NIP-17 DM on a received payment.
|
||||
pub merchant_dm: bool,
|
||||
/// Send the payer a NIP-17 receipt DM.
|
||||
pub payer_receipt: bool,
|
||||
}
|
||||
|
||||
/// Merchant DM text for a received payment.
|
||||
pub fn merchant_dm_text(amount: u64, slate_id: &str) -> String {
|
||||
format!(
|
||||
"[GoblinPay] Received {} GRIN (slate {}).",
|
||||
gp_core::webhook::nanogrin_to_grin(amount),
|
||||
slate_id
|
||||
)
|
||||
}
|
||||
|
||||
/// Payer receipt DM text.
|
||||
pub fn payer_receipt_text(amount: u64) -> String {
|
||||
format!(
|
||||
"[GoblinPay] Payment of {} GRIN received. Thank you.",
|
||||
gp_core::webhook::nanogrin_to_grin(amount)
|
||||
)
|
||||
}
|
||||
|
||||
/// Start the ingest service on its own thread with its own tokio runtime
|
||||
/// (mirrors Goblin's service thread; keeps relay I/O off the HTTP runtime).
|
||||
/// Watches the master identity only.
|
||||
pub fn spawn<R>(keys: Keys, opts: ServiceOptions, receiver: R) -> std::thread::JoinHandle<()>
|
||||
where
|
||||
R: SlatepackReceiver + 'static,
|
||||
{
|
||||
let directory: Arc<dyn KeyDirectory> = Arc::new(MasterDirectory(keys.clone()));
|
||||
spawn_with_directory(keys, opts, receiver, directory)
|
||||
}
|
||||
|
||||
/// Like [`spawn`] but with a multi-identity directory (master + per-invoice and
|
||||
/// per-user derived children), so payments to any watched endpub are received
|
||||
/// and replied from the right identity.
|
||||
pub fn spawn_with_directory<R>(
|
||||
keys: Keys,
|
||||
opts: ServiceOptions,
|
||||
receiver: R,
|
||||
directory: Arc<dyn KeyDirectory>,
|
||||
) -> std::thread::JoinHandle<()>
|
||||
where
|
||||
R: SlatepackReceiver + 'static,
|
||||
{
|
||||
std::thread::Builder::new()
|
||||
.name("gp-nostr".into())
|
||||
.spawn(move || {
|
||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(2)
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("build gp-nostr runtime");
|
||||
rt.block_on(run(keys, opts, receiver, directory));
|
||||
})
|
||||
.expect("spawn gp-nostr thread")
|
||||
}
|
||||
|
||||
/// The service loop. Runs until the process exits (a payment server has no
|
||||
/// reason to stop listening).
|
||||
pub async fn run<R: SlatepackReceiver>(
|
||||
keys: Keys,
|
||||
opts: ServiceOptions,
|
||||
receiver: R,
|
||||
directory: Arc<dyn KeyDirectory>,
|
||||
) {
|
||||
let client = if opts.nym {
|
||||
// Wait for the in-process Nym mixnet tunnel before any network work:
|
||||
// dialing before it is up drops every relay into the pool's
|
||||
// backing-off reconnect (Goblin's wallet-open ordering lesson).
|
||||
crate::nym::warm_up();
|
||||
let waited = std::time::Instant::now();
|
||||
while !crate::nym::is_ready() && waited.elapsed() < NYM_WARM_WAIT {
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
if crate::nym::is_ready() {
|
||||
info!(
|
||||
"nostr: Nym tunnel ready after ~{}ms",
|
||||
waited.elapsed().as_millis()
|
||||
);
|
||||
} else {
|
||||
warn!("nostr: Nym tunnel still warming; relays will retry through it");
|
||||
}
|
||||
Client::builder()
|
||||
.websocket_transport(NymWebSocketTransport)
|
||||
.build()
|
||||
} else {
|
||||
warn!("nostr: GP_NYM=off — relay traffic goes CLEARNET (debugging only)");
|
||||
Client::builder().build()
|
||||
};
|
||||
|
||||
let ingest = Ingest::with_directory(keys.clone(), receiver, directory);
|
||||
let npub_prefix: String = keys.public_key().to_hex().chars().take(8).collect();
|
||||
info!(
|
||||
"nostr: starting service for {npub_prefix}… with {} relay(s)",
|
||||
opts.relays.len()
|
||||
);
|
||||
for relay in &opts.relays {
|
||||
if let Err(e) = client.add_relay(relay.clone()).await {
|
||||
warn!("nostr: add relay failed: {e}");
|
||||
}
|
||||
}
|
||||
client.connect().await;
|
||||
|
||||
// Publish the replaceable identity events: kind 10050 DM relays with the
|
||||
// encryption capability tag, plus the kind 10002 (NIP-65) mirror. No
|
||||
// kind 0 — the till is anonymous by design.
|
||||
publish_inbox(&client, &keys, &opts.relays).await;
|
||||
|
||||
// Re-dispatch stored replies that never verifiably left (crash between
|
||||
// receive_tx and the reply send) before processing anything new.
|
||||
reconcile(&client, &ingest, &opts.relays).await;
|
||||
|
||||
// Catch-up + live subscription for gift wraps addressed to any identity we
|
||||
// watch: the master, plus per-invoice (matching mode 2) and per-user (5b)
|
||||
// derived children the directory currently holds. Targeted at our OWN
|
||||
// advertised set only (a pool-wide subscription would leak the listener
|
||||
// filter to relays added later for reply fan-out). The watched set is
|
||||
// snapshotted here; rotation refreshes it on the next service restart or
|
||||
// re-subscribe (a live refresh tick is the multi-tenant follow-up).
|
||||
let since = (unix_time() - LOOKBACK_SECS).max(0) as u64;
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::GiftWrap)
|
||||
.pubkeys(ingest.watched())
|
||||
.since(Timestamp::from_secs(since));
|
||||
match client
|
||||
.fetch_events_from(&opts.relays, filter.clone(), FETCH_TIMEOUT)
|
||||
.await
|
||||
{
|
||||
Ok(events) => {
|
||||
info!("nostr: catch-up fetched {} wrap(s)", events.len());
|
||||
for event in events.into_iter() {
|
||||
handle(&client, &ingest, &keys, &opts.notify, &event, &opts.relays).await;
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("nostr: catch-up fetch failed: {e}"),
|
||||
}
|
||||
if let Err(e) = client.subscribe_to(&opts.relays, filter, None).await {
|
||||
error!("nostr: subscribe failed: {e}");
|
||||
}
|
||||
|
||||
let mut notifications = client.notifications();
|
||||
loop {
|
||||
match notifications.recv().await {
|
||||
Ok(RelayPoolNotification::Event { event, .. }) => {
|
||||
handle(&client, &ingest, &keys, &opts.notify, &event, &opts.relays).await;
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||
warn!("nostr: notifications lagged by {n}");
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
error!("nostr: notification stream closed; service stopped");
|
||||
}
|
||||
|
||||
/// Handle one incoming event end to end: ingest, dispatch the reply, then
|
||||
/// (if configured) send the optional merchant / payer NIP-17 DMs.
|
||||
async fn handle<R: SlatepackReceiver>(
|
||||
client: &Client,
|
||||
ingest: &Ingest<R>,
|
||||
keys: &Keys,
|
||||
notify: &NotifyOptions,
|
||||
event: &Event,
|
||||
own_relays: &[String],
|
||||
) {
|
||||
match ingest.handle_wrap(event).await {
|
||||
IngestOutcome::Received {
|
||||
slate_id,
|
||||
amount,
|
||||
reply,
|
||||
} => {
|
||||
// Optional notifications (M6): merchant DM from the server identity,
|
||||
// payer receipt from the identity that received. Best effort; a
|
||||
// failed DM never affects the money or the reply.
|
||||
if notify.merchant_dm {
|
||||
if let Some(merchant) = ¬ify.merchant {
|
||||
send_dm(
|
||||
client,
|
||||
keys,
|
||||
merchant,
|
||||
merchant_dm_text(amount, &slate_id),
|
||||
own_relays,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
if notify.payer_receipt {
|
||||
send_dm(
|
||||
client,
|
||||
&reply.from,
|
||||
&reply.payer,
|
||||
payer_receipt_text(amount),
|
||||
own_relays,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
if deliver_reply(client, &reply, own_relays).await {
|
||||
ingest.receiver().mark_replied(&slate_id).await;
|
||||
} else {
|
||||
// Left in status 'received': the boot-time reconcile (or a
|
||||
// restart) re-sends it. The payment itself is safe in the
|
||||
// wallet either way.
|
||||
warn!("nostr: S2 reply dispatch failed for slate {slate_id}, will reconcile");
|
||||
}
|
||||
}
|
||||
IngestOutcome::Dropped(reason) => {
|
||||
info!("nostr: dropped wrap {}…: {reason}", &event.id.to_hex()[..8]);
|
||||
}
|
||||
IngestOutcome::RateLimited => {}
|
||||
IngestOutcome::Failed(e) => {
|
||||
error!("nostr: receive failed (will retry on catch-up): {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Gift wrap and publish one S2 reply, FROM the identity that received the
|
||||
/// payment (master or the derived child the payer addressed). Targets the
|
||||
/// payer's advertised 10050 relays when discoverable, else our own set
|
||||
/// (Goblin's send-target fallback); the encryption version is the best mutual
|
||||
/// method from the same 10050 (absent = v2). Returns true when a relay
|
||||
/// accepted the event.
|
||||
async fn deliver_reply(client: &Client, reply: &PendingReply, own_relays: &[String]) -> bool {
|
||||
let (mut targets, encryption) = recipient_hints(client, &reply.payer, own_relays).await;
|
||||
if targets.is_empty() {
|
||||
// NIP-17 pragmatic fallback: the wrap reached us through a shared
|
||||
// relay, so our own set is the best remaining route.
|
||||
targets = own_relays.to_vec();
|
||||
}
|
||||
let version = wrap::choose_version(encryption.as_deref());
|
||||
let event = match wrap::gift_wrap(&reply.from, &reply.payer, reply.rumor.clone(), version) {
|
||||
Ok(event) => event,
|
||||
Err(e) => {
|
||||
error!("nostr: reply wrap failed: {e}");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
// Dial any target relays we don't already hold (the payer's relays may
|
||||
// differ from ours), then publish to exactly that set.
|
||||
connect_relays(client, &targets).await;
|
||||
match tokio::time::timeout(SEND_TIMEOUT, client.send_event_to(&targets, &event)).await {
|
||||
Ok(Ok(output)) => {
|
||||
info!(
|
||||
"nostr: S2 reply {}… published ({:?}, {} relay(s) ok)",
|
||||
&output.val.to_hex()[..8],
|
||||
version,
|
||||
output.success.len()
|
||||
);
|
||||
!output.success.is_empty()
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
warn!("nostr: reply publish failed: {e}");
|
||||
false
|
||||
}
|
||||
Err(_) => {
|
||||
warn!("nostr: reply publish timed out");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a plain NIP-17 DM `from` an identity `to` a recipient (the optional
|
||||
/// M6 merchant/payer notifications). Version is negotiated from the
|
||||
/// recipient's 10050 like a reply; best effort, errors are logged only.
|
||||
async fn send_dm(
|
||||
client: &Client,
|
||||
from: &Keys,
|
||||
to: &PublicKey,
|
||||
content: String,
|
||||
own_relays: &[String],
|
||||
) {
|
||||
let rumor = EventBuilder::new(Kind::PrivateDirectMessage, content)
|
||||
.tags([Tag::public_key(*to)])
|
||||
.build(from.public_key());
|
||||
let (mut targets, encryption) = recipient_hints(client, to, own_relays).await;
|
||||
if targets.is_empty() {
|
||||
targets = own_relays.to_vec();
|
||||
}
|
||||
let version = wrap::choose_version(encryption.as_deref());
|
||||
let event = match wrap::gift_wrap(from, to, rumor, version) {
|
||||
Ok(event) => event,
|
||||
Err(e) => {
|
||||
warn!("nostr: notify DM wrap failed: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
connect_relays(client, &targets).await;
|
||||
match tokio::time::timeout(SEND_TIMEOUT, client.send_event_to(&targets, &event)).await {
|
||||
Ok(Ok(_)) => info!("nostr: notify DM sent to {}…", &to.to_hex()[..8]),
|
||||
Ok(Err(e)) => warn!("nostr: notify DM send failed: {e}"),
|
||||
Err(_) => warn!("nostr: notify DM send timed out"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch the payer's kind 10050: their advertised DM relays (capped) and the
|
||||
/// `encryption` capability tag. Queried from our own relay set — most Goblin
|
||||
/// peers share the Goblin relay; the discovery-indexer fan-out arrives with
|
||||
/// the G10 relay-strategy work.
|
||||
async fn recipient_hints(
|
||||
client: &Client,
|
||||
payer: &PublicKey,
|
||||
own_relays: &[String],
|
||||
) -> (Vec<String>, Option<String>) {
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::InboxRelays)
|
||||
.author(*payer)
|
||||
.limit(1);
|
||||
let events = match client
|
||||
.fetch_events_from(own_relays, filter, FETCH_TIMEOUT)
|
||||
.await
|
||||
{
|
||||
Ok(events) => events,
|
||||
Err(e) => {
|
||||
warn!("nostr: 10050 lookup failed: {e}");
|
||||
return (vec![], None);
|
||||
}
|
||||
};
|
||||
let Some(event) = events.first() else {
|
||||
return (vec![], None);
|
||||
};
|
||||
let mut relays = vec![];
|
||||
for tag in event.tags.iter() {
|
||||
let parts = tag.as_slice();
|
||||
if parts.first().map(|s| s.as_str()) == Some("relay") {
|
||||
if let Some(url) = parts.get(1) {
|
||||
if relays.len() < MAX_DM_RELAYS {
|
||||
relays.push(url.trim_end_matches('/').to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(relays, wrap::encryption_capability(event))
|
||||
}
|
||||
|
||||
/// Publish the kind 10050 inbox (relay tags + encryption capability) and the
|
||||
/// kind 10002 mirror, signed once, to the advertised set.
|
||||
async fn publish_inbox(client: &Client, keys: &Keys, relays: &[String]) {
|
||||
let advertised: Vec<String> = relays.iter().take(MAX_DM_RELAYS).cloned().collect();
|
||||
let mut dm_tags: Vec<Tag> = advertised
|
||||
.iter()
|
||||
.map(|r| Tag::custom(TagKind::custom("relay"), [r.clone()]))
|
||||
.collect();
|
||||
// The NIP-17 extension: ["encryption", "nip44_v3 nip44_v2"], best first.
|
||||
dm_tags.push(wrap::capability_tag());
|
||||
|
||||
let builders = vec![
|
||||
EventBuilder::new(Kind::InboxRelays, "").tags(dm_tags),
|
||||
// The NIP-65 list mirrors the same set, unmarked (read + write).
|
||||
EventBuilder::relay_list(
|
||||
advertised
|
||||
.iter()
|
||||
.filter_map(|r| RelayUrl::parse(r).ok())
|
||||
.map(|u| (u, None)),
|
||||
),
|
||||
];
|
||||
for builder in builders {
|
||||
match builder.sign_with_keys(keys) {
|
||||
Ok(event) => {
|
||||
if let Err(e) = client.send_event_to(&advertised, &event).await {
|
||||
warn!("nostr: publish kind {} failed: {e}", event.kind);
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("nostr: identity event signing failed: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-dispatch stored S2 replies that never verifiably left (Goblin's
|
||||
/// reconcile, narrowed to the one message type a till sends).
|
||||
async fn reconcile<R: SlatepackReceiver>(
|
||||
client: &Client,
|
||||
ingest: &Ingest<R>,
|
||||
own_relays: &[String],
|
||||
) {
|
||||
for pending in ingest.receiver().unreplied().await {
|
||||
let Ok(payer) = PublicKey::from_hex(&pending.payer_hex) else {
|
||||
warn!(
|
||||
"nostr: reconcile skipped slate {} (bad payer key)",
|
||||
pending.slate_id
|
||||
);
|
||||
continue;
|
||||
};
|
||||
// Rebuild the identity that received it, so the re-dispatched reply is
|
||||
// signed by the same key (master or the derived child) the payer paid.
|
||||
let Some(from) = ingest.resolve(&pending.recipient_hex) else {
|
||||
warn!(
|
||||
"nostr: reconcile skipped slate {} (unwatched recipient)",
|
||||
pending.slate_id
|
||||
);
|
||||
continue;
|
||||
};
|
||||
info!(
|
||||
"nostr: reconcile re-dispatch S2 for slate {}",
|
||||
pending.slate_id
|
||||
);
|
||||
let reply = ingest.build_reply(from, payer, &pending.s2_armor);
|
||||
if deliver_reply(client, &reply, own_relays).await {
|
||||
ingest.receiver().mark_replied(&pending.slate_id).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add + dial every relay in `urls` so a targeted send reaches relays we
|
||||
/// don't already hold (Goblin's `connect_relays`: idempotent add, short
|
||||
/// bounded dial, concurrent so one dead relay doesn't stall the rest).
|
||||
async fn connect_relays(client: &Client, urls: &[String]) {
|
||||
let dials = urls.iter().map(|url| {
|
||||
let url = url.clone();
|
||||
async move {
|
||||
let _ = client.add_relay(&url).await;
|
||||
// Short cap: a reachable relay connects in ~2-4s over the mixnet;
|
||||
// one dead relay in the list must not stall the whole send.
|
||||
let _ = client.try_connect_relay(&url, Duration::from_secs(6)).await;
|
||||
}
|
||||
});
|
||||
async_wsocket::futures_util::future::join_all(dials).await;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn notification_dm_text() {
|
||||
assert_eq!(
|
||||
merchant_dm_text(2_500_000_000, "slate-1"),
|
||||
"[GoblinPay] Received 2.5 GRIN (slate slate-1)."
|
||||
);
|
||||
assert_eq!(
|
||||
payer_receipt_text(1_000_000_000),
|
||||
"[GoblinPay] Payment of 1 GRIN received. Thank you."
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
//! NIP-59 gift wrap build/unwrap with the NIP-17 backward-compat extension
|
||||
//! (NIP-44 v3 context binding), all in one place.
|
||||
//!
|
||||
//! - **v2** rides nostr-sdk exactly as Goblin ships today: the seal content
|
||||
//! is `nip44::encrypt(.., Version::V2)` and the outer wrap goes through
|
||||
//! nostr-sdk's own `EventBuilder::gift_wrap_from_seal` (which hardcodes
|
||||
//! v2 — the reason v3 needs the manual path below).
|
||||
//! - **v3** is built manually against the companion `nip44` crate: the seal
|
||||
//! (kind 13) encrypts the rumor JSON with context `kind=13, scope=""`, the
|
||||
//! wrap (kind 1059) encrypts the seal JSON with `kind=1059, scope=""`, per
|
||||
//! the extension spec. Everything else mirrors what nostr-sdk does for v2:
|
||||
//! `rumor.ensure_id()`, seal signed by the sender with NO tags, wrap signed
|
||||
//! by a fresh ephemeral key with the receiver `p` tag, and `created_at`
|
||||
//! fuzzed up to two days into the past on both.
|
||||
//! - **Decrypt** dispatches per layer on the payload version byte
|
||||
//! (`0x02`/`0x03`), so mixed peers interoperate and a v2-only Goblin can
|
||||
//! always read us.
|
||||
//!
|
||||
//! Negotiation: we advertise `["encryption", "nip44_v3 nip44_v2"]` on our
|
||||
//! kind 10050; on send we take the FIRST method of the recipient's
|
||||
//! (best-first) list that we support; no tag means v2 only.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use nostr_sdk::nips::nip44 as sdk_nip44;
|
||||
use nostr_sdk::nips::nip59::RANGE_RANDOM_TIMESTAMP_TWEAK;
|
||||
use nostr_sdk::{
|
||||
Event, EventBuilder, JsonUtil, Keys, Kind, PublicKey, Tag, Timestamp, UnsignedEvent,
|
||||
};
|
||||
|
||||
/// Tag name on kind 10050 advertising supported encryption methods.
|
||||
pub const ENCRYPTION_TAG: &str = "encryption";
|
||||
/// Our capabilities, space separated, best first.
|
||||
pub const ENCRYPTION_CAPABILITIES: &str = "nip44_v3 nip44_v2";
|
||||
|
||||
/// v3 context values fixed by the NIP-17 extension: seals bind `kind=13`,
|
||||
/// gift wraps bind `kind=1059`, scope is empty for both.
|
||||
const SEAL_KIND: u32 = 13;
|
||||
const WRAP_KIND: u32 = 1059;
|
||||
const EMPTY_SCOPE: &[u8] = b"";
|
||||
|
||||
/// Which NIP-44 version to encrypt a seal + wrap with.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum WrapVersion {
|
||||
V2,
|
||||
V3,
|
||||
}
|
||||
|
||||
/// Errors from wrapping or unwrapping.
|
||||
#[derive(Debug)]
|
||||
pub enum WrapError {
|
||||
/// The outer event is not a kind 1059 gift wrap.
|
||||
NotGiftWrap,
|
||||
/// The decrypted inner event is not a kind 13 seal.
|
||||
NotSeal,
|
||||
/// The rumor author does not match the seal signer (NIP-17 requirement).
|
||||
SenderMismatch,
|
||||
/// Encryption/decryption failure (wrong key, bad MAC, bad context, ...).
|
||||
Crypto(String),
|
||||
/// Event build/parse/signature failure.
|
||||
Event(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for WrapError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
WrapError::NotGiftWrap => write!(f, "not a gift wrap event"),
|
||||
WrapError::NotSeal => write!(f, "inner event is not a seal"),
|
||||
WrapError::SenderMismatch => write!(f, "rumor author differs from seal signer"),
|
||||
WrapError::Crypto(m) => write!(f, "wrap crypto error: {m}"),
|
||||
WrapError::Event(m) => write!(f, "wrap event error: {m}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for WrapError {}
|
||||
|
||||
/// An unwrapped gift: the seal-verified sender and the rumor. Mirrors
|
||||
/// nostr-sdk's `UnwrappedGift`, produced by the version-dispatching path.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Unwrapped {
|
||||
/// The seal signer (verified signature) — the authenticated sender.
|
||||
pub sender: PublicKey,
|
||||
/// The unsigned rumor.
|
||||
pub rumor: UnsignedEvent,
|
||||
}
|
||||
|
||||
/// Pick the encryption version for a recipient from their kind 10050
|
||||
/// `encryption` tag value (space separated, best first). The best mutual
|
||||
/// method wins in THEIR preference order; an absent tag, or a tag with no
|
||||
/// mutual method, means the mandatory v2 baseline.
|
||||
pub fn choose_version(recipient_encryption: Option<&str>) -> WrapVersion {
|
||||
if let Some(tag) = recipient_encryption {
|
||||
for method in tag.split_whitespace() {
|
||||
match method {
|
||||
"nip44_v3" => return WrapVersion::V3,
|
||||
"nip44_v2" => return WrapVersion::V2,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
WrapVersion::V2
|
||||
}
|
||||
|
||||
/// Read the `encryption` tag value from a kind 10050 event, if present.
|
||||
pub fn encryption_capability(event: &Event) -> Option<String> {
|
||||
for tag in event.tags.iter() {
|
||||
let parts = tag.as_slice();
|
||||
if parts.first().map(|s| s.as_str()) == Some(ENCRYPTION_TAG) {
|
||||
return parts.get(1).cloned();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The `encryption` tag we publish on our own kind 10050.
|
||||
pub fn capability_tag() -> Tag {
|
||||
Tag::custom(
|
||||
nostr_sdk::TagKind::custom(ENCRYPTION_TAG),
|
||||
[ENCRYPTION_CAPABILITIES.to_string()],
|
||||
)
|
||||
}
|
||||
|
||||
/// Gift wrap `rumor` from `sender` to `receiver` with the given version.
|
||||
pub fn gift_wrap(
|
||||
sender: &Keys,
|
||||
receiver: &PublicKey,
|
||||
mut rumor: UnsignedEvent,
|
||||
version: WrapVersion,
|
||||
) -> Result<Event, WrapError> {
|
||||
// Fix the rumor id BEFORE encrypting, exactly like nostr-sdk's
|
||||
// `make_seal`, so both peers agree on the rumor identity.
|
||||
rumor.ensure_id();
|
||||
match version {
|
||||
WrapVersion::V2 => {
|
||||
let content = sdk_nip44::encrypt(
|
||||
sender.secret_key(),
|
||||
receiver,
|
||||
rumor.as_json(),
|
||||
sdk_nip44::Version::V2,
|
||||
)
|
||||
.map_err(|e| WrapError::Crypto(e.to_string()))?;
|
||||
let seal = EventBuilder::new(Kind::Seal, content)
|
||||
.custom_created_at(Timestamp::tweaked(RANGE_RANDOM_TIMESTAMP_TWEAK))
|
||||
.sign_with_keys(sender)
|
||||
.map_err(|e| WrapError::Event(e.to_string()))?;
|
||||
// The proven nostr-sdk outer wrap (ephemeral key, `p` tag,
|
||||
// created_at fuzz — and v2 encryption, which is what we want here).
|
||||
EventBuilder::gift_wrap_from_seal(receiver, &seal, [])
|
||||
.map_err(|e| WrapError::Event(e.to_string()))
|
||||
}
|
||||
WrapVersion::V3 => {
|
||||
let ck = v3_conversation_key(sender, receiver)?;
|
||||
let content =
|
||||
nip44::encrypt_v3(&ck, rumor.as_json().as_bytes(), SEAL_KIND, EMPTY_SCOPE)
|
||||
.map_err(|e| WrapError::Crypto(e.to_string()))?;
|
||||
let seal = EventBuilder::new(Kind::Seal, content)
|
||||
.custom_created_at(Timestamp::tweaked(RANGE_RANDOM_TIMESTAMP_TWEAK))
|
||||
.sign_with_keys(sender)
|
||||
.map_err(|e| WrapError::Event(e.to_string()))?;
|
||||
|
||||
let ephemeral = Keys::generate();
|
||||
let wck = v3_conversation_key(&ephemeral, receiver)?;
|
||||
let wrapped =
|
||||
nip44::encrypt_v3(&wck, seal.as_json().as_bytes(), WRAP_KIND, EMPTY_SCOPE)
|
||||
.map_err(|e| WrapError::Crypto(e.to_string()))?;
|
||||
EventBuilder::new(Kind::GiftWrap, wrapped)
|
||||
.tags([Tag::public_key(*receiver)])
|
||||
.custom_created_at(Timestamp::tweaked(RANGE_RANDOM_TIMESTAMP_TWEAK))
|
||||
.sign_with_keys(&ephemeral)
|
||||
.map_err(|e| WrapError::Event(e.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Unwrap a gift wrap addressed to `receiver`, dispatching each layer on its
|
||||
/// NIP-44 version byte. Verifies the seal signature and the NIP-17
|
||||
/// author-equals-signer rule; for v3 layers the kind/scope context binding is
|
||||
/// enforced by the `nip44` crate against the expected values (13/1059, "").
|
||||
pub fn unwrap_gift_wrap(receiver: &Keys, wrap: &Event) -> Result<Unwrapped, WrapError> {
|
||||
if wrap.kind != Kind::GiftWrap {
|
||||
return Err(WrapError::NotGiftWrap);
|
||||
}
|
||||
let seal_json = decrypt_layer(receiver, &wrap.pubkey, &wrap.content, WRAP_KIND)?;
|
||||
let seal = Event::from_json(seal_json).map_err(|e| WrapError::Event(e.to_string()))?;
|
||||
seal.verify().map_err(|e| WrapError::Event(e.to_string()))?;
|
||||
if seal.kind != Kind::Seal {
|
||||
return Err(WrapError::NotSeal);
|
||||
}
|
||||
let rumor_json = decrypt_layer(receiver, &seal.pubkey, &seal.content, SEAL_KIND)?;
|
||||
let rumor =
|
||||
UnsignedEvent::from_json(rumor_json).map_err(|e| WrapError::Event(e.to_string()))?;
|
||||
if rumor.pubkey != seal.pubkey {
|
||||
return Err(WrapError::SenderMismatch);
|
||||
}
|
||||
Ok(Unwrapped {
|
||||
sender: seal.pubkey,
|
||||
rumor,
|
||||
})
|
||||
}
|
||||
|
||||
/// Decrypt one layer, branching on the version byte: `0x02` goes to
|
||||
/// nostr-sdk's v2, `0x03` to the `nip44` crate with the expected context.
|
||||
fn decrypt_layer(
|
||||
keys: &Keys,
|
||||
author: &PublicKey,
|
||||
content: &str,
|
||||
expected_kind: u32,
|
||||
) -> Result<String, WrapError> {
|
||||
match nip44::payload_version(content).map_err(|e| WrapError::Crypto(e.to_string()))? {
|
||||
2 => sdk_nip44::decrypt(keys.secret_key(), author, content)
|
||||
.map_err(|e| WrapError::Crypto(e.to_string())),
|
||||
3 => {
|
||||
let ck = v3_conversation_key(keys, author)?;
|
||||
let plain = nip44::decrypt_v3(&ck, content, expected_kind, EMPTY_SCOPE)
|
||||
.map_err(|e| WrapError::Crypto(e.to_string()))?;
|
||||
String::from_utf8(plain).map_err(|e| WrapError::Crypto(e.to_string()))
|
||||
}
|
||||
v => Err(WrapError::Crypto(format!("unsupported nip44 version {v}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The v3 conversation key (raw ECDH x coordinate) between our secret key and
|
||||
/// a peer's x-only public key. nostr-sdk is on secp256k1 0.29 while the nip44
|
||||
/// crate speaks 0.31, so the conversion goes through raw bytes.
|
||||
fn v3_conversation_key(ours: &Keys, theirs: &PublicKey) -> Result<[u8; 32], WrapError> {
|
||||
let sk = secp256k1::SecretKey::from_byte_array(ours.secret_key().to_secret_bytes())
|
||||
.map_err(|e| WrapError::Crypto(format!("bad secret key: {e}")))?;
|
||||
let pk = secp256k1::XOnlyPublicKey::from_byte_array(theirs.to_bytes())
|
||||
.map_err(|e| WrapError::Crypto(format!("bad public key: {e}")))?;
|
||||
Ok(nip44::get_conversation_key_v3(sk, pk))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use nostr_sdk::nips::nip59::UnwrappedGift;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn rumor(sender: &Keys, receiver: &PublicKey, text: &str) -> UnsignedEvent {
|
||||
EventBuilder::new(Kind::PrivateDirectMessage, text)
|
||||
.tags([Tag::public_key(*receiver)])
|
||||
.build(sender.public_key())
|
||||
}
|
||||
|
||||
fn version_byte(event: &Event) -> u8 {
|
||||
nip44::payload_version(&event.content).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v3_seal_and_wrap_round_trip() {
|
||||
let alice = Keys::generate();
|
||||
let bob = Keys::generate();
|
||||
let r = rumor(&alice, &bob.public_key(), "hello over v3");
|
||||
|
||||
let wrap = gift_wrap(&alice, &bob.public_key(), r.clone(), WrapVersion::V3).unwrap();
|
||||
assert_eq!(wrap.kind, Kind::GiftWrap);
|
||||
assert_eq!(version_byte(&wrap), 3, "outer layer must be v3");
|
||||
// Signed by a fresh ephemeral key, never by Alice.
|
||||
assert_ne!(wrap.pubkey, alice.public_key());
|
||||
wrap.verify().unwrap();
|
||||
// Addressed to Bob via the p tag, timestamp fuzzed into the past.
|
||||
assert!(wrap
|
||||
.tags
|
||||
.iter()
|
||||
.any(|t| t.as_slice().get(1).map(|s| s.as_str())
|
||||
== Some(bob.public_key().to_hex().as_str())));
|
||||
assert!(wrap.created_at <= Timestamp::now());
|
||||
|
||||
let unwrapped = unwrap_gift_wrap(&bob, &wrap).unwrap();
|
||||
assert_eq!(unwrapped.sender, alice.public_key());
|
||||
assert_eq!(unwrapped.rumor.pubkey, alice.public_key());
|
||||
assert_eq!(unwrapped.rumor.kind, Kind::PrivateDirectMessage);
|
||||
assert_eq!(unwrapped.rumor.content, "hello over v3");
|
||||
|
||||
// A stranger cannot open it.
|
||||
let mallory = Keys::generate();
|
||||
assert!(unwrap_gift_wrap(&mallory, &wrap).is_err());
|
||||
// And the sender cannot open their own wrap (it is not wrapped to them).
|
||||
assert!(unwrap_gift_wrap(&alice, &wrap).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v2_interop_with_nostr_sdk_both_directions() {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.build()
|
||||
.unwrap();
|
||||
let alice = Keys::generate();
|
||||
let bob = Keys::generate();
|
||||
|
||||
// Ours -> stock nostr-sdk (what today's v2-only Goblin runs).
|
||||
let r = rumor(&alice, &bob.public_key(), "ours to sdk");
|
||||
let wrap = gift_wrap(&alice, &bob.public_key(), r, WrapVersion::V2).unwrap();
|
||||
assert_eq!(version_byte(&wrap), 2);
|
||||
let gift = rt
|
||||
.block_on(UnwrappedGift::from_gift_wrap(&bob, &wrap))
|
||||
.unwrap();
|
||||
assert_eq!(gift.sender, alice.public_key());
|
||||
assert_eq!(gift.rumor.content, "ours to sdk");
|
||||
|
||||
// Stock nostr-sdk -> ours (a v2 Goblin paying us).
|
||||
let r = rumor(&alice, &bob.public_key(), "sdk to ours");
|
||||
let wrap = rt
|
||||
.block_on(EventBuilder::gift_wrap(&alice, &bob.public_key(), r, []))
|
||||
.unwrap();
|
||||
let unwrapped = unwrap_gift_wrap(&bob, &wrap).unwrap();
|
||||
assert_eq!(unwrapped.sender, alice.public_key());
|
||||
assert_eq!(unwrapped.rumor.content, "sdk to ours");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unwrap_dispatches_on_version_byte() {
|
||||
// The receiver is never told which version arrived — the payload
|
||||
// version byte decides, per layer.
|
||||
let alice = Keys::generate();
|
||||
let bob = Keys::generate();
|
||||
for (version, byte) in [(WrapVersion::V2, 2u8), (WrapVersion::V3, 3u8)] {
|
||||
let r = rumor(&alice, &bob.public_key(), "dispatch");
|
||||
let wrap = gift_wrap(&alice, &bob.public_key(), r, version).unwrap();
|
||||
assert_eq!(version_byte(&wrap), byte);
|
||||
let unwrapped = unwrap_gift_wrap(&bob, &wrap).unwrap();
|
||||
assert_eq!(unwrapped.sender, alice.public_key());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v3_context_binding_is_enforced() {
|
||||
// A v3 payload sealed for one context must not open under another:
|
||||
// decrypting the WRAP layer content as if it were a SEAL fails on the
|
||||
// kind binding even with the right conversation key.
|
||||
let alice = Keys::generate();
|
||||
let bob = Keys::generate();
|
||||
let r = rumor(&alice, &bob.public_key(), "context");
|
||||
let wrap = gift_wrap(&alice, &bob.public_key(), r, WrapVersion::V3).unwrap();
|
||||
|
||||
let ck = v3_conversation_key(&bob, &wrap.pubkey).unwrap();
|
||||
assert!(nip44::decrypt_v3(&ck, &wrap.content, WRAP_KIND, EMPTY_SCOPE).is_ok());
|
||||
assert!(
|
||||
nip44::decrypt_v3(&ck, &wrap.content, SEAL_KIND, EMPTY_SCOPE).is_err(),
|
||||
"kind binding must reject a cross-context decrypt"
|
||||
);
|
||||
assert!(
|
||||
nip44::decrypt_v3(&ck, &wrap.content, WRAP_KIND, b"other").is_err(),
|
||||
"scope binding must reject a cross-context decrypt"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rumor_id_is_fixed_before_encryption() {
|
||||
let alice = Keys::generate();
|
||||
let bob = Keys::generate();
|
||||
let r = rumor(&alice, &bob.public_key(), "id check");
|
||||
let wrap = gift_wrap(&alice, &bob.public_key(), r.clone(), WrapVersion::V3).unwrap();
|
||||
let mut unwrapped = unwrap_gift_wrap(&bob, &wrap).unwrap();
|
||||
let mut original = r;
|
||||
assert_eq!(unwrapped.rumor.id(), original.id());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chooses_best_mutual_version() {
|
||||
assert_eq!(choose_version(None), WrapVersion::V2);
|
||||
assert_eq!(choose_version(Some("nip44_v2")), WrapVersion::V2);
|
||||
assert_eq!(choose_version(Some("nip44_v3 nip44_v2")), WrapVersion::V3);
|
||||
// Their list is best-first: respect a peer that prefers v2.
|
||||
assert_eq!(choose_version(Some("nip44_v2 nip44_v3")), WrapVersion::V2);
|
||||
// Unknown methods are skipped; nothing mutual falls back to v2.
|
||||
assert_eq!(choose_version(Some("mls nip44_v3")), WrapVersion::V3);
|
||||
assert_eq!(choose_version(Some("mls")), WrapVersion::V2);
|
||||
assert_eq!(choose_version(Some("")), WrapVersion::V2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_capability_from_10050() {
|
||||
let keys = Keys::generate();
|
||||
let event = EventBuilder::new(Kind::InboxRelays, "")
|
||||
.tags([
|
||||
Tag::custom(
|
||||
nostr_sdk::TagKind::custom("relay"),
|
||||
["wss://relay.example".to_string()],
|
||||
),
|
||||
capability_tag(),
|
||||
])
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
encryption_capability(&event).as_deref(),
|
||||
Some(ENCRYPTION_CAPABILITIES)
|
||||
);
|
||||
assert_eq!(
|
||||
choose_version(encryption_capability(&event).as_deref()),
|
||||
WrapVersion::V3
|
||||
);
|
||||
|
||||
let bare = EventBuilder::new(Kind::InboxRelays, "")
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
assert_eq!(encryption_capability(&bare), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
[package]
|
||||
name = "gp-server"
|
||||
description = "GoblinPay HTTP server (Actix-Web, in-process rustls TLS)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "gp-server"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
gp-core = { path = "../gp-core" }
|
||||
gp-nostr = { path = "../gp-nostr" }
|
||||
gp-wallet = { path = "../gp-wallet" }
|
||||
actix-web = { version = "4", default-features = false, features = [
|
||||
"macros",
|
||||
"http2",
|
||||
"rustls-0_23",
|
||||
] }
|
||||
askama = "0.14"
|
||||
rustls = { version = "0.23", default-features = false, features = [
|
||||
"ring",
|
||||
"logging",
|
||||
"std",
|
||||
"tls12",
|
||||
] }
|
||||
rustls-pemfile = "2"
|
||||
serde = { workspace = true }
|
||||
serde_json.workspace = true
|
||||
sqlx = { workspace = true }
|
||||
log = "0.4"
|
||||
# Outbound webhook delivery. `rustls-no-provider` reuses the process-installed
|
||||
# ring crypto provider (no aws-lc-rs build) and brings platform-verifier roots;
|
||||
# no JSON feature (we send a pre-signed body).
|
||||
reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider"] }
|
||||
# Stderr logger for the gp-nostr/nym `log` output; no regex filtering needed.
|
||||
env_logger = { version = "0.11", default-features = false, features = ["humantime"] }
|
||||
|
||||
[dev-dependencies]
|
||||
# The milestone-3 end-to-end test: a stand-in payer built from nostr-sdk
|
||||
# gift-wraps a REAL S1 (generated by the gp-goblin-sender subprocess), the
|
||||
# ingest pipeline receives it through the real WalletReceiver, and the reply
|
||||
# is decrypted and finalized by Goblin's wallet stack.
|
||||
nostr-sdk = { version = "0.44", features = ["nip44", "nip49", "nip59"] }
|
||||
nip44 = { path = "../../../nip44" }
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
||||
rand = "0.6"
|
||||
grin_keychain = "=5.4.1"
|
||||
grin_core = "=5.4.1"
|
||||
@@ -0,0 +1,3 @@
|
||||
# Templates live at the workspace root (see the repo layout in the plan).
|
||||
[general]
|
||||
dirs = ["../../templates"]
|
||||
@@ -0,0 +1,386 @@
|
||||
//! The authenticated admin surface (`GP_ADMIN_TOKEN`): a zero-JS dashboard
|
||||
//! plus the JSON management API for per-user endpubs (milestone 5b) and webhook
|
||||
//! deliveries. Everything here is server-rendered or plain JSON, no build step.
|
||||
|
||||
use actix_web::{web, HttpRequest, HttpResponse, Responder};
|
||||
use askama::Template;
|
||||
use gp_core::config::Config;
|
||||
use gp_core::endpub;
|
||||
use gp_core::webhook::nanogrin_to_grin;
|
||||
use gp_nostr::{Keys, PublicKey};
|
||||
use log::error;
|
||||
use serde::Deserialize;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::auth::authorized;
|
||||
use crate::payments::ReceiptSigner;
|
||||
|
||||
/// Register the admin routes.
|
||||
pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
cfg.route("/admin", web::get().to(dashboard))
|
||||
.route("/admin/payments", web::get().to(list_payments))
|
||||
.route("/admin/users", web::get().to(list_users))
|
||||
.route("/admin/users", web::post().to(create_user))
|
||||
.route("/admin/users/{id}", web::get().to(get_user))
|
||||
.route("/admin/users/{id}/rotate", web::post().to(rotate_user))
|
||||
.route(
|
||||
"/admin/users/{id}/rotate-interval",
|
||||
web::post().to(set_rotate_interval),
|
||||
)
|
||||
.route("/admin/webhooks", web::get().to(list_webhooks));
|
||||
}
|
||||
|
||||
fn deny() -> HttpResponse {
|
||||
HttpResponse::Unauthorized().json(serde_json::json!({"error": "unauthorized"}))
|
||||
}
|
||||
|
||||
fn is_admin(req: &HttpRequest, cfg: &Config) -> bool {
|
||||
authorized(req, cfg.admin_token.as_ref().map(|s| s.reveal()))
|
||||
}
|
||||
|
||||
fn master_secret(keys: &Keys) -> [u8; 32] {
|
||||
keys.secret_key().to_secret_bytes()
|
||||
}
|
||||
|
||||
/// npub for an x-only pubkey hex (or empty on parse failure).
|
||||
fn npub_of_hex(hex: &str) -> String {
|
||||
PublicKey::from_hex(hex)
|
||||
.map(gp_nostr::npub_of)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
// ----- dashboard (HTML) -----
|
||||
|
||||
struct PaymentRow {
|
||||
slate_id: String,
|
||||
amount_grin: String,
|
||||
status: String,
|
||||
invoice_id: String,
|
||||
user_id: String,
|
||||
created_at: String,
|
||||
}
|
||||
|
||||
struct BalanceRow {
|
||||
user_id: String,
|
||||
npub: String,
|
||||
epoch: i64,
|
||||
balance_grin: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "admin.html")]
|
||||
struct AdminPage {
|
||||
payments: Vec<PaymentRow>,
|
||||
balances: Vec<BalanceRow>,
|
||||
node_url: String,
|
||||
match_mode: String,
|
||||
nym: bool,
|
||||
ingest: bool,
|
||||
relay_count: usize,
|
||||
webhook_configured: bool,
|
||||
pending_webhooks: i64,
|
||||
rotate_interval: i64,
|
||||
overlap_epochs: i64,
|
||||
}
|
||||
|
||||
async fn dashboard(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<SqlitePool>,
|
||||
cfg: web::Data<Config>,
|
||||
) -> impl Responder {
|
||||
if !is_admin(&req, cfg.get_ref()) {
|
||||
return HttpResponse::Unauthorized().body("unauthorized");
|
||||
}
|
||||
let payments = recent_payment_rows(pool.get_ref()).await;
|
||||
let balances = balance_rows(pool.get_ref()).await;
|
||||
let pending_webhooks: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM webhook_delivery WHERE delivered = 0")
|
||||
.fetch_one(pool.get_ref())
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
let page = AdminPage {
|
||||
payments,
|
||||
balances,
|
||||
node_url: cfg.node_url.clone(),
|
||||
match_mode: format!("{:?}", cfg.match_mode).to_lowercase(),
|
||||
nym: cfg.nym,
|
||||
ingest: cfg.ingest,
|
||||
relay_count: gp_nostr::relays::resolve(&cfg.relays).len(),
|
||||
webhook_configured: cfg.webhook_url.is_some(),
|
||||
pending_webhooks,
|
||||
rotate_interval: cfg.endpub_rotate_interval,
|
||||
overlap_epochs: cfg.endpub_overlap_epochs,
|
||||
};
|
||||
match page.render() {
|
||||
Ok(html) => HttpResponse::Ok()
|
||||
.content_type("text/html; charset=utf-8")
|
||||
.body(html),
|
||||
Err(e) => {
|
||||
error!("admin render: {e}");
|
||||
HttpResponse::InternalServerError().body("template error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn recent_payment_rows(pool: &SqlitePool) -> Vec<PaymentRow> {
|
||||
#[allow(clippy::type_complexity)] // a flat sqlx row tuple, mapped immediately below
|
||||
let rows: Vec<(String, i64, String, Option<String>, Option<String>, String)> = sqlx::query_as(
|
||||
"SELECT slate_id, amount, status, invoice_id, user_id, created_at FROM payment \
|
||||
ORDER BY created_at DESC LIMIT 50",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
rows.into_iter()
|
||||
.map(
|
||||
|(slate_id, amount, status, invoice_id, user_id, created_at)| PaymentRow {
|
||||
slate_id,
|
||||
amount_grin: nanogrin_to_grin(amount as u64),
|
||||
status,
|
||||
invoice_id: invoice_id.unwrap_or_default(),
|
||||
user_id: user_id.unwrap_or_default(),
|
||||
created_at,
|
||||
},
|
||||
)
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn balance_rows(pool: &SqlitePool) -> Vec<BalanceRow> {
|
||||
endpub::list_with_balances(pool)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|b| BalanceRow {
|
||||
user_id: b.user_id,
|
||||
npub: npub_of_hex(&b.endpub),
|
||||
epoch: b.epoch,
|
||||
balance_grin: nanogrin_to_grin(b.balance.max(0) as u64),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ----- JSON API -----
|
||||
|
||||
async fn list_payments(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<SqlitePool>,
|
||||
cfg: web::Data<Config>,
|
||||
) -> impl Responder {
|
||||
if !is_admin(&req, cfg.get_ref()) {
|
||||
return deny();
|
||||
}
|
||||
#[allow(clippy::type_complexity)] // a flat sqlx row tuple, mapped immediately below
|
||||
let rows: Vec<(
|
||||
String,
|
||||
i64,
|
||||
Option<String>,
|
||||
String,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
String,
|
||||
)> = sqlx::query_as(
|
||||
"SELECT slate_id, amount, payer, status, invoice_id, user_id, created_at \
|
||||
FROM payment ORDER BY created_at DESC LIMIT 200",
|
||||
)
|
||||
.fetch_all(pool.get_ref())
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let list: Vec<_> = rows
|
||||
.into_iter()
|
||||
.map(
|
||||
|(id, amount, payer, status, invoice_id, user_id, created_at)| {
|
||||
serde_json::json!({
|
||||
"payment_id": id, "amount": amount, "payer": payer, "status": status,
|
||||
"invoice_id": invoice_id, "user_id": user_id, "created_at": created_at,
|
||||
})
|
||||
},
|
||||
)
|
||||
.collect();
|
||||
HttpResponse::Ok().json(serde_json::json!({ "payments": list }))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CreateUserBody {
|
||||
user_id: Option<String>,
|
||||
rotate_interval: Option<i64>,
|
||||
}
|
||||
|
||||
fn endpub_json(cfg: &Config, user_id: &str, epoch: i64, pubkey: &str) -> serde_json::Value {
|
||||
let relays = gp_nostr::relays::resolve(&cfg.relays);
|
||||
let (npub, nprofile, qr) = match PublicKey::from_hex(pubkey) {
|
||||
Ok(pk) => (
|
||||
gp_nostr::npub_of(pk),
|
||||
gp_nostr::nprofile(pk, &relays),
|
||||
gp_core::qr::svg(&gp_nostr::nprofile(pk, &relays), cfg.qr_logo_href())
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
Err(_) => (String::new(), String::new(), String::new()),
|
||||
};
|
||||
serde_json::json!({
|
||||
"user_id": user_id, "epoch": epoch, "pubkey": pubkey,
|
||||
"npub": npub, "nprofile": nprofile, "qr_svg": qr,
|
||||
})
|
||||
}
|
||||
|
||||
async fn create_user(
|
||||
req: HttpRequest,
|
||||
body: web::Json<CreateUserBody>,
|
||||
pool: web::Data<SqlitePool>,
|
||||
cfg: web::Data<Config>,
|
||||
signer: web::Data<ReceiptSigner>,
|
||||
) -> impl Responder {
|
||||
if !is_admin(&req, cfg.get_ref()) {
|
||||
return deny();
|
||||
}
|
||||
let Some(keys) = signer.0.as_ref() else {
|
||||
return HttpResponse::ServiceUnavailable()
|
||||
.json(serde_json::json!({"error": "server identity not loaded"}));
|
||||
};
|
||||
let sk = master_secret(keys);
|
||||
let body = body.into_inner();
|
||||
match endpub::create_user(pool.get_ref(), &sk, body.user_id, body.rotate_interval).await {
|
||||
Ok((user, ep)) => {
|
||||
HttpResponse::Ok().json(endpub_json(cfg.get_ref(), &user.id, ep.epoch, &ep.pubkey))
|
||||
}
|
||||
Err(e) => {
|
||||
error!("create user: {e}");
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "internal"}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_users(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<SqlitePool>,
|
||||
cfg: web::Data<Config>,
|
||||
) -> impl Responder {
|
||||
if !is_admin(&req, cfg.get_ref()) {
|
||||
return deny();
|
||||
}
|
||||
let balances = endpub::list_with_balances(pool.get_ref())
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let list: Vec<_> = balances
|
||||
.into_iter()
|
||||
.map(|b| {
|
||||
serde_json::json!({
|
||||
"user_id": b.user_id, "epoch": b.epoch,
|
||||
"endpub": b.endpub, "npub": npub_of_hex(&b.endpub), "balance": b.balance,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
HttpResponse::Ok().json(serde_json::json!({ "users": list }))
|
||||
}
|
||||
|
||||
async fn get_user(
|
||||
req: HttpRequest,
|
||||
path: web::Path<String>,
|
||||
pool: web::Data<SqlitePool>,
|
||||
cfg: web::Data<Config>,
|
||||
) -> impl Responder {
|
||||
if !is_admin(&req, cfg.get_ref()) {
|
||||
return deny();
|
||||
}
|
||||
let id = path.into_inner();
|
||||
match endpub::current_endpub(pool.get_ref(), &id).await {
|
||||
Ok(Some(ep)) => {
|
||||
HttpResponse::Ok().json(endpub_json(cfg.get_ref(), &id, ep.epoch, &ep.pubkey))
|
||||
}
|
||||
Ok(None) => HttpResponse::NotFound().json(serde_json::json!({"error": "user not found"})),
|
||||
Err(e) => {
|
||||
error!("get user: {e}");
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "internal"}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn rotate_user(
|
||||
req: HttpRequest,
|
||||
path: web::Path<String>,
|
||||
pool: web::Data<SqlitePool>,
|
||||
cfg: web::Data<Config>,
|
||||
signer: web::Data<ReceiptSigner>,
|
||||
) -> impl Responder {
|
||||
if !is_admin(&req, cfg.get_ref()) {
|
||||
return deny();
|
||||
}
|
||||
let Some(keys) = signer.0.as_ref() else {
|
||||
return HttpResponse::ServiceUnavailable()
|
||||
.json(serde_json::json!({"error": "server identity not loaded"}));
|
||||
};
|
||||
let sk = master_secret(keys);
|
||||
let id = path.into_inner();
|
||||
match endpub::rotate(pool.get_ref(), &sk, &id).await {
|
||||
Ok(ep) => HttpResponse::Ok().json(endpub_json(cfg.get_ref(), &id, ep.epoch, &ep.pubkey)),
|
||||
Err(e) => {
|
||||
error!("rotate user: {e}");
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "internal"}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RotateIntervalBody {
|
||||
/// New interval in seconds; null clears the per-user override.
|
||||
interval: Option<i64>,
|
||||
}
|
||||
|
||||
async fn set_rotate_interval(
|
||||
req: HttpRequest,
|
||||
path: web::Path<String>,
|
||||
body: web::Json<RotateIntervalBody>,
|
||||
pool: web::Data<SqlitePool>,
|
||||
cfg: web::Data<Config>,
|
||||
) -> impl Responder {
|
||||
if !is_admin(&req, cfg.get_ref()) {
|
||||
return deny();
|
||||
}
|
||||
let id = path.into_inner();
|
||||
match endpub::set_rotate_interval(pool.get_ref(), &id, body.into_inner().interval).await {
|
||||
Ok(true) => HttpResponse::Ok().json(serde_json::json!({"user_id": id, "updated": true})),
|
||||
Ok(false) => HttpResponse::NotFound().json(serde_json::json!({"error": "user not found"})),
|
||||
Err(e) => {
|
||||
error!("set rotate interval: {e}");
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "internal"}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_webhooks(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<SqlitePool>,
|
||||
cfg: web::Data<Config>,
|
||||
) -> impl Responder {
|
||||
if !is_admin(&req, cfg.get_ref()) {
|
||||
return deny();
|
||||
}
|
||||
#[allow(clippy::type_complexity)] // a flat sqlx row tuple, mapped immediately below
|
||||
let rows: Vec<(
|
||||
String,
|
||||
Option<String>,
|
||||
String,
|
||||
i64,
|
||||
i64,
|
||||
String,
|
||||
Option<String>,
|
||||
)> = sqlx::query_as(
|
||||
"SELECT id, payment_id, event_type, attempts, delivered, next_attempt_at, last_error \
|
||||
FROM webhook_delivery ORDER BY created_at DESC LIMIT 200",
|
||||
)
|
||||
.fetch_all(pool.get_ref())
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let list: Vec<_> = rows
|
||||
.into_iter()
|
||||
.map(
|
||||
|(id, payment_id, event_type, attempts, delivered, next_attempt_at, last_error)| {
|
||||
serde_json::json!({
|
||||
"event_id": id, "payment_id": payment_id, "event_type": event_type,
|
||||
"attempts": attempts, "delivered": delivered == 1,
|
||||
"next_attempt_at": next_attempt_at, "last_error": last_error,
|
||||
})
|
||||
},
|
||||
)
|
||||
.collect();
|
||||
HttpResponse::Ok().json(serde_json::json!({ "deliveries": list }))
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//! Bearer-token authorization for the write and admin surfaces.
|
||||
//!
|
||||
//! Two independent tokens: `GP_API_TOKEN` gates the connector/create-invoice
|
||||
//! API, `GP_ADMIN_TOKEN` gates the admin dashboard and endpub/webhook
|
||||
//! management. A route whose token is unset is closed (401), never open. The
|
||||
//! public-by-token surfaces (`/pay/<token>`, payment status) carry their own
|
||||
//! unguessable capability and do not use these.
|
||||
|
||||
use actix_web::HttpRequest;
|
||||
|
||||
/// The bearer token from the `Authorization: Bearer <token>` header, if any.
|
||||
pub fn bearer(req: &HttpRequest) -> Option<String> {
|
||||
let value = req.headers().get("Authorization")?.to_str().ok()?;
|
||||
value
|
||||
.strip_prefix("Bearer ")
|
||||
.or_else(|| value.strip_prefix("bearer "))
|
||||
.map(|s| s.trim().to_string())
|
||||
}
|
||||
|
||||
/// Is the request authorized against `expected`? An unset expected token
|
||||
/// (feature not configured) is always unauthorized. The comparison is
|
||||
/// constant time.
|
||||
pub fn authorized(req: &HttpRequest, expected: Option<&str>) -> bool {
|
||||
match (expected, bearer(req)) {
|
||||
(Some(exp), Some(got)) => gp_core::ct_eq(got.as_bytes(), exp.as_bytes()),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
//! The hosted, zero-JS checkout: the `/pay/<token>` page (shared renderer for
|
||||
//! embedded and hosted use), its live status, and the manual-slatepack
|
||||
//! fallback.
|
||||
//!
|
||||
//! 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.
|
||||
|
||||
use actix_web::{web, HttpResponse, Responder};
|
||||
use askama::Template;
|
||||
use gp_core::config::Config;
|
||||
use gp_core::invoice::{self, Invoice, InvoiceStatus};
|
||||
use gp_core::qr;
|
||||
use gp_core::webhook::nanogrin_to_grin;
|
||||
use gp_nostr::PublicKey;
|
||||
use gp_wallet::GpWallet;
|
||||
use log::error;
|
||||
use serde::Deserialize;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
/// Everything the checkout page (and the create-invoice API) present for one
|
||||
/// invoice.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CheckoutInfo {
|
||||
pub invoice_id: String,
|
||||
pub token: String,
|
||||
pub pay_url: String,
|
||||
pub recipient_pubkey: String,
|
||||
pub npub: String,
|
||||
pub nprofile: String,
|
||||
pub qr_svg: String,
|
||||
pub amount_display: String,
|
||||
pub status: String,
|
||||
pub memo: Option<String>,
|
||||
pub order_ref: Option<String>,
|
||||
}
|
||||
|
||||
/// 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);
|
||||
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()),
|
||||
};
|
||||
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 {
|
||||
invoice_id: inv.id.clone(),
|
||||
pay_url: format!("{}/pay/{}", cfg.public_url, token),
|
||||
token,
|
||||
recipient_pubkey,
|
||||
npub,
|
||||
nprofile,
|
||||
qr_svg,
|
||||
amount_display,
|
||||
status: inv.status.clone(),
|
||||
memo: inv.memo.clone(),
|
||||
order_ref: inv.order_ref.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Human amount for display: a priced fiat invoice shows both the fiat charge
|
||||
/// and its locked Grin quote, an exact-Grin invoice shows Grin, an unpriced
|
||||
/// fiat invoice notes the quote is pending, and an open amount shows "any".
|
||||
fn amount_display(inv: &Invoice) -> String {
|
||||
match (inv.expected_amount, &inv.fiat_amount, &inv.fiat_currency) {
|
||||
// Priced fiat quote: the fiat charge with its locked Grin equivalent.
|
||||
(Some(nano), Some(amount), Some(currency)) => {
|
||||
format!(
|
||||
"{amount} {currency} (~{} GRIN)",
|
||||
nanogrin_to_grin(nano as u64)
|
||||
)
|
||||
}
|
||||
// Exact Grin invoice.
|
||||
(Some(nano), _, _) => format!("{} GRIN", nanogrin_to_grin(nano as u64)),
|
||||
// Fiat invoice not yet priced (oracle disabled/deferred).
|
||||
(None, Some(amount), Some(currency)) => format!("{amount} {currency} (Grin quote pending)"),
|
||||
_ => "any amount".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The checkout page template.
|
||||
#[derive(Template)]
|
||||
#[template(path = "pay.html")]
|
||||
struct PayPage {
|
||||
info: CheckoutInfo,
|
||||
is_open: bool,
|
||||
is_paid: bool,
|
||||
is_expired: bool,
|
||||
wallet_available: bool,
|
||||
}
|
||||
|
||||
/// The manual-slatepack result template (S2 to copy back).
|
||||
#[derive(Template)]
|
||||
#[template(path = "pay_result.html")]
|
||||
struct PayResultPage {
|
||||
token: String,
|
||||
ok: bool,
|
||||
message: String,
|
||||
s2_armor: String,
|
||||
}
|
||||
|
||||
/// Register the checkout routes.
|
||||
pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
cfg.route("/pay/{token}", web::get().to(pay_page))
|
||||
.route("/pay/{token}/status", web::get().to(pay_status))
|
||||
.route("/pay/{token}/slatepack", web::post().to(manual_slatepack));
|
||||
}
|
||||
|
||||
/// GET /pay/{token}: the hosted checkout page.
|
||||
async fn pay_page(
|
||||
path: web::Path<String>,
|
||||
pool: web::Data<SqlitePool>,
|
||||
cfg: web::Data<Config>,
|
||||
wallet: web::Data<Option<GpWallet>>,
|
||||
) -> impl Responder {
|
||||
let token = path.into_inner();
|
||||
let inv = match invoice::get_by_token(pool.get_ref(), &token).await {
|
||||
Ok(Some(inv)) => inv,
|
||||
Ok(None) => return HttpResponse::NotFound().body("invoice not found"),
|
||||
Err(e) => {
|
||||
error!("pay: lookup failed: {e}");
|
||||
return HttpResponse::InternalServerError().body("internal error");
|
||||
}
|
||||
};
|
||||
let status = inv.status();
|
||||
let page = PayPage {
|
||||
info: build_info(&inv, cfg.get_ref()),
|
||||
is_open: status == InvoiceStatus::Open,
|
||||
is_paid: status == InvoiceStatus::Paid,
|
||||
is_expired: status == InvoiceStatus::Expired,
|
||||
wallet_available: wallet.get_ref().is_some(),
|
||||
};
|
||||
render(page)
|
||||
}
|
||||
|
||||
/// GET /pay/{token}/status: status JSON for polling (public-by-token).
|
||||
async fn pay_status(path: web::Path<String>, pool: web::Data<SqlitePool>) -> impl Responder {
|
||||
let token = path.into_inner();
|
||||
match invoice::get_by_token(pool.get_ref(), &token).await {
|
||||
Ok(Some(inv)) => HttpResponse::Ok().json(serde_json::json!({
|
||||
"invoice_id": inv.id,
|
||||
"status": inv.status,
|
||||
"expected_amount": inv.expected_amount,
|
||||
"paid_payment_id": inv.paid_payment_id,
|
||||
})),
|
||||
Ok(None) => HttpResponse::NotFound().json(serde_json::json!({"error": "not found"})),
|
||||
Err(e) => {
|
||||
error!("pay status: {e}");
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "internal"}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Manual-slatepack form body.
|
||||
#[derive(Deserialize)]
|
||||
struct ManualForm {
|
||||
slatepack: String,
|
||||
}
|
||||
|
||||
/// POST /pay/{token}/slatepack: offline receive of a pasted S1, rendering S2.
|
||||
async fn manual_slatepack(
|
||||
path: web::Path<String>,
|
||||
form: web::Form<ManualForm>,
|
||||
pool: web::Data<SqlitePool>,
|
||||
cfg: web::Data<Config>,
|
||||
wallet: web::Data<Option<GpWallet>>,
|
||||
) -> impl Responder {
|
||||
let token = path.into_inner();
|
||||
let inv = match invoice::get_by_token(pool.get_ref(), &token).await {
|
||||
Ok(Some(inv)) => inv,
|
||||
Ok(None) => return HttpResponse::NotFound().body("invoice not found"),
|
||||
Err(e) => {
|
||||
error!("manual: lookup failed: {e}");
|
||||
return HttpResponse::InternalServerError().body("internal error");
|
||||
}
|
||||
};
|
||||
|
||||
let Some(wallet) = wallet.get_ref().as_ref() else {
|
||||
return render(PayResultPage {
|
||||
token,
|
||||
ok: false,
|
||||
message: "Manual receive is unavailable on this instance (wallet not loaded).".into(),
|
||||
s2_armor: String::new(),
|
||||
});
|
||||
};
|
||||
|
||||
// Offline receive_tx (no node), exactly the wallet path the Nostr flow
|
||||
// uses. Then persist + match + webhook via the shared helper, so a manual
|
||||
// payment lands in the ledger like any other.
|
||||
let s1 = form.slatepack.trim().to_string();
|
||||
let page = match wallet.receive_slatepack(&s1) {
|
||||
Ok(received) => {
|
||||
let webhook = match (cfg.webhook_url.clone(), cfg.webhook_secret.as_ref()) {
|
||||
(Some(url), Some(secret)) => Some((url, secret.reveal().to_string())),
|
||||
_ => None,
|
||||
};
|
||||
crate::record::persist_and_match(
|
||||
pool.get_ref(),
|
||||
&received,
|
||||
None,
|
||||
inv.recipient_pubkey.as_deref().unwrap_or_default(),
|
||||
inv.order_ref.as_deref(),
|
||||
cfg.match_mode,
|
||||
webhook.as_ref(),
|
||||
)
|
||||
.await;
|
||||
PayResultPage {
|
||||
token,
|
||||
ok: true,
|
||||
message: "Payment received. Copy the response slatepack below back into your \
|
||||
wallet to finalize and post it to the chain."
|
||||
.into(),
|
||||
s2_armor: received.s2_armor,
|
||||
}
|
||||
}
|
||||
Err(e) => PayResultPage {
|
||||
token,
|
||||
ok: false,
|
||||
message: format!("That slatepack could not be received: {e}"),
|
||||
s2_armor: String::new(),
|
||||
},
|
||||
};
|
||||
render(page)
|
||||
}
|
||||
|
||||
/// Render an Askama template to an HTML response.
|
||||
fn render<T: Template>(page: T) -> HttpResponse {
|
||||
match page.render() {
|
||||
Ok(html) => HttpResponse::Ok()
|
||||
.content_type("text/html; charset=utf-8")
|
||||
.body(html),
|
||||
Err(e) => {
|
||||
error!("template render failed: {e}");
|
||||
HttpResponse::InternalServerError().body("template error")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
//! The DB-backed key directory: resolves an incoming gift wrap's `p` tag to
|
||||
//! the identity we hold for it (the master key, a per-invoice derived child,
|
||||
//! or a per-user endpub), and lists the identities to subscribe to.
|
||||
//!
|
||||
//! Derived child secrets are never stored: the directory keeps a periodically
|
||||
//! refreshed snapshot of `pubkey -> secret` computed on the fly from the open
|
||||
//! derived invoices and the watched endpubs (current + overlap epochs). The
|
||||
//! same maintenance tick advances endpub rotation, so the watch set rolls
|
||||
//! forward with the users' clocks.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use gp_core::config::MatchMode;
|
||||
use gp_core::{derive, endpub};
|
||||
use gp_nostr::{keys_from_secret, KeyDirectory, Keys, PublicKey};
|
||||
use log::{info, warn};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
/// How often the watch set is rebuilt (and rotation advanced).
|
||||
const REFRESH_INTERVAL: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Snapshot of derived identities we currently watch: pubkey hex -> secret.
|
||||
type Snapshot = Arc<RwLock<HashMap<String, [u8; 32]>>>;
|
||||
|
||||
/// A directory over the master identity plus a refreshed set of derived
|
||||
/// children.
|
||||
pub struct DbKeyDirectory {
|
||||
master: Keys,
|
||||
master_hex: String,
|
||||
snapshot: Snapshot,
|
||||
}
|
||||
|
||||
impl DbKeyDirectory {
|
||||
pub fn new(master: Keys) -> DbKeyDirectory {
|
||||
let master_hex = master.public_key().to_hex();
|
||||
DbKeyDirectory {
|
||||
master,
|
||||
master_hex,
|
||||
snapshot: Arc::new(RwLock::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// A handle to the shared snapshot, for the maintenance task.
|
||||
pub fn snapshot(&self) -> Snapshot {
|
||||
self.snapshot.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl KeyDirectory for DbKeyDirectory {
|
||||
fn resolve(&self, recipient_hex: &str) -> Option<Keys> {
|
||||
if recipient_hex == self.master_hex {
|
||||
return Some(self.master.clone());
|
||||
}
|
||||
let secret = *self.snapshot.read().ok()?.get(recipient_hex)?;
|
||||
keys_from_secret(&secret).ok()
|
||||
}
|
||||
|
||||
fn watched(&self) -> Vec<PublicKey> {
|
||||
let mut out = vec![self.master.public_key()];
|
||||
if let Ok(snap) = self.snapshot.read() {
|
||||
for hex in snap.keys() {
|
||||
if let Ok(pk) = PublicKey::from_hex(hex) {
|
||||
out.push(pk);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Master secret bytes for deriving child keys.
|
||||
fn master_secret(master: &Keys) -> [u8; 32] {
|
||||
master.secret_key().to_secret_bytes()
|
||||
}
|
||||
|
||||
/// Rebuild the derived-identity snapshot from the open derived invoices and the
|
||||
/// watched endpubs (current + `overlap` epochs).
|
||||
pub async fn build_snapshot(
|
||||
pool: &SqlitePool,
|
||||
master: &Keys,
|
||||
default_mode: MatchMode,
|
||||
overlap: i64,
|
||||
) -> HashMap<String, [u8; 32]> {
|
||||
let sk = master_secret(master);
|
||||
let mut map = HashMap::new();
|
||||
|
||||
// Open, derived-mode invoices: recompute each child secret from its id.
|
||||
let default = match default_mode {
|
||||
MatchMode::Memo => "memo",
|
||||
MatchMode::Derived => "derived",
|
||||
MatchMode::Amount => "amount",
|
||||
};
|
||||
let derived_invoices: Vec<(String, Option<String>)> = sqlx::query_as(
|
||||
"SELECT id, recipient_pubkey FROM invoice \
|
||||
WHERE status = 'open' AND COALESCE(match_mode, ?1) = 'derived'",
|
||||
)
|
||||
.bind(default)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
warn!("directory: derived-invoice scan failed: {e}");
|
||||
vec![]
|
||||
});
|
||||
for (id, recipient) in derived_invoices {
|
||||
let pubkey = recipient.unwrap_or_else(|| derive::invoice_pubkey_hex(&sk, &id));
|
||||
map.insert(pubkey, derive::invoice_secret(&sk, &id));
|
||||
}
|
||||
|
||||
// Watched endpubs (current + overlap epochs) per user.
|
||||
match endpub::watched_pubkeys(pool, overlap).await {
|
||||
Ok(endpubs) => {
|
||||
for ep in endpubs {
|
||||
map.insert(ep.pubkey, derive::endpub_secret(&sk, &ep.user_id, ep.epoch));
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("directory: endpub scan failed: {e}"),
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
/// Spawn the maintenance tick on the current (Actix) runtime: advance endpub
|
||||
/// rotation, then rebuild the watch snapshot. Runs for the process lifetime.
|
||||
pub fn spawn_maintenance(
|
||||
pool: SqlitePool,
|
||||
master: Keys,
|
||||
default_mode: MatchMode,
|
||||
rotate_interval: i64,
|
||||
overlap: i64,
|
||||
snapshot: Snapshot,
|
||||
) {
|
||||
actix_web::rt::spawn(async move {
|
||||
let sk = master_secret(&master);
|
||||
loop {
|
||||
// Advance any users whose rotation clock elapsed (staggered).
|
||||
if rotate_interval > 0 {
|
||||
match endpub::rotate_due(&pool, &sk, rotate_interval).await {
|
||||
Ok(n) if n > 0 => info!("endpub: rotated {n} user(s)"),
|
||||
Ok(_) => {}
|
||||
Err(e) => warn!("endpub: rotation tick failed: {e}"),
|
||||
}
|
||||
}
|
||||
let fresh = build_snapshot(&pool, &master, default_mode, overlap).await;
|
||||
if let Ok(mut guard) = snapshot.write() {
|
||||
*guard = fresh;
|
||||
}
|
||||
actix_web::rt::time::sleep(REFRESH_INTERVAL).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
//! The secure handoff between the Nostr transport and the Grin wallet:
|
||||
//! gp-nostr's [`SlatepackReceiver`] implemented over [`gp_wallet::GpWallet`]
|
||||
//! plus the SQLite payment table. Only armored slatepack strings cross the
|
||||
//! boundary, exactly like Goblin hands a gift-wrapped slatepack to its wallet.
|
||||
//!
|
||||
//! On top of the milestone-3 receive + reply, this adapter runs the
|
||||
//! milestone-5 matching layer (link the payment to an invoice and/or tenant
|
||||
//! user) and enqueues the milestone-6 webhook, via the shared
|
||||
//! [`crate::record::persist_and_match`] so a manual-slatepack payment takes the
|
||||
//! identical path.
|
||||
|
||||
use gp_core::config::MatchMode;
|
||||
use gp_nostr::{
|
||||
IncomingContext, ReceiveError, ReceivedPayment, SlatepackReceiver, UnrepliedPayment,
|
||||
};
|
||||
use gp_wallet::{GpWallet, WalletError};
|
||||
use log::warn;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::record::persist_and_match;
|
||||
|
||||
/// Wallet + database receiver for incoming S1 slatepacks.
|
||||
pub struct WalletReceiver {
|
||||
wallet: GpWallet,
|
||||
pool: SqlitePool,
|
||||
/// Global default matching mode (per-invoice overrides win over this).
|
||||
default_mode: MatchMode,
|
||||
/// Webhook endpoint + HMAC secret, when notifications are configured.
|
||||
webhook: Option<(String, String)>,
|
||||
}
|
||||
|
||||
impl WalletReceiver {
|
||||
/// A receiver with the default matching mode and no webhook (the
|
||||
/// milestone-3 E2E path).
|
||||
pub fn new(wallet: GpWallet, pool: SqlitePool) -> WalletReceiver {
|
||||
WalletReceiver {
|
||||
wallet,
|
||||
pool,
|
||||
default_mode: MatchMode::Memo,
|
||||
webhook: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A receiver with the matching default and (optional) webhook wired.
|
||||
pub fn with_matching(
|
||||
wallet: GpWallet,
|
||||
pool: SqlitePool,
|
||||
default_mode: MatchMode,
|
||||
webhook_url: Option<String>,
|
||||
webhook_secret: Option<String>,
|
||||
) -> WalletReceiver {
|
||||
let webhook = match (webhook_url, webhook_secret) {
|
||||
(Some(url), Some(secret)) => Some((url, secret)),
|
||||
_ => None,
|
||||
};
|
||||
WalletReceiver {
|
||||
wallet,
|
||||
pool,
|
||||
default_mode,
|
||||
webhook,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SlatepackReceiver for WalletReceiver {
|
||||
async fn receive(
|
||||
&self,
|
||||
s1_armor: &str,
|
||||
ctx: &IncomingContext<'_>,
|
||||
) -> Result<ReceivedPayment, ReceiveError> {
|
||||
// The wallet enforces everything slate-level: parse, S1-only,
|
||||
// receive_tx (offline), S2 armor. `receive_slatepack` blocks for a
|
||||
// few milliseconds; the ingest loop is this runtime's only consumer,
|
||||
// so a direct call is fine (Goblin does the same).
|
||||
let received = self
|
||||
.wallet
|
||||
.receive_slatepack(s1_armor)
|
||||
.map_err(|e| match e {
|
||||
WalletError::Slatepack(m) => ReceiveError::Rejected(m),
|
||||
// gp-wallet errors are strings by design; grin's own
|
||||
// duplicate-receive guard surfaces through this message.
|
||||
WalletError::Wallet(m) if m.contains("already been received") => {
|
||||
ReceiveError::Duplicate
|
||||
}
|
||||
other => ReceiveError::Failed(other.to_string()),
|
||||
})?;
|
||||
|
||||
// Persist, match (all three modes), and enqueue the webhook. Side
|
||||
// effects only: the reply is what completes the payment, so a matching
|
||||
// or webhook hiccup never fails the receive.
|
||||
persist_and_match(
|
||||
&self.pool,
|
||||
&received,
|
||||
Some(ctx.payer_hex),
|
||||
ctx.recipient_hex,
|
||||
ctx.memo,
|
||||
self.default_mode,
|
||||
self.webhook.as_ref(),
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(ReceivedPayment {
|
||||
slate_id: received.slate_id,
|
||||
amount: received.amount,
|
||||
s2_armor: received.s2_armor,
|
||||
})
|
||||
}
|
||||
|
||||
async fn mark_replied(&self, slate_id: &str) {
|
||||
if let Err(e) = sqlx::query("UPDATE payment SET status = 'replied' WHERE slate_id = ?1")
|
||||
.bind(slate_id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
{
|
||||
warn!("payment status update failed for {slate_id}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
async fn unreplied(&self) -> Vec<UnrepliedPayment> {
|
||||
let rows: Vec<(String, String, String, Option<String>)> = sqlx::query_as(
|
||||
"SELECT slate_id, payer, s2_armor, recipient FROM payment \
|
||||
WHERE status = 'received' AND s2_armor IS NOT NULL AND payer IS NOT NULL",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
warn!("unreplied payment query failed: {e}");
|
||||
vec![]
|
||||
});
|
||||
rows.into_iter()
|
||||
.map(
|
||||
|(slate_id, payer_hex, s2_armor, recipient)| UnrepliedPayment {
|
||||
slate_id,
|
||||
payer_hex,
|
||||
s2_armor,
|
||||
recipient_hex: recipient.unwrap_or_default(),
|
||||
},
|
||||
)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
//! The connector-facing invoice API (authenticated with `GP_API_TOKEN`):
|
||||
//! create an invoice and read its checkout info. This is what a store
|
||||
//! connector (WooCommerce, Medusa, generic REST) calls; it returns the hosted
|
||||
//! `/pay/<token>` URL plus the nprofile + QR so the store can render or
|
||||
//! redirect. All matching modes are supported per invoice.
|
||||
|
||||
use actix_web::{web, HttpRequest, HttpResponse, Responder};
|
||||
use gp_core::config::{Config, MatchMode};
|
||||
use gp_core::invoice::{self, AmountSpec, NewInvoice};
|
||||
use gp_core::rates::{Oracle, RateError};
|
||||
use gp_core::store::{CreateInvoiceRequest, RestConnector, StoreConnector};
|
||||
use gp_nostr::Keys;
|
||||
use log::error;
|
||||
use serde::Deserialize;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::auth::authorized;
|
||||
use crate::checkout::{build_info, CheckoutInfo};
|
||||
use crate::payments::ReceiptSigner;
|
||||
|
||||
/// Register the invoice API routes.
|
||||
pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
cfg.route("/invoice", web::post().to(create_invoice))
|
||||
.route("/invoice/{id}", web::get().to(get_invoice));
|
||||
}
|
||||
|
||||
/// JSON body for `POST /invoice`.
|
||||
#[derive(Deserialize)]
|
||||
struct CreateInvoiceBody {
|
||||
/// The store's order reference (memo/subject match key).
|
||||
order_ref: Option<String>,
|
||||
/// Exact amount in nanogrin.
|
||||
amount_grin: Option<u64>,
|
||||
/// Or a fiat amount (decimal string) plus currency (Grin quote deferred).
|
||||
amount_fiat: Option<String>,
|
||||
currency: Option<String>,
|
||||
memo: Option<String>,
|
||||
/// Per-invoice matching mode override: `memo`, `derived`, or `amount`.
|
||||
match_mode: Option<String>,
|
||||
/// Expiry in seconds from now.
|
||||
expiry_secs: Option<i64>,
|
||||
}
|
||||
|
||||
fn parse_mode(s: &str) -> Option<MatchMode> {
|
||||
match s {
|
||||
"memo" => Some(MatchMode::Memo),
|
||||
"derived" => Some(MatchMode::Derived),
|
||||
"amount" => Some(MatchMode::Amount),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON shape returned for a created/fetched invoice.
|
||||
fn checkout_json(info: &CheckoutInfo) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"invoice_id": info.invoice_id,
|
||||
"token": info.token,
|
||||
"pay_url": info.pay_url,
|
||||
"recipient_pubkey": info.recipient_pubkey,
|
||||
"npub": info.npub,
|
||||
"nprofile": info.nprofile,
|
||||
"qr_svg": info.qr_svg,
|
||||
"amount": info.amount_display,
|
||||
"status": info.status,
|
||||
"order_ref": info.order_ref,
|
||||
"memo": info.memo,
|
||||
})
|
||||
}
|
||||
|
||||
/// POST /invoice (auth): create an invoice, return its checkout info.
|
||||
async fn create_invoice(
|
||||
req: HttpRequest,
|
||||
body: web::Json<CreateInvoiceBody>,
|
||||
pool: web::Data<SqlitePool>,
|
||||
cfg: web::Data<Config>,
|
||||
signer: web::Data<ReceiptSigner>,
|
||||
oracle: web::Data<Oracle>,
|
||||
) -> impl Responder {
|
||||
if !authorized(&req, cfg.api_token.as_ref().map(|s| s.reveal())) {
|
||||
return HttpResponse::Unauthorized().json(serde_json::json!({"error": "unauthorized"}));
|
||||
}
|
||||
// Invoice creation needs the server identity (to derive per-invoice keys
|
||||
// and to name the master recipient).
|
||||
let Some(keys) = signer.0.as_ref() else {
|
||||
return HttpResponse::ServiceUnavailable()
|
||||
.json(serde_json::json!({"error": "server identity not loaded (GP_INGEST=off)"}));
|
||||
};
|
||||
|
||||
let body = body.into_inner();
|
||||
let amount = match (body.amount_grin, body.amount_fiat, body.currency) {
|
||||
(Some(nano), _, _) => AmountSpec::Grin(nano),
|
||||
(None, Some(amount), Some(currency)) => AmountSpec::Fiat { amount, currency },
|
||||
_ => {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({
|
||||
"error": "provide amount_grin, or amount_fiat + currency"
|
||||
}))
|
||||
}
|
||||
};
|
||||
let match_mode = match body.match_mode.as_deref() {
|
||||
Some(m) => match parse_mode(m) {
|
||||
Some(mode) => Some(mode),
|
||||
None => {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({
|
||||
"error": "match_mode must be memo, derived, or amount"
|
||||
}))
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
|
||||
// Route the request through the store connector (uniform mapping).
|
||||
let connector = RestConnector::new(cfg.webhook_url.clone());
|
||||
let params: NewInvoice = connector.new_invoice(CreateInvoiceRequest {
|
||||
order_ref: body.order_ref,
|
||||
amount,
|
||||
memo: body.memo,
|
||||
match_mode,
|
||||
expiry_secs: body.expiry_secs,
|
||||
});
|
||||
|
||||
// Milestone 7: a fiat invoice is priced into Grin by the oracle (DIRECT
|
||||
// HTTP, never Nym) and its quote locked for the expiry window, so its
|
||||
// expected_amount is filled and it matches by amount. A Grin invoice
|
||||
// bypasses the oracle entirely. Fail fast on an unpriceable invoice.
|
||||
let params = match price_if_fiat(oracle.get_ref(), params).await {
|
||||
Ok(params) => params,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
|
||||
let master_sk = master_secret(keys);
|
||||
let master_hex = keys.public_key().to_hex();
|
||||
let inv = match invoice::create(
|
||||
pool.get_ref(),
|
||||
params,
|
||||
&master_sk,
|
||||
&master_hex,
|
||||
cfg.match_mode,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(inv) => inv,
|
||||
Err(e) => {
|
||||
error!("create invoice failed: {e}");
|
||||
return HttpResponse::InternalServerError()
|
||||
.json(serde_json::json!({"error": "internal error"}));
|
||||
}
|
||||
};
|
||||
let info = build_info(&inv, cfg.get_ref());
|
||||
HttpResponse::Ok().json(checkout_json(&info))
|
||||
}
|
||||
|
||||
/// GET /invoice/{id} (auth): the invoice's current checkout info + status.
|
||||
async fn get_invoice(
|
||||
req: HttpRequest,
|
||||
path: web::Path<String>,
|
||||
pool: web::Data<SqlitePool>,
|
||||
cfg: web::Data<Config>,
|
||||
) -> impl Responder {
|
||||
if !authorized(&req, cfg.api_token.as_ref().map(|s| s.reveal())) {
|
||||
return HttpResponse::Unauthorized().json(serde_json::json!({"error": "unauthorized"}));
|
||||
}
|
||||
match invoice::get(pool.get_ref(), &path.into_inner()).await {
|
||||
Ok(Some(inv)) => {
|
||||
let info = build_info(&inv, cfg.get_ref());
|
||||
HttpResponse::Ok().json(checkout_json(&info))
|
||||
}
|
||||
Ok(None) => HttpResponse::NotFound().json(serde_json::json!({"error": "not found"})),
|
||||
Err(e) => {
|
||||
error!("get invoice: {e}");
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "internal"}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Price a fiat invoice through the oracle, in place. A `Grin` or already
|
||||
/// `FiatQuoted` amount passes through untouched. On a fiat amount the oracle is
|
||||
/// consulted (DIRECT HTTP); on success the amount becomes `FiatQuoted` with the
|
||||
/// locked nanogrin and the expiry is clamped to the quote-lock window so the
|
||||
/// locked rate is never honoured past it. On failure a clear HTTP error is
|
||||
/// returned (never a silently unpriced invoice).
|
||||
async fn price_if_fiat(
|
||||
oracle: &Oracle,
|
||||
mut params: NewInvoice,
|
||||
) -> Result<NewInvoice, HttpResponse> {
|
||||
let AmountSpec::Fiat { amount, currency } = ¶ms.amount else {
|
||||
return Ok(params);
|
||||
};
|
||||
let (amount, currency) = (amount.clone(), currency.clone());
|
||||
match oracle.quote(&amount, ¤cy).await {
|
||||
Ok(quote) => {
|
||||
// The quote lock window (GP_QUOTE_TTL) caps the invoice expiry: a
|
||||
// shorter requested expiry is kept, anything longer (or unset) is
|
||||
// clamped so the rate is not honoured beyond its lock.
|
||||
let ttl = oracle.quote_ttl_secs();
|
||||
params.expiry_secs = Some(match params.expiry_secs {
|
||||
Some(secs) if secs > 0 && secs < ttl => secs,
|
||||
_ => ttl,
|
||||
});
|
||||
params.amount = AmountSpec::FiatQuoted {
|
||||
amount,
|
||||
currency,
|
||||
nanogrin: quote.nanogrin,
|
||||
rate: gp_core::rates::format_rate(quote.fiat_per_grin),
|
||||
source: quote.source.to_string(),
|
||||
};
|
||||
Ok(params)
|
||||
}
|
||||
Err(e) => Err(rate_error_response(&e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Map an oracle failure to a clear HTTP error so create-invoice never returns
|
||||
/// an unpriceable invoice: bad input is a 400, an unreachable source is a 502.
|
||||
fn rate_error_response(err: &RateError) -> HttpResponse {
|
||||
match err {
|
||||
RateError::UnsupportedCurrency(_) | RateError::BadAmount(_) => {
|
||||
HttpResponse::BadRequest().json(serde_json::json!({"error": err.to_string()}))
|
||||
}
|
||||
RateError::SourceUnavailable(_) => {
|
||||
error!("create invoice: {err}");
|
||||
HttpResponse::BadGateway().json(serde_json::json!({"error": err.to_string()}))
|
||||
}
|
||||
RateError::Config(_) => {
|
||||
error!("create invoice: {err}");
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": err.to_string()}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Master Nostr secret bytes (for per-invoice derivation).
|
||||
fn master_secret(keys: &Keys) -> [u8; 32] {
|
||||
keys.secret_key().to_secret_bytes()
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//! gp-server library surface: the pieces the binary shares with the
|
||||
//! integration tests (the wallet/transport handoff, the checkout renderer, the
|
||||
//! matching/webhook recorder). The HTTP server itself lives in `main.rs`.
|
||||
|
||||
pub mod admin;
|
||||
pub mod auth;
|
||||
pub mod checkout;
|
||||
pub mod directory;
|
||||
pub mod ingest;
|
||||
pub mod invoices;
|
||||
pub mod payments;
|
||||
pub mod record;
|
||||
pub mod webhookd;
|
||||
@@ -0,0 +1,297 @@
|
||||
//! GoblinPay HTTP server: Actix-Web with in-process rustls TLS (off by
|
||||
//! default), a zero-JS Askama frontend, the SQLite-backed domain core, and
|
||||
//! (config-gated) the Nostr ingest service receiving payments over the Nym
|
||||
//! mixnet.
|
||||
|
||||
use std::io;
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::{web, App, HttpResponse, HttpServer, Responder};
|
||||
use askama::Template;
|
||||
use gp_core::config::{Config, Tls};
|
||||
use gp_nostr::{KeyDirectory, Keys};
|
||||
use gp_server::directory::{self, DbKeyDirectory};
|
||||
use gp_server::ingest::WalletReceiver;
|
||||
use gp_server::payments::{self, ReceiptSigner};
|
||||
use gp_server::{admin, checkout, invoices, webhookd};
|
||||
use gp_wallet::GpWallet;
|
||||
|
||||
/// Landing page ("GoblinPay").
|
||||
#[derive(Template)]
|
||||
#[template(path = "index.html")]
|
||||
struct IndexPage;
|
||||
|
||||
async fn index() -> impl Responder {
|
||||
match IndexPage.render() {
|
||||
Ok(html) => HttpResponse::Ok()
|
||||
.content_type("text/html; charset=utf-8")
|
||||
.body(html),
|
||||
Err(err) => HttpResponse::InternalServerError().body(format!("template error: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn health() -> impl Responder {
|
||||
HttpResponse::Ok().json(serde_json::json!({
|
||||
"status": "ok",
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
}))
|
||||
}
|
||||
|
||||
/// The one hand-written stylesheet, embedded at compile time (zero build step).
|
||||
async fn style() -> impl Responder {
|
||||
HttpResponse::Ok()
|
||||
.content_type("text/css; charset=utf-8")
|
||||
.body(include_str!("../../../static/style.css"))
|
||||
}
|
||||
|
||||
/// The bundled Goblin mark, the default QR center logo.
|
||||
async fn goblin_mark() -> impl Responder {
|
||||
HttpResponse::Ok()
|
||||
.content_type("image/svg+xml")
|
||||
.body(include_str!("../../../static/goblin-mark.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));
|
||||
// Payment status + signed-receipt reads (public-by-token, M4).
|
||||
payments::configure(cfg);
|
||||
// Hosted checkout + manual slatepack (public-by-token, M5).
|
||||
checkout::configure(cfg);
|
||||
// Connector invoice API (auth, M5) + admin surface (auth, M5b/M6).
|
||||
invoices::configure(cfg);
|
||||
admin::configure(cfg);
|
||||
}
|
||||
|
||||
/// Boot the Nostr ingest service (M3): open the wallet, resolve the payment
|
||||
/// identity, build the multi-identity key directory (M5b), seed the initial
|
||||
/// watch set, and start the relay listener over Nym on its own thread. Fails
|
||||
/// fast on misconfiguration. Returns the identity keys (for receipts + invoice
|
||||
/// derivation) and a clone of the wallet (for the manual-slatepack handler).
|
||||
async fn start_ingest(cfg: &Config, pool: sqlx::SqlitePool) -> (Keys, GpWallet) {
|
||||
let wallet = match GpWallet::open(cfg) {
|
||||
Ok(wallet) => wallet,
|
||||
Err(e) => {
|
||||
eprintln!("wallet error: {e}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
match wallet.slatepack_address() {
|
||||
Ok(addr) => println!("wallet ready (slatepack address {addr})"),
|
||||
Err(e) => {
|
||||
eprintln!("wallet error: {e}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
let keys = match gp_nostr::identity::load_or_create(cfg) {
|
||||
Ok(keys) => keys,
|
||||
Err(e) => {
|
||||
eprintln!("identity error: {e}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
println!(
|
||||
"payment identity ready: {} (advertising `{}`)",
|
||||
gp_nostr::npub(&keys),
|
||||
gp_nostr::wrap::ENCRYPTION_CAPABILITIES
|
||||
);
|
||||
|
||||
if cfg.nym {
|
||||
gp_nostr::nym::warm_up();
|
||||
}
|
||||
let merchant = cfg
|
||||
.merchant_npub
|
||||
.as_deref()
|
||||
.and_then(gp_nostr::pubkey_from_str);
|
||||
if cfg.notify_merchant_dm && merchant.is_none() {
|
||||
eprintln!("warning: GP_NOTIFY_MERCHANT_DM=on but GP_MERCHANT_NPUB is unset/invalid");
|
||||
}
|
||||
let opts = gp_nostr::service::ServiceOptions {
|
||||
relays: gp_nostr::relays::resolve(&cfg.relays),
|
||||
nym: cfg.nym,
|
||||
notify: gp_nostr::service::NotifyOptions {
|
||||
merchant,
|
||||
merchant_dm: cfg.notify_merchant_dm,
|
||||
payer_receipt: cfg.notify_payer_receipt,
|
||||
},
|
||||
};
|
||||
let receiver = WalletReceiver::with_matching(
|
||||
wallet.clone(),
|
||||
pool.clone(),
|
||||
cfg.match_mode,
|
||||
cfg.webhook_url.clone(),
|
||||
cfg.webhook_secret.as_ref().map(|s| s.reveal().to_string()),
|
||||
);
|
||||
|
||||
// The DB-backed directory (master + per-invoice + endpub children). Seed
|
||||
// its snapshot before the service subscribes so existing derived identities
|
||||
// are watched from the start, then keep it fresh (and rotate) in the tick.
|
||||
let dir = DbKeyDirectory::new(keys.clone());
|
||||
let snapshot = dir.snapshot();
|
||||
let initial =
|
||||
directory::build_snapshot(&pool, &keys, cfg.match_mode, cfg.endpub_overlap_epochs).await;
|
||||
if let Ok(mut guard) = snapshot.write() {
|
||||
*guard = initial;
|
||||
}
|
||||
directory::spawn_maintenance(
|
||||
pool.clone(),
|
||||
keys.clone(),
|
||||
cfg.match_mode,
|
||||
cfg.endpub_rotate_interval,
|
||||
cfg.endpub_overlap_epochs,
|
||||
snapshot,
|
||||
);
|
||||
let directory: Arc<dyn KeyDirectory> = Arc::new(dir);
|
||||
gp_nostr::service::spawn_with_directory(keys.clone(), opts, receiver, directory);
|
||||
(keys, wallet)
|
||||
}
|
||||
|
||||
/// Build a rustls server config from PEM certificate-chain and key files.
|
||||
fn tls_server_config(cert_path: &str, key_path: &str) -> Result<rustls::ServerConfig, String> {
|
||||
let mut cert_reader = io::BufReader::new(
|
||||
std::fs::File::open(cert_path)
|
||||
.map_err(|e| format!("GP_TLS_CERT `{cert_path}` unreadable: {e}"))?,
|
||||
);
|
||||
let certs = rustls_pemfile::certs(&mut cert_reader)
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| format!("GP_TLS_CERT `{cert_path}` invalid PEM: {e}"))?;
|
||||
if certs.is_empty() {
|
||||
return Err(format!(
|
||||
"GP_TLS_CERT `{cert_path}` contains no certificates"
|
||||
));
|
||||
}
|
||||
|
||||
let mut key_reader = io::BufReader::new(
|
||||
std::fs::File::open(key_path)
|
||||
.map_err(|e| format!("GP_TLS_KEY `{key_path}` unreadable: {e}"))?,
|
||||
);
|
||||
let key = rustls_pemfile::private_key(&mut key_reader)
|
||||
.map_err(|e| format!("GP_TLS_KEY `{key_path}` invalid PEM: {e}"))?
|
||||
.ok_or_else(|| format!("GP_TLS_KEY `{key_path}` contains no private key"))?;
|
||||
|
||||
rustls::ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(certs, key)
|
||||
.map_err(|e| format!("TLS config rejected: {e}"))
|
||||
}
|
||||
|
||||
#[actix_web::main]
|
||||
async fn main() -> io::Result<()> {
|
||||
// Install the rustls ring provider exactly once, before anything else
|
||||
// touches rustls. Shared by sqlx, nostr-sdk, tungstenite, reqwest, and the
|
||||
// Nym stack (the Build 65/66 gotcha).
|
||||
rustls::crypto::ring::default_provider()
|
||||
.install_default()
|
||||
.expect("install rustls ring crypto provider");
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
||||
|
||||
let cfg = match Config::from_env() {
|
||||
Ok(cfg) => cfg,
|
||||
Err(err) => {
|
||||
eprintln!("configuration error: {err}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
println!(
|
||||
"gp-server {} starting: {}",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
cfg.summary()
|
||||
);
|
||||
|
||||
let pool = gp_core::db::init(&cfg.db_path)
|
||||
.await
|
||||
.map_err(io::Error::other)?;
|
||||
println!("database ready at {}", cfg.db_path);
|
||||
|
||||
let (signer, wallet_opt): (Option<Keys>, Option<GpWallet>) = if cfg.ingest {
|
||||
let (keys, wallet) = start_ingest(&cfg, pool.clone()).await;
|
||||
(Some(keys), Some(wallet))
|
||||
} else {
|
||||
println!("ingest disabled (GP_INGEST=off): serving HTTP only");
|
||||
(None, None)
|
||||
};
|
||||
|
||||
// Confirmation poll (M4): advances received payments to `confirmed` when
|
||||
// their kernel lands. Node reads go DIRECT (never Nym).
|
||||
payments::spawn_confirm_poll(pool.clone(), cfg.node_url.clone());
|
||||
|
||||
// Webhook dispatcher (M6): drains the persisted queue with backoff.
|
||||
if let Some(secret) = cfg.webhook_secret.as_ref() {
|
||||
if cfg.webhook_url.is_some() {
|
||||
webhookd::spawn(pool.clone(), secret.reveal().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let receipt_signer = ReceiptSigner(signer);
|
||||
let cfg_data = web::Data::new(cfg.clone());
|
||||
let wallet_data = web::Data::new(wallet_opt);
|
||||
// The conversion-rate oracle (M7): shared across workers, prices fiat
|
||||
// invoices at create time over DIRECT HTTP (never Nym).
|
||||
let oracle_data = web::Data::new(gp_core::rates::Oracle::from_config(&cfg));
|
||||
let server = HttpServer::new(move || {
|
||||
App::new()
|
||||
.app_data(web::Data::new(pool.clone()))
|
||||
.app_data(web::Data::new(receipt_signer.clone()))
|
||||
.app_data(cfg_data.clone())
|
||||
.app_data(wallet_data.clone())
|
||||
.app_data(oracle_data.clone())
|
||||
.configure(routes)
|
||||
});
|
||||
|
||||
match &cfg.tls {
|
||||
Tls::Off => {
|
||||
println!("listening on http://{}", cfg.bind);
|
||||
server.bind(&cfg.bind)?.run().await
|
||||
}
|
||||
Tls::Rustls { cert, key } => {
|
||||
let tls = tls_server_config(cert, key).map_err(io::Error::other)?;
|
||||
println!("listening on https://{}", cfg.bind);
|
||||
server.bind_rustls_0_23(&cfg.bind, tls)?.run().await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use actix_web::{test, App};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[actix_web::test]
|
||||
async fn health_returns_ok_and_version() {
|
||||
let app = test::init_service(App::new().configure(routes)).await;
|
||||
let req = test::TestRequest::get().uri("/health").to_request();
|
||||
let body: serde_json::Value = test::call_and_read_body_json(&app, req).await;
|
||||
assert_eq!(body["status"], "ok");
|
||||
assert_eq!(body["version"], env!("CARGO_PKG_VERSION"));
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn index_renders_goblinpay() {
|
||||
let app = test::init_service(App::new().configure(routes)).await;
|
||||
let req = test::TestRequest::get().uri("/").to_request();
|
||||
let resp = test::call_service(&app, req).await;
|
||||
assert!(resp.status().is_success());
|
||||
let body = test::read_body(resp).await;
|
||||
let html = std::str::from_utf8(&body).unwrap();
|
||||
assert!(html.contains("GoblinPay"));
|
||||
assert!(html.contains("/static/style.css"));
|
||||
assert!(!html.contains("<script"));
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn stylesheet_is_served() {
|
||||
let app = test::init_service(App::new().configure(routes)).await;
|
||||
let req = test::TestRequest::get()
|
||||
.uri("/static/style.css")
|
||||
.to_request();
|
||||
let resp = test::call_service(&app, req).await;
|
||||
assert!(resp.status().is_success());
|
||||
let content_type = resp.headers().get("content-type").unwrap();
|
||||
assert!(content_type.to_str().unwrap().starts_with("text/css"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
//! Payment read surface (M4): the confirmation poll that advances received
|
||||
//! payments to `confirmed` when their kernel lands, plus two public-by-token
|
||||
//! read endpoints:
|
||||
//!
|
||||
//! GET /payment/{id} -> payment status JSON
|
||||
//! GET /payment/{id}/receipt -> the server-signed, verifiable receipt
|
||||
//!
|
||||
//! Both are keyed by the payment id (the Grin slate UUID), which is an
|
||||
//! unguessable bearer token, so no separate auth is needed for these reads
|
||||
//! (admin/write endpoints arrive with later milestones). The receipt is
|
||||
//! self-authenticating (BIP-340 Schnorr over the server identity key), so it
|
||||
//! is safe to expose.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use actix_web::{web, HttpResponse, Responder};
|
||||
use gp_nostr::receipt::{sign_receipt, Receipt, RECEIPT_VERSION};
|
||||
use gp_nostr::Keys;
|
||||
use log::{debug, error, info, warn};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
/// How often the confirmation poll runs. Node reads are direct and cheap, so a
|
||||
/// simple fixed interval is enough; a payment confirms within one interval of
|
||||
/// its kernel landing.
|
||||
const CONFIRM_POLL_INTERVAL: Duration = Duration::from_secs(30);
|
||||
|
||||
/// The receipt signer: the server identity keys, or `None` when ingest (and
|
||||
/// thus the identity) is disabled. Shared into the HTTP app as app data.
|
||||
#[derive(Clone)]
|
||||
pub struct ReceiptSigner(pub Option<Keys>);
|
||||
|
||||
/// Register the M4 read routes.
|
||||
pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
cfg.route("/payment/{id}", web::get().to(payment_status))
|
||||
.route("/payment/{id}/receipt", web::get().to(payment_receipt));
|
||||
}
|
||||
|
||||
/// Spawn the confirmation poll on the current (Actix/tokio) runtime. It scans
|
||||
/// not-yet-confirmed payments that carry a kernel excess and does one DIRECT
|
||||
/// `get_kernel` per payment (off the async workers via `spawn_blocking`,
|
||||
/// because the grin node client blocks). When the kernel is on chain the row
|
||||
/// advances to `confirmed` with its height + timestamp.
|
||||
pub fn spawn_confirm_poll(pool: SqlitePool, node_url: String) {
|
||||
actix_web::rt::spawn(async move {
|
||||
info!("confirm: polling pending payments every {CONFIRM_POLL_INTERVAL:?} via {node_url}");
|
||||
loop {
|
||||
actix_web::rt::time::sleep(CONFIRM_POLL_INTERVAL).await;
|
||||
if let Err(e) = confirm_pending(&pool, &node_url).await {
|
||||
warn!("confirm: poll pass failed: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// One poll pass. Returns Err only on a DB error reading the work list; a
|
||||
/// single payment's node read failing is logged and retried next pass (never
|
||||
/// drops confirmation tracking).
|
||||
async fn confirm_pending(pool: &SqlitePool, node_url: &str) -> Result<(), sqlx::Error> {
|
||||
let pending: Vec<(String, String)> = sqlx::query_as(
|
||||
"SELECT slate_id, kernel FROM payment \
|
||||
WHERE status IN ('received', 'replied') AND kernel IS NOT NULL",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
if pending.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
debug!("confirm: checking {} pending payment(s)", pending.len());
|
||||
|
||||
for (slate_id, kernel_excess) in pending {
|
||||
let node = node_url.to_string();
|
||||
let excess = kernel_excess.clone();
|
||||
// The grin node client blocks (its own runtime); keep it off the async
|
||||
// workers.
|
||||
let result =
|
||||
actix_web::rt::task::spawn_blocking(move || gp_wallet::confirm_status(&node, &excess))
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(status)) if status.confirmed => {
|
||||
let height = status.height.map(|h| h as i64);
|
||||
if let Err(e) = sqlx::query(
|
||||
"UPDATE payment SET status = 'confirmed', confirmed_height = ?1, \
|
||||
confirmed_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE slate_id = ?2",
|
||||
)
|
||||
.bind(height)
|
||||
.bind(&slate_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
{
|
||||
error!("confirm: failed to mark {slate_id} confirmed: {e}");
|
||||
} else {
|
||||
info!(
|
||||
"confirm: payment {slate_id} confirmed at height {:?}",
|
||||
status.height
|
||||
);
|
||||
}
|
||||
}
|
||||
// Not yet on chain: leave pending, retry next pass.
|
||||
Ok(Ok(_status)) => {}
|
||||
Ok(Err(e)) => warn!("confirm: node read failed for {slate_id}: {e}"),
|
||||
Err(e) => warn!("confirm: confirm task panicked for {slate_id}: {e}"),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// GET /payment/{id}: status JSON (pure DB read; public-by-token).
|
||||
async fn payment_status(path: web::Path<String>, pool: web::Data<SqlitePool>) -> impl Responder {
|
||||
let id = path.into_inner();
|
||||
#[allow(clippy::type_complexity)] // a flat sqlx row tuple, destructured just below
|
||||
let row: Option<(
|
||||
i64,
|
||||
Option<String>,
|
||||
String,
|
||||
Option<i64>,
|
||||
Option<String>,
|
||||
String,
|
||||
)> = match sqlx::query_as(
|
||||
"SELECT amount, payer, status, confirmed_height, confirmed_at, created_at \
|
||||
FROM payment WHERE slate_id = ?1",
|
||||
)
|
||||
.bind(&id)
|
||||
.fetch_optional(pool.get_ref())
|
||||
.await
|
||||
{
|
||||
Ok(row) => row,
|
||||
Err(e) => {
|
||||
error!("status: query failed for {id}: {e}");
|
||||
return HttpResponse::InternalServerError()
|
||||
.json(serde_json::json!({"error": "internal error"}));
|
||||
}
|
||||
};
|
||||
|
||||
match row {
|
||||
Some((amount, payer, status, confirmed_height, confirmed_at, created_at)) => {
|
||||
HttpResponse::Ok().json(serde_json::json!({
|
||||
"payment_id": id,
|
||||
"amount": amount,
|
||||
"payer": payer,
|
||||
"status": status,
|
||||
"confirmed_height": confirmed_height,
|
||||
"confirmed_at": confirmed_at,
|
||||
"created_at": created_at,
|
||||
}))
|
||||
}
|
||||
None => HttpResponse::NotFound().json(serde_json::json!({"error": "payment not found"})),
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /payment/{id}/receipt: the server-signed, verifiable receipt.
|
||||
async fn payment_receipt(
|
||||
path: web::Path<String>,
|
||||
pool: web::Data<SqlitePool>,
|
||||
signer: web::Data<ReceiptSigner>,
|
||||
) -> impl Responder {
|
||||
let id = path.into_inner();
|
||||
|
||||
let Some(keys) = signer.0.as_ref() else {
|
||||
return HttpResponse::ServiceUnavailable().json(serde_json::json!({
|
||||
"error": "receipt signing unavailable (server identity not loaded)"
|
||||
}));
|
||||
};
|
||||
|
||||
#[allow(clippy::type_complexity)] // a flat sqlx row tuple, destructured just below
|
||||
let row: Option<(i64, Option<String>, Option<String>, Option<i64>)> = match sqlx::query_as(
|
||||
"SELECT amount, kernel, proof, confirmed_height FROM payment WHERE slate_id = ?1",
|
||||
)
|
||||
.bind(&id)
|
||||
.fetch_optional(pool.get_ref())
|
||||
.await
|
||||
{
|
||||
Ok(row) => row,
|
||||
Err(e) => {
|
||||
error!("receipt: query failed for {id}: {e}");
|
||||
return HttpResponse::InternalServerError()
|
||||
.json(serde_json::json!({"error": "internal error"}));
|
||||
}
|
||||
};
|
||||
|
||||
let Some((amount, kernel, proof, confirmed_height)) = row else {
|
||||
return HttpResponse::NotFound().json(serde_json::json!({"error": "payment not found"}));
|
||||
};
|
||||
let Some(kernel_excess) = kernel else {
|
||||
// No kernel recorded means the payment predates M4 or was not received
|
||||
// through the wallet path; a receipt has nothing to anchor.
|
||||
return HttpResponse::Conflict()
|
||||
.json(serde_json::json!({"error": "payment has no kernel excess recorded"}));
|
||||
};
|
||||
|
||||
let receipt = Receipt {
|
||||
version: RECEIPT_VERSION,
|
||||
payment_id: id,
|
||||
amount: amount as u64,
|
||||
kernel_excess,
|
||||
confirmed_height: confirmed_height.map(|h| h as u64),
|
||||
confirmations: None,
|
||||
proof: proof.and_then(|p| serde_json::from_str(&p).ok()),
|
||||
issued_at: now_iso8601(),
|
||||
server_pubkey: String::new(), // filled by sign_receipt
|
||||
};
|
||||
|
||||
match sign_receipt(keys, receipt) {
|
||||
Ok(signed) => HttpResponse::Ok().json(signed),
|
||||
Err(e) => {
|
||||
error!("receipt: signing failed: {e}");
|
||||
HttpResponse::InternalServerError()
|
||||
.json(serde_json::json!({"error": "receipt signing failed"}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ISO-8601 UTC timestamp (seconds), no extra dependency.
|
||||
fn now_iso8601() -> String {
|
||||
// Delegate the calendar math to SQLite-free logic is overkill here; use the
|
||||
// same seconds-since-epoch the rest of the crate uses and format via a tiny
|
||||
// civil-time conversion.
|
||||
let secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
format_unix_utc(secs)
|
||||
}
|
||||
|
||||
/// Format a Unix timestamp (seconds) as `YYYY-MM-DDTHH:MM:SSZ` (UTC), using the
|
||||
/// civil-from-days algorithm (Howard Hinnant), so no date-time dependency is
|
||||
/// pulled in for one timestamp.
|
||||
fn format_unix_utc(secs: u64) -> String {
|
||||
let days = (secs / 86_400) as i64;
|
||||
let rem = secs % 86_400;
|
||||
let (hh, mm, ss) = (rem / 3600, (rem % 3600) / 60, rem % 60);
|
||||
|
||||
// days since 1970-01-01 -> civil date
|
||||
let z = days + 719_468;
|
||||
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||
let doe = z - era * 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = if m <= 2 { y + 1 } else { y };
|
||||
|
||||
format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}Z")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn formats_known_epochs() {
|
||||
assert_eq!(format_unix_utc(0), "1970-01-01T00:00:00Z");
|
||||
// The Unix billennium.
|
||||
assert_eq!(format_unix_utc(1_000_000_000), "2001-09-09T01:46:40Z");
|
||||
// A widely-cited round timestamp.
|
||||
assert_eq!(format_unix_utc(1_700_000_000), "2023-11-14T22:13:20Z");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//! Persisting a received payment and running the matching + webhook side
|
||||
//! effects, shared by the Nostr ingest adapter and the manual-slatepack
|
||||
//! handler so both take exactly the same path (record -> match -> notify).
|
||||
|
||||
use gp_core::config::MatchMode;
|
||||
use gp_core::invoice;
|
||||
use gp_core::matching::{match_payment, IncomingPayment, MatchResult};
|
||||
use gp_core::webhook::{enqueue, WebhookPayload};
|
||||
use gp_wallet::Received;
|
||||
use log::{error, warn};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
/// Insert the payment row, resolve it to an invoice/user, and enqueue the
|
||||
/// webhook if one is configured. Returns what it matched. Never fails the
|
||||
/// caller: the money is already in hand, so persistence/matching/webhook
|
||||
/// errors are logged and swallowed.
|
||||
pub async fn persist_and_match(
|
||||
pool: &SqlitePool,
|
||||
received: &Received,
|
||||
payer_hex: Option<&str>,
|
||||
recipient_hex: &str,
|
||||
memo: Option<&str>,
|
||||
default_mode: MatchMode,
|
||||
webhook: Option<&(String, String)>,
|
||||
) -> MatchResult {
|
||||
let inserted = sqlx::query(
|
||||
"INSERT INTO payment \
|
||||
(id, amount, payer, slate_id, kernel, proof, s2_armor, recipient, status, \
|
||||
created_at) \
|
||||
VALUES (?1, ?2, ?3, ?1, ?4, ?5, ?6, ?7, 'received', \
|
||||
strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))",
|
||||
)
|
||||
.bind(&received.slate_id)
|
||||
.bind(received.amount as i64)
|
||||
.bind(payer_hex)
|
||||
.bind(&received.kernel_excess)
|
||||
.bind(&received.proof)
|
||||
.bind(&received.s2_armor)
|
||||
.bind(recipient_hex)
|
||||
.execute(pool)
|
||||
.await;
|
||||
if let Err(e) = inserted {
|
||||
// A duplicate id (same slate received twice) is expected on a retry;
|
||||
// anything else is logged. Either way, keep going so the reply/S2 is
|
||||
// still handed back.
|
||||
error!("payment record insert for {}: {e}", received.slate_id);
|
||||
}
|
||||
|
||||
let matched = match match_payment(
|
||||
pool,
|
||||
default_mode,
|
||||
&IncomingPayment {
|
||||
slate_id: &received.slate_id,
|
||||
amount: received.amount,
|
||||
recipient_hex,
|
||||
memo,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
warn!("matching failed for {}: {e}", received.slate_id);
|
||||
MatchResult::default()
|
||||
}
|
||||
};
|
||||
|
||||
if let Some((url, _secret)) = webhook {
|
||||
let order_ref = match matched.invoice_id.as_deref() {
|
||||
Some(id) => invoice::get(pool, id)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|inv| inv.order_ref),
|
||||
None => None,
|
||||
};
|
||||
let payload = WebhookPayload::received(
|
||||
received.slate_id.clone(),
|
||||
received.amount,
|
||||
payer_hex.map(|p| p.to_string()),
|
||||
matched.invoice_id.clone(),
|
||||
order_ref,
|
||||
matched.user_id.clone(),
|
||||
);
|
||||
if let Err(e) = enqueue(pool, url, &payload).await {
|
||||
warn!("webhook enqueue failed for {}: {e}", received.slate_id);
|
||||
}
|
||||
}
|
||||
|
||||
matched
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! The webhook delivery worker (milestone 6): drains the persisted
|
||||
//! `webhook_delivery` queue, POSTs each due payload with its HMAC signature,
|
||||
//! and marks it delivered or reschedules it with backoff. Runs on the Actix
|
||||
//! runtime for the process lifetime.
|
||||
//!
|
||||
//! Idempotency is the receiver's job (dedupe on the `X-GoblinPay-Delivery`
|
||||
//! event id); the worker guarantees at-least-once with bounded retries.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use gp_core::webhook::{self, sign, DELIVERY_HEADER, SIGNATURE_HEADER};
|
||||
use log::{info, warn};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
/// How often the queue is drained.
|
||||
const POLL_INTERVAL: Duration = Duration::from_secs(10);
|
||||
/// Max deliveries attempted per pass.
|
||||
const BATCH: i64 = 20;
|
||||
/// Per-request timeout.
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
|
||||
/// Spawn the webhook dispatcher. `secret` is the HMAC key (`GP_WEBHOOK_SECRET`).
|
||||
pub fn spawn(pool: SqlitePool, secret: String) {
|
||||
actix_web::rt::spawn(async move {
|
||||
let client = match reqwest::Client::builder().timeout(REQUEST_TIMEOUT).build() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
warn!("webhook: HTTP client build failed, dispatcher disabled: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
info!("webhook: dispatcher started (every {POLL_INTERVAL:?})");
|
||||
loop {
|
||||
if let Err(e) = drain(&pool, &client, &secret).await {
|
||||
warn!("webhook: drain pass failed: {e}");
|
||||
}
|
||||
actix_web::rt::time::sleep(POLL_INTERVAL).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// One drain pass: deliver every due webhook once.
|
||||
async fn drain(
|
||||
pool: &SqlitePool,
|
||||
client: &reqwest::Client,
|
||||
secret: &str,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
for delivery in webhook::due(pool, BATCH).await? {
|
||||
let signature = sign(secret, delivery.body.as_bytes());
|
||||
let result = client
|
||||
.post(&delivery.url)
|
||||
.header("content-type", "application/json")
|
||||
.header(SIGNATURE_HEADER, signature)
|
||||
.header(DELIVERY_HEADER, &delivery.id)
|
||||
.body(delivery.body.clone())
|
||||
.send()
|
||||
.await;
|
||||
match result {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
webhook::mark_delivered(pool, &delivery.id).await?;
|
||||
info!(
|
||||
"webhook: delivered {} to {} (HTTP {})",
|
||||
&delivery.id[..8.min(delivery.id.len())],
|
||||
host_of(&delivery.url),
|
||||
resp.status().as_u16()
|
||||
);
|
||||
}
|
||||
Ok(resp) => {
|
||||
let msg = format!("HTTP {}", resp.status().as_u16());
|
||||
webhook::mark_failed(pool, &delivery.id, &msg).await?;
|
||||
}
|
||||
Err(e) => {
|
||||
webhook::mark_failed(pool, &delivery.id, &e.to_string()).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Host of a URL for logging (host-only privacy, never the full path).
|
||||
fn host_of(url: &str) -> String {
|
||||
url.split("://")
|
||||
.nth(1)
|
||||
.and_then(|rest| rest.split('/').next())
|
||||
.unwrap_or("?")
|
||||
.to_string()
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
//! Milestone-5 checkout tests: the hosted `/pay/<token>` page renders (Askama
|
||||
//! render + QR), and the manual-slatepack fallback round-trips a REAL S1
|
||||
//! through gp-wallet's offline `receive_tx` to an S2, recording + matching the
|
||||
//! payment. The S1 is produced by the gp-goblin-sender subprocess (the same
|
||||
//! fixture the milestone-2/3 gate uses), so this is an end-to-end proof of the
|
||||
//! zero-JS manual path with no live network.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
use actix_web::{test, web, App};
|
||||
use gp_core::config::{Config, MatchMode};
|
||||
use gp_core::invoice::{self, AmountSpec, NewInvoice};
|
||||
use gp_nostr::Keys;
|
||||
use gp_server::checkout;
|
||||
use gp_server::payments::ReceiptSigner;
|
||||
use gp_wallet::GpWallet;
|
||||
use rand::RngCore;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
const AMOUNT: u64 = 2_000_000_000; // 2 grin, nanogrin
|
||||
|
||||
struct TempDir(PathBuf);
|
||||
|
||||
impl TempDir {
|
||||
fn new(tag: &str) -> TempDir {
|
||||
static N: AtomicU32 = AtomicU32::new(0);
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"gp-checkout-{tag}-{}-{}",
|
||||
std::process::id(),
|
||||
N.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
fs::create_dir_all(&path).unwrap();
|
||||
TempDir(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// A migrated single-connection in-memory pool.
|
||||
async fn pool() -> SqlitePool {
|
||||
let pool = sqlx::sqlite::SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
gp_core::db::MIGRATOR.run(&pool).await.unwrap();
|
||||
pool
|
||||
}
|
||||
|
||||
fn cfg() -> Config {
|
||||
Config {
|
||||
public_url: "https://pay.example".into(),
|
||||
..Config::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the gp-goblin-sender subprocess (builds a real Goblin-stack S1).
|
||||
fn sender(args: &[&str]) {
|
||||
let workspace_root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.unwrap()
|
||||
.parent()
|
||||
.unwrap()
|
||||
.to_path_buf();
|
||||
let target_dir = std::env::var("CARGO_TARGET_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| workspace_root.join("target"));
|
||||
let bin = target_dir.join("debug").join("gp-goblin-sender");
|
||||
if !bin.exists() {
|
||||
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
|
||||
let build = Command::new(cargo)
|
||||
.current_dir(&workspace_root)
|
||||
.args(["build", "--quiet", "-p", "gp-goblin-sender"])
|
||||
.output()
|
||||
.expect("build gp-goblin-sender");
|
||||
assert!(
|
||||
build.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&build.stderr)
|
||||
);
|
||||
}
|
||||
let out = Command::new(&bin).args(args).output().expect("run sender");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"sender {args:?} failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
fn new_wallet(dir: &Path) -> GpWallet {
|
||||
let mut entropy = [0u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut entropy);
|
||||
let mnemonic = grin_keychain::mnemonic::from_entropy(&entropy).unwrap();
|
||||
GpWallet::open_at(
|
||||
dir,
|
||||
&mnemonic,
|
||||
"checkout-pw",
|
||||
"http://127.0.0.1:3413",
|
||||
grin_core::global::ChainTypes::Mainnet,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Create a memo-mode invoice receiving on the master identity.
|
||||
async fn make_invoice(
|
||||
pool: &SqlitePool,
|
||||
keys: &Keys,
|
||||
amount: AmountSpec,
|
||||
order_ref: &str,
|
||||
) -> invoice::Invoice {
|
||||
let sk = keys.secret_key().to_secret_bytes();
|
||||
let hex = keys.public_key().to_hex();
|
||||
invoice::create(
|
||||
pool,
|
||||
NewInvoice {
|
||||
order_ref: Some(order_ref.to_string()),
|
||||
amount,
|
||||
memo: Some("Coffee".into()),
|
||||
match_mode: Some(MatchMode::Memo),
|
||||
expiry_secs: None,
|
||||
},
|
||||
&sk,
|
||||
&hex,
|
||||
MatchMode::Memo,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn pay_page_renders_zero_js_with_qr_and_nprofile() {
|
||||
let pool = pool().await;
|
||||
let cfg = cfg();
|
||||
let keys = Keys::generate();
|
||||
let inv = make_invoice(&pool, &keys, AmountSpec::Grin(1_500_000_000), "order-1").await;
|
||||
let token = inv.token.clone().unwrap();
|
||||
|
||||
let app = test::init_service(
|
||||
App::new()
|
||||
.app_data(web::Data::new(pool.clone()))
|
||||
.app_data(web::Data::new(cfg.clone()))
|
||||
.app_data(web::Data::new(None::<GpWallet>))
|
||||
.app_data(web::Data::new(ReceiptSigner(Some(keys.clone()))))
|
||||
.configure(checkout::configure),
|
||||
)
|
||||
.await;
|
||||
|
||||
let req = test::TestRequest::get()
|
||||
.uri(&format!("/pay/{token}"))
|
||||
.to_request();
|
||||
let resp = test::call_service(&app, req).await;
|
||||
assert!(resp.status().is_success());
|
||||
let body = test::read_body(resp).await;
|
||||
let html = std::str::from_utf8(&body).unwrap();
|
||||
|
||||
assert!(html.contains("Pay with Goblin"));
|
||||
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("http-equiv=\"refresh\""),
|
||||
"live status refresh while open"
|
||||
);
|
||||
assert!(!html.contains("<script"), "zero JS");
|
||||
|
||||
// The status endpoint reports the open invoice.
|
||||
let req = test::TestRequest::get()
|
||||
.uri(&format!("/pay/{token}/status"))
|
||||
.to_request();
|
||||
let status: serde_json::Value = test::call_and_read_body_json(&app, req).await;
|
||||
assert_eq!(status["status"], "open");
|
||||
assert_eq!(status["invoice_id"], inv.id);
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn manual_slatepack_post_round_trips_and_records_payment() {
|
||||
let pool = pool().await;
|
||||
let cfg = cfg();
|
||||
let keys = Keys::generate();
|
||||
|
||||
// A real wallet to receive into, and an amount-matched invoice.
|
||||
let wallet_dir = TempDir::new("wallet");
|
||||
let wallet = new_wallet(&wallet_dir.0);
|
||||
let inv = make_invoice(&pool, &keys, AmountSpec::Grin(AMOUNT), "order-manual").await;
|
||||
let token = inv.token.clone().unwrap();
|
||||
|
||||
// A real S1 from the Goblin wallet stack.
|
||||
let work = TempDir::new("s1");
|
||||
let workdir = work.0.to_str().unwrap();
|
||||
sender(&["gen", workdir, &AMOUNT.to_string()]);
|
||||
let s1_armor = fs::read_to_string(work.0.join("s1.armor")).unwrap();
|
||||
|
||||
let app = test::init_service(
|
||||
App::new()
|
||||
.app_data(web::Data::new(pool.clone()))
|
||||
.app_data(web::Data::new(cfg.clone()))
|
||||
.app_data(web::Data::new(Some(wallet)))
|
||||
.app_data(web::Data::new(ReceiptSigner(Some(keys.clone()))))
|
||||
.configure(checkout::configure),
|
||||
)
|
||||
.await;
|
||||
|
||||
let req = test::TestRequest::post()
|
||||
.uri(&format!("/pay/{token}/slatepack"))
|
||||
.set_form([("slatepack", s1_armor.as_str())])
|
||||
.to_request();
|
||||
let resp = test::call_service(&app, req).await;
|
||||
assert!(resp.status().is_success());
|
||||
let body = test::read_body(resp).await;
|
||||
let html = std::str::from_utf8(&body).unwrap();
|
||||
|
||||
assert!(html.contains("Payment received"));
|
||||
assert!(html.contains("BEGINSLATEPACK."), "S2 rendered to copy back");
|
||||
assert!(html.contains("ENDSLATEPACK."));
|
||||
assert!(!html.contains("<script"), "zero JS");
|
||||
|
||||
// The payment landed and matched the (memo-mode, amount-carrying) invoice.
|
||||
let (status, matched): (String, Option<String>) =
|
||||
sqlx::query_as("SELECT status, invoice_id FROM payment LIMIT 1")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(status, "received");
|
||||
assert_eq!(matched.as_deref(), Some(inv.id.as_str()));
|
||||
// And the invoice flipped to paid.
|
||||
let paid = invoice::get(&pool, &inv.id).await.unwrap().unwrap();
|
||||
assert_eq!(paid.status(), invoice::InvoiceStatus::Paid);
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
//! Milestone-3 end-to-end proof: pay -> GoblinPay receives -> S2 back,
|
||||
//! with a REAL slatepack at every hop.
|
||||
//!
|
||||
//! The strongest automatable version of the plan's "pay from Goblin ->
|
||||
//! GoblinPay receives -> S2 back": a live over-the-wire run needs the Goblin
|
||||
//! GUI, a funded wallet, relays, and the mixnet, none of which are
|
||||
//! automatable here (headless-test limits) — that run is deferred to the
|
||||
//! supervised mainnet round. Instead:
|
||||
//!
|
||||
//! - The SENDER is Goblin's actual wallet stack (the `gp-goblin-sender`
|
||||
//! subprocess from the milestone-2 gate) producing a real S1, plus a
|
||||
//! stand-in payer Nostr identity built from nostr-sdk — for the v2 leg the
|
||||
//! incoming gift wrap is built by STOCK nostr-sdk (`EventBuilder::
|
||||
//! gift_wrap`), byte-compatible with what today's Goblin publishes; the v3
|
||||
//! leg uses gp-nostr's manual v3 wrap (the only v3 implementation).
|
||||
//! - The ingest events are handed to `Ingest::handle_wrap` DIRECTLY rather
|
||||
//! than through a local relay stub. Deliberate: the relay leg is
|
||||
//! nostr-sdk's own pool driven exactly as Goblin drives it (proven in
|
||||
//! production), while everything milestone 3 adds — unwrap dispatch,
|
||||
//! policy, wallet handoff, reply construction, version negotiation — sits
|
||||
//! behind `handle_wrap`, which the live service loop calls with the same
|
||||
//! arguments. A minimal ws relay stub would re-test nostr-sdk, not us.
|
||||
//! - The RECEIVER is the real production adapter (`WalletReceiver` over
|
||||
//! `gp_wallet::GpWallet` + SQLite), so the payment row and the stored S2
|
||||
//! are asserted too.
|
||||
//! - The reply wrap is decrypted BY THE PAYER (both legs; the v2 leg
|
||||
//! additionally through stock nostr-sdk's `UnwrappedGift`) and the S2 it
|
||||
//! carries is finalized by the Goblin wallet stack (`check` subcommand:
|
||||
//! full offline validation of sums, signatures, range proofs).
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
use gp_nostr::ingest::{Ingest, IngestOutcome};
|
||||
use gp_nostr::wrap::{self, WrapVersion};
|
||||
use gp_nostr::{protocol, SlatepackReceiver};
|
||||
use gp_server::ingest::WalletReceiver;
|
||||
use gp_wallet::GpWallet;
|
||||
use nostr_sdk::nips::nip59::UnwrappedGift;
|
||||
use nostr_sdk::{EventBuilder, JsonUtil, Keys, Kind, Tag};
|
||||
use rand::RngCore;
|
||||
|
||||
const AMOUNT: u64 = 2_000_000_000; // 2 grin, in nanogrin
|
||||
|
||||
/// Self-cleaning unique temp dir.
|
||||
struct TempDir(PathBuf);
|
||||
|
||||
impl TempDir {
|
||||
fn new(tag: &str) -> TempDir {
|
||||
static N: AtomicU32 = AtomicU32::new(0);
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"gp-e2e-{tag}-{}-{}",
|
||||
std::process::id(),
|
||||
N.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
fs::create_dir_all(&path).unwrap();
|
||||
TempDir(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Run one gp-goblin-sender subcommand (same helper as the milestone-2
|
||||
/// gate: the binary is a workspace member, built by `cargo test --workspace`
|
||||
/// before tests run; fall back to one `cargo build` for partial runs).
|
||||
fn sender(args: &[&str]) {
|
||||
let workspace_root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.unwrap()
|
||||
.parent()
|
||||
.unwrap()
|
||||
.to_path_buf();
|
||||
let target_dir = std::env::var("CARGO_TARGET_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| workspace_root.join("target"));
|
||||
let bin = target_dir.join("debug").join("gp-goblin-sender");
|
||||
|
||||
if !bin.exists() {
|
||||
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
|
||||
let build = Command::new(cargo)
|
||||
.current_dir(&workspace_root)
|
||||
.args(["build", "--quiet", "-p", "gp-goblin-sender"])
|
||||
.output()
|
||||
.expect("failed to spawn cargo build for gp-goblin-sender");
|
||||
assert!(
|
||||
build.status.success(),
|
||||
"building gp-goblin-sender failed:\n{}",
|
||||
String::from_utf8_lossy(&build.stderr),
|
||||
);
|
||||
}
|
||||
|
||||
let output = Command::new(&bin)
|
||||
.args(args)
|
||||
.output()
|
||||
.expect("failed to spawn gp-goblin-sender");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"gp-goblin-sender {args:?} failed:\nstdout: {}\nstderr: {}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
}
|
||||
|
||||
fn read_json(path: &Path) -> serde_json::Value {
|
||||
serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn new_wallet(dir: &Path) -> GpWallet {
|
||||
let mut entropy = [0u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut entropy);
|
||||
let mnemonic = grin_keychain::mnemonic::from_entropy(&entropy).unwrap();
|
||||
GpWallet::open_at(
|
||||
dir,
|
||||
&mnemonic,
|
||||
"e2e-password",
|
||||
"http://127.0.0.1:3413",
|
||||
grin_core::global::ChainTypes::Mainnet,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Build the payer-side payment rumor exactly as Goblin's `send_payment_dm`
|
||||
/// does: kind 14, preamble + armor content, goblin/subject tags, p tag.
|
||||
fn payment_rumor(payer: &Keys, server: &Keys, s1_armor: &str) -> nostr_sdk::UnsignedEvent {
|
||||
let mut tags = protocol::build_rumor_tags(Some("e2e test payment"));
|
||||
tags.push(Tag::public_key(server.public_key()));
|
||||
EventBuilder::new(
|
||||
Kind::PrivateDirectMessage,
|
||||
protocol::build_payment_content(s1_armor),
|
||||
)
|
||||
.tags(tags)
|
||||
.build(payer.public_key())
|
||||
}
|
||||
|
||||
/// One full leg: real S1 -> gift wrap (v2 via stock nostr-sdk, v3 via
|
||||
/// gp-nostr) -> ingest -> wallet S2 -> reply wrap (negotiated version) ->
|
||||
/// payer decrypts -> Goblin stack finalizes.
|
||||
async fn leg(tag: &str, incoming: WrapVersion, payer_advertises: Option<&str>) {
|
||||
let work = TempDir::new(tag);
|
||||
let wallet_dir = TempDir::new(&format!("{tag}-wallet"));
|
||||
let workdir = work.0.to_str().unwrap();
|
||||
|
||||
// GoblinPay side: real wallet, real DB, real receiver adapter.
|
||||
let db_path = work.0.join("gp.db");
|
||||
let pool = gp_core::db::init(db_path.to_str().unwrap()).await.unwrap();
|
||||
let receiver = WalletReceiver::new(new_wallet(&wallet_dir.0), pool.clone());
|
||||
let server_keys = Keys::generate();
|
||||
let ingest = Ingest::new(server_keys.clone(), receiver);
|
||||
|
||||
// Payer side: Goblin's wallet stack builds the S1...
|
||||
let amount = AMOUNT.to_string();
|
||||
sender(&["gen", workdir, &amount]);
|
||||
let s1_armor = fs::read_to_string(work.0.join("s1.armor")).unwrap();
|
||||
let slate_id = read_json(&work.0.join("meta.json"))["slate_id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
|
||||
// ...and a stand-in payer identity gift-wraps it to the server npub.
|
||||
let payer_keys = Keys::generate();
|
||||
let rumor = payment_rumor(&payer_keys, &server_keys, &s1_armor);
|
||||
let wrap_event = match incoming {
|
||||
// v2 leg: STOCK nostr-sdk, byte-compatible with today's Goblin.
|
||||
WrapVersion::V2 => {
|
||||
EventBuilder::gift_wrap(&payer_keys, &server_keys.public_key(), rumor, [])
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
// v3 leg: the manual v3 wrap (kind/scope-bound seals + wraps).
|
||||
WrapVersion::V3 => wrap::gift_wrap(
|
||||
&payer_keys,
|
||||
&server_keys.public_key(),
|
||||
rumor,
|
||||
WrapVersion::V3,
|
||||
)
|
||||
.unwrap(),
|
||||
};
|
||||
assert_eq!(
|
||||
nip44::payload_version(&wrap_event.content).unwrap(),
|
||||
match incoming {
|
||||
WrapVersion::V2 => 2,
|
||||
WrapVersion::V3 => 3,
|
||||
}
|
||||
);
|
||||
|
||||
// Ingest: unwrap (version dispatch) -> policy -> wallet receive_tx.
|
||||
let outcome = ingest.handle_wrap(&wrap_event).await;
|
||||
let reply = match outcome {
|
||||
IngestOutcome::Received {
|
||||
slate_id: got_slate,
|
||||
amount: got_amount,
|
||||
reply,
|
||||
} => {
|
||||
assert_eq!(got_slate, slate_id, "slate id must survive the pipeline");
|
||||
assert_eq!(got_amount, AMOUNT, "amount must survive the pipeline");
|
||||
reply
|
||||
}
|
||||
other => panic!("expected Received, got {other:?}"),
|
||||
};
|
||||
assert_eq!(reply.payer, payer_keys.public_key());
|
||||
|
||||
// A redelivered wrap (relay replay) is dropped without a second receive.
|
||||
assert!(matches!(
|
||||
ingest.handle_wrap(&wrap_event).await,
|
||||
IngestOutcome::Dropped(_)
|
||||
));
|
||||
|
||||
// The payment row is durable with the S2 stored for reconcile.
|
||||
let (db_amount, db_payer, db_status, db_s2): (i64, String, String, String) =
|
||||
sqlx::query_as("SELECT amount, payer, status, s2_armor FROM payment WHERE slate_id = ?1")
|
||||
.bind(&slate_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(db_amount as u64, AMOUNT);
|
||||
assert_eq!(db_payer, payer_keys.public_key().to_hex());
|
||||
assert_eq!(db_status, "received");
|
||||
assert!(db_s2.starts_with("BEGINSLATEPACK."));
|
||||
assert_eq!(ingest.receiver().unreplied().await.len(), 1);
|
||||
|
||||
// Reply leg: negotiate the version from the payer's advertised 10050
|
||||
// encryption tag (None = v2-only peer), wrap, and let the payer open it.
|
||||
let reply_version = wrap::choose_version(payer_advertises);
|
||||
let reply_event = wrap::gift_wrap(
|
||||
&server_keys,
|
||||
&reply.payer,
|
||||
reply.rumor.clone(),
|
||||
reply_version,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
nip44::payload_version(&reply_event.content).unwrap(),
|
||||
match reply_version {
|
||||
WrapVersion::V2 => 2,
|
||||
WrapVersion::V3 => 3,
|
||||
}
|
||||
);
|
||||
|
||||
// The payer decrypts the reply (version-byte dispatch, no hints).
|
||||
let unwrapped = wrap::unwrap_gift_wrap(&payer_keys, &reply_event).unwrap();
|
||||
assert_eq!(unwrapped.sender, server_keys.public_key());
|
||||
let s2_armor = protocol::extract_slatepack(&unwrapped.rumor.content)
|
||||
.expect("reply rumor must carry the S2 slatepack");
|
||||
|
||||
// v2 reply leg: prove stock nostr-sdk (today's Goblin) opens our reply
|
||||
// and reads the same S2.
|
||||
if reply_version == WrapVersion::V2 {
|
||||
let gift = UnwrappedGift::from_gift_wrap(&payer_keys, &reply_event)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(gift.sender, server_keys.public_key());
|
||||
assert_eq!(gift.rumor.as_json(), unwrapped.rumor.as_json());
|
||||
}
|
||||
|
||||
// A wrong recipient cannot open the reply.
|
||||
assert!(wrap::unwrap_gift_wrap(&Keys::generate(), &reply_event).is_err());
|
||||
|
||||
ingest.receiver().mark_replied(&slate_id).await;
|
||||
assert!(ingest.receiver().unreplied().await.is_empty());
|
||||
|
||||
// The Goblin wallet stack finalizes the S2: full offline validation of
|
||||
// the receiver's output, range proof, and partial signature.
|
||||
let s2_path = work.0.join("s2.armor");
|
||||
fs::write(&s2_path, &s2_armor).unwrap();
|
||||
sender(&["check", workdir, s2_path.to_str().unwrap()]);
|
||||
let result = read_json(&work.0.join("result.json"));
|
||||
assert_eq!(result["slate_id"].as_str().unwrap(), slate_id);
|
||||
assert_eq!(result["state"].as_str().unwrap(), "Standard3");
|
||||
assert!(result["kernel_verified"].as_bool().unwrap());
|
||||
}
|
||||
|
||||
/// v2 leg: a stock-nostr-sdk payer (today's Goblin, no encryption tag on its
|
||||
/// 10050) pays; the reply negotiates down to v2 and stock nostr-sdk opens it.
|
||||
#[tokio::test]
|
||||
async fn goblin_pays_over_v2_gift_wrap_and_finalizes_the_reply() {
|
||||
leg("v2", WrapVersion::V2, None).await;
|
||||
}
|
||||
|
||||
/// v3 leg: a v3-capable payer (advertising `nip44_v3 nip44_v2`) pays with a
|
||||
/// v3 wrap; the reply negotiates v3 and the payer decrypts it via the nip44
|
||||
/// crate's context-bound path.
|
||||
#[tokio::test]
|
||||
async fn goblin_pays_over_v3_gift_wrap_and_finalizes_the_reply() {
|
||||
leg("v3", WrapVersion::V3, Some("nip44_v3 nip44_v2")).await;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
[package]
|
||||
name = "gp-wallet"
|
||||
description = "Grin wallet handoff for GoblinPay: receive-only over upstream grin-wallet"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
gp-core = { path = "../gp-core" }
|
||||
|
||||
# Money-critical: the official upstream grin-wallet crates, never a
|
||||
# reimplementation of Grin crypto. The tag is PINNED by the Milestone-2
|
||||
# slatepack round-trip gate against Goblin's actual wallet stack
|
||||
# (goblin/wallet, fork of grin-wallet 5.4.0-alpha.1 over vendored
|
||||
# grin_core 5.4.1): tag v5.4.1 = rev 5c20635a24a1afa48c167775081015cae6321a4f.
|
||||
# Validated by tests/goblin_roundtrip.rs (goblin sender -> upstream receiver
|
||||
# -> goblin finalize). Do not bump without re-running that gate.
|
||||
grin_wallet_libwallet = { git = "https://github.com/mimblewimble/grin-wallet", tag = "v5.4.1" }
|
||||
grin_wallet_impls = { git = "https://github.com/mimblewimble/grin-wallet", tag = "v5.4.1" }
|
||||
|
||||
# The grin node crates the wallet crates build on, held to the exact release
|
||||
# line Goblin vendors (goblin/node/* is grin 5.4.1).
|
||||
grin_core = "=5.4.1"
|
||||
grin_keychain = "=5.4.1"
|
||||
grin_util = "=5.4.1"
|
||||
|
||||
# ed25519 for the Grin native payment proof (M4). Pinned to the SAME version
|
||||
# grin_wallet_libwallet uses (its `ed25519-dalek = "1.0.0-pre.4"` resolves to
|
||||
# 1.0.1), so the `DalekPublicKey`/`DalekSignature` types on the received
|
||||
# slate's `payment_proof` unify with ours. We only reconstruct the proof
|
||||
# message bytes and call this library's verify: never a hand-rolled ed25519.
|
||||
ed25519-dalek = "=1.0.1"
|
||||
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# The exact node API type HTTPNodeClient parses a get_kernel response into.
|
||||
# Used only to assert the confirmation parser against a recorded node fixture
|
||||
# (no live node in the test); pinned to the node release line Goblin vendors.
|
||||
grin_api = "=5.4.1"
|
||||
# THE GATE (Milestone 2) lives in tests/goblin_roundtrip.rs. Goblin's actual
|
||||
# wallet stack acts as the SENDER through the sibling `gp-goblin-sender`
|
||||
# binary crate, driven as a subprocess. The two stacks cannot share one test
|
||||
# binary: Goblin's fork moved grin_store to heed (lmdb-master-sys) while
|
||||
# upstream grin_store uses lmdb-zero (liblmdb-sys), and the two bundled LMDB
|
||||
# C libraries collide at link time (duplicate mdb_* symbols; the loser then
|
||||
# calls the wrong LMDB and fails with MDB_BAD_TXN).
|
||||
rand = "0.6"
|
||||
serde_json = { workspace = true }
|
||||
@@ -0,0 +1,219 @@
|
||||
//! Lightweight on-chain confirmation for a received payment.
|
||||
//!
|
||||
//! A GoblinPay payment confirms when the transaction kernel the payer builds
|
||||
//! (and posts) lands in a block. We never run the heavy full-UTXO scan/updater
|
||||
//! for this: we already know the tx kernel excess at receive time (the upstream
|
||||
//! `Slate::calc_excess`, stored per payment), so confirmation is a single
|
||||
//! `get_kernel(excess)` lookup against the node, exactly the query the wallet's
|
||||
//! own updater uses to detect reverted kernels.
|
||||
//!
|
||||
//! Transport: this read goes DIRECT over normal HTTP to the configured node
|
||||
//! (`grin_wallet_impls::HTTPNodeClient`), NEVER through the Nym tunnel. The
|
||||
//! mixnet in gp-nostr carries only the Nostr gift-wrap layer; the wallet<->node
|
||||
//! reads are a server concern that rides clearnet, mirroring Goblin's own
|
||||
//! wallet->node traffic (owner ruling). This crate has no Nym linkage at all,
|
||||
//! so the direct path is structural, not merely configured.
|
||||
//!
|
||||
//! We depend on upstream grin-wallet for the network round-trip and kernel
|
||||
//! parse; the only logic here is turning the located kernel into a
|
||||
//! confirmation height + count, which is unit-tested against a recorded node
|
||||
//! response (no live node needed).
|
||||
|
||||
use grin_util::secp::pedersen::Commitment;
|
||||
use grin_wallet_impls::HTTPNodeClient;
|
||||
use grin_wallet_libwallet::NodeClient;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::WalletError;
|
||||
|
||||
/// The 33-byte Pedersen commitment size (a kernel excess is a commitment).
|
||||
const COMMITMENT_LEN: usize = 33;
|
||||
|
||||
/// Confirmation status for one payment's kernel.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct ConfirmStatus {
|
||||
/// The kernel is included in a block on the node's current chain.
|
||||
pub confirmed: bool,
|
||||
/// Block height the kernel landed at (present iff `confirmed`).
|
||||
pub height: Option<u64>,
|
||||
/// Confirmation depth at the queried tip (1 = in the tip block).
|
||||
pub confirmations: Option<u64>,
|
||||
/// The kernel excess we queried, hex (echoed for the caller/record).
|
||||
pub kernel_excess: String,
|
||||
}
|
||||
|
||||
/// Query the node for a received payment's kernel and report confirmation.
|
||||
///
|
||||
/// `node_url` is the configured `GP_NODE_URL` (default `https://main.gri.mw`);
|
||||
/// the client is built fresh per call (cheap: one JSON-RPC round trip) and
|
||||
/// talks DIRECT HTTP. `kernel_excess_hex` is the 66-char hex of the kernel
|
||||
/// excess commitment recorded at receive time.
|
||||
pub fn confirm_status(
|
||||
node_url: &str,
|
||||
kernel_excess_hex: &str,
|
||||
) -> Result<ConfirmStatus, WalletError> {
|
||||
let excess = parse_commitment(kernel_excess_hex)?;
|
||||
|
||||
let mut client = HTTPNodeClient::new(node_url, None)
|
||||
.map_err(|e| WalletError::Config(format!("bad node URL `{node_url}`: {e}")))?;
|
||||
|
||||
let tip = client
|
||||
.get_chain_tip()
|
||||
.map_err(|e| WalletError::Wallet(format!("node get_tip failed: {e}")))?
|
||||
.0;
|
||||
|
||||
// get_kernel returns Ok(None) for a kernel not (yet) on chain, and
|
||||
// Ok(Some((kernel, height, mmr_index))) once it lands. We only need the
|
||||
// height; the node already validated the kernel by including it in a block.
|
||||
let kernel_height = client
|
||||
.get_kernel(&excess, None, None)
|
||||
.map_err(|e| WalletError::Wallet(format!("node get_kernel failed: {e}")))?
|
||||
.map(|(_kernel, height, _mmr_index)| height);
|
||||
|
||||
Ok(interpret(tip, kernel_height, kernel_excess_hex))
|
||||
}
|
||||
|
||||
/// Pure interpretation of a kernel lookup into a [`ConfirmStatus`]. Split out
|
||||
/// so the confirmation math is unit-testable without a node.
|
||||
fn interpret(
|
||||
tip_height: u64,
|
||||
kernel_height: Option<u64>,
|
||||
kernel_excess_hex: &str,
|
||||
) -> ConfirmStatus {
|
||||
match kernel_height {
|
||||
// A kernel cannot be above the tip; clamp defensively so a racing tip
|
||||
// read never yields a nonsensical (underflowed) confirmation count.
|
||||
Some(height) => ConfirmStatus {
|
||||
confirmed: true,
|
||||
height: Some(height),
|
||||
confirmations: Some(tip_height.saturating_sub(height) + 1),
|
||||
kernel_excess: kernel_excess_hex.to_string(),
|
||||
},
|
||||
None => ConfirmStatus {
|
||||
confirmed: false,
|
||||
height: None,
|
||||
confirmations: None,
|
||||
kernel_excess: kernel_excess_hex.to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a 33-byte commitment from hex.
|
||||
fn parse_commitment(hex: &str) -> Result<Commitment, WalletError> {
|
||||
let bytes = decode_hex(hex.trim())
|
||||
.ok_or_else(|| WalletError::Config(format!("kernel excess is not valid hex: `{hex}`")))?;
|
||||
if bytes.len() != COMMITMENT_LEN {
|
||||
return Err(WalletError::Config(format!(
|
||||
"kernel excess must be {COMMITMENT_LEN} bytes ({} hex chars), got {}",
|
||||
COMMITMENT_LEN * 2,
|
||||
bytes.len()
|
||||
)));
|
||||
}
|
||||
Ok(Commitment::from_vec(bytes))
|
||||
}
|
||||
|
||||
/// Minimal hex decode (no dependency; the excess is machine-generated hex).
|
||||
fn decode_hex(s: &str) -> Option<Vec<u8>> {
|
||||
if !s.len().is_multiple_of(2) {
|
||||
return None;
|
||||
}
|
||||
(0..s.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// A real get_tip response captured from https://main.gri.mw/v2/foreign
|
||||
// (2026-07-01), used to source the chain tip in the fixtures below.
|
||||
const TIP_JSON: &str = r#"{"id":1,"jsonrpc":"2.0","result":{"Ok":{"height":3911936,"last_block_pushed":"00015b89ed20619bb003ce358c3ab861a05882064cdeb06dac8bd3ba913ee763","prev_block_to_last":"00039cf6ccb9b730cd2b58906d4eb4d549190919c3f47e6a399ea98dabcf9a22","total_difficulty":2358710106678858}}}"#;
|
||||
|
||||
// A real get_kernel "not on chain" response captured from the same node
|
||||
// (queried with a well-formed but nonexistent excess). This is the shape
|
||||
// the confirmation poll sees for every still-pending payment.
|
||||
const KERNEL_NOTFOUND_JSON: &str = r#"{"id":1,"jsonrpc":"2.0","result":{"Err":"NotFound"}}"#;
|
||||
|
||||
// A get_kernel "found" response in the node's exact wire shape. Public
|
||||
// block explorers were unreachable to capture a live hit at build time, so
|
||||
// this fixture is round-tripped through the real `grin_api::LocatedTxKernel`
|
||||
// type in `found_fixture_matches_real_node_type` below, which guarantees it
|
||||
// is byte-shaped exactly as the node (and HTTPNodeClient) produce/consume.
|
||||
// The live "found" path is exercised in the supervised mainnet round.
|
||||
const KERNEL_FOUND_JSON: &str = r#"{"id":1,"jsonrpc":"2.0","result":{"Ok":{"tx_kernel":{"features":{"Plain":{"fee":7000000}},"excess":"09a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90","excess_sig":"8f1c9d2e3a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5"},"height":3900000,"mmr_index":54321000}}}"#;
|
||||
|
||||
/// Envelope helpers mirroring how HTTPNodeClient reads the result field.
|
||||
fn tip_height(json: &str) -> u64 {
|
||||
let v: serde_json::Value = serde_json::from_str(json).unwrap();
|
||||
v["result"]["Ok"]["height"].as_u64().unwrap()
|
||||
}
|
||||
|
||||
/// Parse a get_kernel response into the located kernel's height, exactly
|
||||
/// the field confirm_status consumes. Returns None on `Err(NotFound)`.
|
||||
fn located_height(json: &str) -> Option<u64> {
|
||||
let v: serde_json::Value = serde_json::from_str(json).unwrap();
|
||||
let ok = v["result"].get("Ok")?;
|
||||
let located: grin_api::LocatedTxKernel = serde_json::from_value(ok.clone()).unwrap();
|
||||
Some(located.height)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn found_fixture_matches_real_node_type() {
|
||||
// The recorded "found" fixture must deserialize into the EXACT type
|
||||
// HTTPNodeClient parses into (grin_api::LocatedTxKernel), so the parser
|
||||
// the production path relies on and this test agree on the wire shape.
|
||||
let v: serde_json::Value = serde_json::from_str(KERNEL_FOUND_JSON).unwrap();
|
||||
let located: grin_api::LocatedTxKernel =
|
||||
serde_json::from_value(v["result"]["Ok"].clone()).unwrap();
|
||||
assert_eq!(located.height, 3_900_000);
|
||||
assert_eq!(located.mmr_index, 54_321_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirmed_when_kernel_found() {
|
||||
let tip = tip_height(TIP_JSON);
|
||||
let height = located_height(KERNEL_FOUND_JSON);
|
||||
assert_eq!(height, Some(3_900_000));
|
||||
let status = interpret(tip, height, "09aa");
|
||||
assert!(status.confirmed);
|
||||
assert_eq!(status.height, Some(3_900_000));
|
||||
// 3911936 - 3900000 + 1
|
||||
assert_eq!(status.confirmations, Some(11_937));
|
||||
assert_eq!(status.kernel_excess, "09aa");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_when_kernel_not_found() {
|
||||
let tip = tip_height(TIP_JSON);
|
||||
let height = located_height(KERNEL_NOTFOUND_JSON);
|
||||
assert_eq!(height, None);
|
||||
let status = interpret(tip, height, "09bb");
|
||||
assert!(!status.confirmed);
|
||||
assert!(status.height.is_none());
|
||||
assert!(status.confirmations.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirmations_are_one_in_the_tip_block() {
|
||||
let status = interpret(100, Some(100), "09cc");
|
||||
assert_eq!(status.confirmations, Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tip_behind_kernel_does_not_underflow() {
|
||||
// A racing/stale tip below the kernel height must not panic or wrap.
|
||||
let status = interpret(90, Some(100), "09dd");
|
||||
assert!(status.confirmed);
|
||||
assert_eq!(status.confirmations, Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_excess_hex_is_rejected() {
|
||||
assert!(parse_commitment("nothex!!").is_err());
|
||||
assert!(parse_commitment("09aa").is_err()); // right hex, wrong length
|
||||
// 33 bytes of valid hex parses.
|
||||
assert!(parse_commitment(&"09".repeat(COMMITMENT_LEN)).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
//! Grin wallet handoff for GoblinPay: the receive-only half of a standard
|
||||
//! Grin interactive transaction, built on the official upstream `grin-wallet`
|
||||
//! crates (never a reimplementation of Grin crypto).
|
||||
//!
|
||||
//! What this crate does:
|
||||
//! - opens (or creates) a wallet from a BIP-39 mnemonic; the seed is stored
|
||||
//! encrypted at rest under the gp data dir with file mode 0600,
|
||||
//! - `receive_slatepack`: parse an S1 slatepack -> `receive_tx` (fully
|
||||
//! offline, no node involved) -> return the S2 slatepack armor for the
|
||||
//! payer to finalize and post,
|
||||
//! - exposes the wallet's slatepack address (payers can encrypt to it).
|
||||
//!
|
||||
//! What this crate never does: initiate a send, finalize, or post to chain.
|
||||
//!
|
||||
//! Two-secrets rule: the Grin mnemonic handled here is the money secret. It
|
||||
//! must never be used for anything Nostr; the payment identity is a separate
|
||||
//! random nsec owned by `gp-nostr`.
|
||||
//!
|
||||
//! The upstream crates are pinned to the exact tag validated by the
|
||||
//! Milestone-2 round-trip gate against Goblin's wallet stack (see
|
||||
//! `Cargo.toml` and `tests/goblin_roundtrip.rs`).
|
||||
|
||||
pub mod confirm;
|
||||
pub mod proof;
|
||||
|
||||
use std::fmt;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::mpsc::Sender;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use gp_core::config::{Chain, Config};
|
||||
use grin_core::global::{self, ChainTypes};
|
||||
use grin_keychain::ExtKeychain;
|
||||
use grin_util::secp::key::SecretKey;
|
||||
use grin_util::{Mutex, ZeroingString};
|
||||
use grin_wallet_impls::{DefaultLCProvider, DefaultWalletImpl, HTTPNodeClient};
|
||||
use grin_wallet_libwallet::api_impl::{foreign, owner};
|
||||
use grin_wallet_libwallet::{SlateState, StatusMessage, WalletInst};
|
||||
use serde::Serialize;
|
||||
|
||||
pub use confirm::{confirm_status, ConfirmStatus};
|
||||
pub use proof::{verify_receiver_proof, ReceiverProof};
|
||||
|
||||
/// The wallet instance type this crate drives (upstream grin-wallet stack).
|
||||
type Provider = DefaultLCProvider<'static, HTTPNodeClient, ExtKeychain>;
|
||||
type Instance = Arc<Mutex<Box<dyn WalletInst<'static, Provider, HTTPNodeClient, ExtKeychain>>>>;
|
||||
|
||||
/// Errors from the wallet handoff. String-based on purpose: callers report,
|
||||
/// they do not branch on Grin internals.
|
||||
#[derive(Debug)]
|
||||
pub enum WalletError {
|
||||
/// Bad or missing configuration (fail fast at startup).
|
||||
Config(String),
|
||||
/// The wallet stack itself failed (lifecycle, receive, keychain).
|
||||
Wallet(String),
|
||||
/// The incoming slatepack could not be parsed, decrypted, or is not an
|
||||
/// S1 send slatepack.
|
||||
Slatepack(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for WalletError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
WalletError::Config(m) => write!(f, "wallet config error: {m}"),
|
||||
WalletError::Wallet(m) => write!(f, "wallet error: {m}"),
|
||||
WalletError::Slatepack(m) => write!(f, "slatepack error: {m}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for WalletError {}
|
||||
|
||||
impl From<grin_wallet_libwallet::Error> for WalletError {
|
||||
fn from(e: grin_wallet_libwallet::Error) -> Self {
|
||||
WalletError::Wallet(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of receiving an S1 slatepack: what gp-core needs for matching and
|
||||
/// what the transport needs to send back to the payer.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Received {
|
||||
/// Slate UUID, shared by S1, S2, and the final transaction.
|
||||
pub slate_id: String,
|
||||
/// Amount in nanogrin, as stated by the S1 slate.
|
||||
pub amount: u64,
|
||||
/// The S2 reply slatepack (plain armor; transport encryption is the
|
||||
/// Nostr layer's job, matching Goblin's behavior).
|
||||
pub s2_armor: String,
|
||||
/// Tx kernel excess commitment, hex (33 bytes). Computed via the upstream
|
||||
/// `Slate::calc_excess` (identical to what the wallet's own updater uses
|
||||
/// for kernel confirmation and to what the proof signature binds). Stored
|
||||
/// per payment so the confirmation poll can query the node for this kernel.
|
||||
pub kernel_excess: String,
|
||||
/// The receiver-side Grin payment proof as JSON, present only when the
|
||||
/// payer's S1 requested one (carried a proof address). `None` otherwise
|
||||
/// (today's Goblin senders do not request proofs).
|
||||
pub proof: Option<String>,
|
||||
}
|
||||
|
||||
/// Cheap wallet balance snapshot (nanogrin), read from the local DB without a
|
||||
/// node scan (the heavy updater stays disabled per the plan).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
pub struct Balance {
|
||||
/// Total across unspent, unconfirmed, and immature outputs.
|
||||
pub total: u64,
|
||||
/// Currently spendable (confirmed, unlocked).
|
||||
pub spendable: u64,
|
||||
}
|
||||
|
||||
/// A receive-only Grin wallet over the upstream grin-wallet stack.
|
||||
///
|
||||
/// Cheaply cloneable: the wallet instance is an `Arc<Mutex<..>>`, so a clone
|
||||
/// shares one underlying wallet + seed. Both the Nostr ingest service and the
|
||||
/// HTTP manual-slatepack handler hold a clone and serialize on the inner mutex.
|
||||
#[derive(Clone)]
|
||||
pub struct GpWallet {
|
||||
instance: Instance,
|
||||
mask: Option<SecretKey>,
|
||||
/// Configured node URL for confirmation reads (DIRECT HTTP, never Nym).
|
||||
node_url: String,
|
||||
}
|
||||
|
||||
impl fmt::Debug for GpWallet {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
// Never expose keys or the mask.
|
||||
f.write_str("GpWallet(open)")
|
||||
}
|
||||
}
|
||||
|
||||
impl GpWallet {
|
||||
/// Open (or create on first run) the wallet described by the gp config.
|
||||
/// Requires `GP_MNEMONIC` (or `_FILE`) and `GP_WALLET_PASSWORD` (or
|
||||
/// `_FILE`); fails fast when either is missing.
|
||||
pub fn open(cfg: &Config) -> Result<GpWallet, WalletError> {
|
||||
let mnemonic = cfg.mnemonic.as_ref().ok_or_else(|| {
|
||||
WalletError::Config("GP_MNEMONIC (or GP_MNEMONIC_FILE) is required".into())
|
||||
})?;
|
||||
let password = cfg.wallet_password.as_ref().ok_or_else(|| {
|
||||
WalletError::Config(
|
||||
"GP_WALLET_PASSWORD (or GP_WALLET_PASSWORD_FILE) is required to \
|
||||
encrypt the wallet seed at rest"
|
||||
.into(),
|
||||
)
|
||||
})?;
|
||||
let chain = match cfg.chain {
|
||||
Chain::Mainnet => ChainTypes::Mainnet,
|
||||
Chain::Testnet => ChainTypes::Testnet,
|
||||
};
|
||||
Self::open_at(
|
||||
Path::new(&cfg.data_dir),
|
||||
mnemonic.reveal(),
|
||||
password.reveal(),
|
||||
&cfg.node_url,
|
||||
chain,
|
||||
)
|
||||
}
|
||||
|
||||
/// Open (or create) a wallet under `data_dir` from a BIP-39 mnemonic.
|
||||
/// The seed is written encrypted (with `password`) to
|
||||
/// `<data_dir>/wallet/wallet_data/wallet.seed`, mode 0600. The receive path
|
||||
/// is fully offline; `node_url` is used only for lightweight confirmation
|
||||
/// reads (a single `get_kernel` per pending payment), which go DIRECT over
|
||||
/// HTTP, never through the Nym tunnel.
|
||||
pub fn open_at(
|
||||
data_dir: &Path,
|
||||
mnemonic: &str,
|
||||
password: &str,
|
||||
node_url: &str,
|
||||
chain: ChainTypes,
|
||||
) -> Result<GpWallet, WalletError> {
|
||||
init_chain_type(chain)?;
|
||||
|
||||
let top_dir = data_dir.join("wallet");
|
||||
fs::create_dir_all(&top_dir)
|
||||
.map_err(|e| WalletError::Config(format!("cannot create {top_dir:?}: {e}")))?;
|
||||
restrict_permissions(data_dir, 0o700)?;
|
||||
restrict_permissions(&top_dir, 0o700)?;
|
||||
let top_dir_str = top_dir
|
||||
.to_str()
|
||||
.ok_or_else(|| WalletError::Config(format!("non-UTF8 data dir {top_dir:?}")))?;
|
||||
|
||||
let node_client = HTTPNodeClient::new(node_url, None)
|
||||
.map_err(|e| WalletError::Config(format!("bad node URL `{node_url}`: {e}")))?;
|
||||
let mut wallet = Box::new(
|
||||
DefaultWalletImpl::<'static, HTTPNodeClient>::new(node_client)
|
||||
.map_err(|e| WalletError::Wallet(e.to_string()))?,
|
||||
)
|
||||
as Box<dyn WalletInst<'static, Provider, HTTPNodeClient, ExtKeychain>>;
|
||||
|
||||
let mask = {
|
||||
let lc = wallet.lc_provider()?;
|
||||
lc.set_top_level_directory(top_dir_str)?;
|
||||
if lc.wallet_exists(None)? {
|
||||
// The data dir already holds a wallet: refuse to run against
|
||||
// a different seed than the configured one.
|
||||
let existing = lc.get_mnemonic(None, ZeroingString::from(password))?;
|
||||
if &*existing != mnemonic {
|
||||
return Err(WalletError::Config(format!(
|
||||
"data dir {top_dir:?} already holds a wallet created from a \
|
||||
different mnemonic; refusing to open"
|
||||
)));
|
||||
}
|
||||
} else {
|
||||
lc.create_wallet(
|
||||
None,
|
||||
Some(ZeroingString::from(mnemonic)),
|
||||
32,
|
||||
ZeroingString::from(password),
|
||||
false,
|
||||
)?;
|
||||
}
|
||||
lc.open_wallet(None, ZeroingString::from(password), true, false)?
|
||||
};
|
||||
|
||||
// The seed is encrypted, but belt and braces: nobody else on the
|
||||
// host gets to read it.
|
||||
let wallet_data = top_dir.join("wallet_data");
|
||||
restrict_permissions(&wallet_data, 0o700)?;
|
||||
restrict_permissions(&wallet_data.join("wallet.seed"), 0o600)?;
|
||||
|
||||
Ok(GpWallet {
|
||||
instance: Arc::new(Mutex::new(wallet)),
|
||||
mask,
|
||||
node_url: node_url.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The wallet's slatepack address (derivation index 0). Payers may
|
||||
/// encrypt slatepacks to it; it is also the payment-proof address.
|
||||
pub fn slatepack_address(&self) -> Result<String, WalletError> {
|
||||
let addr = owner::get_slatepack_address(self.instance.clone(), self.mask.as_ref(), 0)?;
|
||||
Ok(addr.to_string())
|
||||
}
|
||||
|
||||
/// Receive a payment: parse the S1 slatepack (plain or encrypted to our
|
||||
/// address), run `receive_tx` (offline), and return the S2 reply armor.
|
||||
pub fn receive_slatepack(&self, s1_armor: &str) -> Result<Received, WalletError> {
|
||||
let slate = owner::slate_from_slatepack_message(
|
||||
self.instance.clone(),
|
||||
self.mask.as_ref(),
|
||||
s1_armor.trim().to_string(),
|
||||
vec![0],
|
||||
)
|
||||
.map_err(|e| WalletError::Slatepack(format!("cannot read slatepack: {e}")))?;
|
||||
|
||||
if slate.state != SlateState::Standard1 {
|
||||
return Err(WalletError::Slatepack(format!(
|
||||
"expected an S1 (standard send) slatepack, got {:?}",
|
||||
slate.state
|
||||
)));
|
||||
}
|
||||
// Captured before receive_tx zeroes it on the S2 slate; this is the
|
||||
// amount the proof signature binds to.
|
||||
let amount = slate.amount;
|
||||
|
||||
// Receive offline.
|
||||
let s2 = {
|
||||
let mut w_lock = self.instance.lock();
|
||||
let lc = w_lock.lc_provider()?;
|
||||
let w = lc.wallet_inst()?;
|
||||
foreign::receive_tx(&mut **w, self.mask.as_ref(), &slate, None, false)?
|
||||
};
|
||||
|
||||
// The kernel excess is read from the tx log, NOT recomputed from the
|
||||
// returned S2: compact-slate receive strips the sender's participant
|
||||
// data off S2 before returning it, so `s2.calc_excess()` would sum only
|
||||
// our own excess and be wrong. The value the wallet logged during
|
||||
// receive is summed over both participants (and is offset-independent),
|
||||
// so it equals both the excess receive_tx signed into the payment proof
|
||||
// and the on-chain kernel excess the node returns — the single anchor
|
||||
// for confirmation and proof.
|
||||
let kernel_excess = {
|
||||
let channel: Option<Sender<StatusMessage>> = None;
|
||||
let (_refreshed, txs) = owner::retrieve_txs(
|
||||
self.instance.clone(),
|
||||
self.mask.as_ref(),
|
||||
&channel,
|
||||
false, // local read only; the heavy updater stays disabled
|
||||
None,
|
||||
Some(slate.id),
|
||||
None,
|
||||
)?;
|
||||
let excess = txs
|
||||
.iter()
|
||||
.find_map(|t| t.kernel_excess.as_ref())
|
||||
.ok_or_else(|| {
|
||||
WalletError::Wallet("received tx has no recorded kernel excess".into())
|
||||
})?;
|
||||
proof::encode_hex(&excess.0)
|
||||
};
|
||||
|
||||
// If the payer's S1 requested a payment proof, receive_tx has filled in
|
||||
// the receiver signature on the returned slate; capture the full
|
||||
// receiver-side proof for storage + independent verification.
|
||||
let proof = self.build_proof(&s2, amount, &kernel_excess)?;
|
||||
|
||||
// Plain armor, like Goblin: transport encryption (NIP-44 gift wrap)
|
||||
// is the Nostr layer's job, not the slatepack's.
|
||||
let s2_armor = owner::create_slatepack_message(
|
||||
self.instance.clone(),
|
||||
self.mask.as_ref(),
|
||||
&s2,
|
||||
Some(0),
|
||||
vec![],
|
||||
)?;
|
||||
|
||||
Ok(Received {
|
||||
slate_id: s2.id.to_string(),
|
||||
amount,
|
||||
s2_armor,
|
||||
kernel_excess,
|
||||
proof,
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract the receiver-side payment proof from a post-`receive_tx` slate,
|
||||
/// serialized to JSON. Returns `None` when the slate carried no proof
|
||||
/// request (or, defensively, no receiver signature).
|
||||
fn build_proof(
|
||||
&self,
|
||||
s2: &grin_wallet_libwallet::Slate,
|
||||
amount: u64,
|
||||
kernel_excess: &str,
|
||||
) -> Result<Option<String>, WalletError> {
|
||||
let Some(info) = s2.payment_proof.as_ref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(sig) = info.receiver_signature.as_ref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let receiver_proof = ReceiverProof {
|
||||
amount,
|
||||
kernel_excess: kernel_excess.to_string(),
|
||||
sender_address: proof::encode_hex(&info.sender_address.to_bytes()),
|
||||
recipient_address: proof::encode_hex(&info.receiver_address.to_bytes()),
|
||||
recipient_sig: proof::encode_hex(&sig.to_bytes()),
|
||||
};
|
||||
// Belt and braces: never store a proof we cannot verify ourselves.
|
||||
if !receiver_proof.verify() {
|
||||
return Err(WalletError::Wallet(
|
||||
"receive_tx produced a payment proof that does not verify".into(),
|
||||
));
|
||||
}
|
||||
serde_json::to_string(&receiver_proof)
|
||||
.map(Some)
|
||||
.map_err(|e| WalletError::Wallet(format!("serialize payment proof: {e}")))
|
||||
}
|
||||
|
||||
/// Confirmation status for a received payment's kernel, via a DIRECT node
|
||||
/// read (never Nym). `kernel_excess_hex` is [`Received::kernel_excess`].
|
||||
pub fn confirm_status(&self, kernel_excess_hex: &str) -> Result<ConfirmStatus, WalletError> {
|
||||
confirm::confirm_status(&self.node_url, kernel_excess_hex)
|
||||
}
|
||||
|
||||
/// The wallet balance from the local DB (no node scan; cheap). Total and
|
||||
/// currently-spendable nanogrin.
|
||||
pub fn balance(&self) -> Result<Balance, WalletError> {
|
||||
let channel: Option<Sender<StatusMessage>> = None;
|
||||
let (_refreshed, info) = owner::retrieve_summary_info(
|
||||
self.instance.clone(),
|
||||
self.mask.as_ref(),
|
||||
&channel,
|
||||
false, // never refresh from node: the heavy updater stays disabled
|
||||
1,
|
||||
)?;
|
||||
Ok(Balance {
|
||||
total: info.total,
|
||||
spendable: info.amount_currently_spendable,
|
||||
})
|
||||
}
|
||||
|
||||
/// Path of the encrypted seed file for a given data dir (for operators
|
||||
/// and tests; the two-backups story documents this file).
|
||||
pub fn seed_path(data_dir: &Path) -> PathBuf {
|
||||
data_dir
|
||||
.join("wallet")
|
||||
.join("wallet_data")
|
||||
.join("wallet.seed")
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize the process-wide Grin chain type exactly once and refuse a
|
||||
/// conflicting re-initialization (grin globals are process state).
|
||||
fn init_chain_type(chain: ChainTypes) -> Result<(), WalletError> {
|
||||
static CHAIN: OnceLock<ChainTypes> = OnceLock::new();
|
||||
let set = CHAIN.get_or_init(|| {
|
||||
global::init_global_chain_type(chain);
|
||||
chain
|
||||
});
|
||||
if *set != chain {
|
||||
return Err(WalletError::Config(format!(
|
||||
"chain type already initialized to {set:?}, cannot switch to {chain:?}"
|
||||
)));
|
||||
}
|
||||
// Make the calling thread consistent even if a local override was set.
|
||||
global::set_local_chain_type(chain);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// chmod, failing fast (the seed must never be world readable).
|
||||
fn restrict_permissions(path: &Path, mode: u32) -> Result<(), WalletError> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(mode))
|
||||
.map_err(|e| WalletError::Config(format!("cannot chmod {path:?}: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
use rand::RngCore;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Self-cleaning unique temp dir (no extra dev-deps).
|
||||
struct TempDir(PathBuf);
|
||||
|
||||
impl TempDir {
|
||||
fn new(tag: &str) -> TempDir {
|
||||
static N: AtomicU32 = AtomicU32::new(0);
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"gp-wallet-{tag}-{}-{}",
|
||||
std::process::id(),
|
||||
N.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
fs::create_dir_all(&path).unwrap();
|
||||
TempDir(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// A fresh random 24-word test mnemonic. Never a user-provided seed.
|
||||
fn random_mnemonic() -> String {
|
||||
let mut entropy = [0u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut entropy);
|
||||
grin_keychain::mnemonic::from_entropy(&entropy).unwrap()
|
||||
}
|
||||
|
||||
fn open(dir: &TempDir, mnemonic: &str) -> Result<GpWallet, WalletError> {
|
||||
GpWallet::open_at(
|
||||
&dir.0,
|
||||
mnemonic,
|
||||
"test-password",
|
||||
"http://127.0.0.1:3413",
|
||||
ChainTypes::Mainnet,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creates_wallet_with_encrypted_seed_mode_0600() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = TempDir::new("create");
|
||||
let mnemonic = random_mnemonic();
|
||||
let wallet = open(&dir, &mnemonic).unwrap();
|
||||
|
||||
let seed_path = GpWallet::seed_path(&dir.0);
|
||||
assert!(seed_path.exists(), "seed file missing at {seed_path:?}");
|
||||
let mode = fs::metadata(&seed_path).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o600, "seed file must be 0600");
|
||||
|
||||
// Encrypted at rest: no mnemonic word appears in the seed file.
|
||||
let raw = fs::read_to_string(&seed_path).unwrap();
|
||||
for word in mnemonic.split_whitespace() {
|
||||
assert!(!raw.contains(word), "seed file leaks mnemonic word");
|
||||
}
|
||||
|
||||
let addr = wallet.slatepack_address().unwrap();
|
||||
assert!(addr.starts_with("grin1"), "mainnet address, got {addr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reopen_same_mnemonic_yields_same_address() {
|
||||
let dir = TempDir::new("reopen");
|
||||
let mnemonic = random_mnemonic();
|
||||
let first = open(&dir, &mnemonic).unwrap().slatepack_address().unwrap();
|
||||
let second = open(&dir, &mnemonic).unwrap().slatepack_address().unwrap();
|
||||
assert_eq!(first, second);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reopen_with_different_mnemonic_is_refused() {
|
||||
let dir = TempDir::new("mismatch");
|
||||
open(&dir, &random_mnemonic()).unwrap();
|
||||
let err = open(&dir, &random_mnemonic()).unwrap_err();
|
||||
assert!(matches!(err, WalletError::Config(_)), "got {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_mnemonic_fails_fast() {
|
||||
let dir = TempDir::new("badseed");
|
||||
assert!(open(&dir, "not a valid bip39 phrase").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_armor_is_rejected() {
|
||||
let dir = TempDir::new("garbage");
|
||||
let wallet = open(&dir, &random_mnemonic()).unwrap();
|
||||
let err = wallet.receive_slatepack("BEGINSLATEPACK. nope. ENDSLATEPACK.");
|
||||
assert!(matches!(err, Err(WalletError::Slatepack(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_from_config_requires_both_secrets() {
|
||||
let cfg = Config::default();
|
||||
let err = GpWallet::open(&cfg).unwrap_err();
|
||||
assert!(err.to_string().contains("GP_MNEMONIC"), "got {err}");
|
||||
|
||||
let cfg = Config {
|
||||
mnemonic: Some(gp_core::config::Secret::new(random_mnemonic())),
|
||||
..Config::default()
|
||||
};
|
||||
let err = GpWallet::open(&cfg).unwrap_err();
|
||||
assert!(err.to_string().contains("GP_WALLET_PASSWORD"), "got {err}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
//! Grin native payment proof, receiver side.
|
||||
//!
|
||||
//! When a payer's S1 slate carries a payment-proof request (a `sender_address`
|
||||
//! plus our address as the `receiver_address`), upstream `receive_tx` signs
|
||||
//! `payment_proof_message(amount, kernel_excess, sender_address)` with our
|
||||
//! ed25519 address key and puts the signature on the returned S2 slate. That
|
||||
//! receiver signature IS the payment proof the payer keeps ("this recipient
|
||||
//! acknowledged receiving `amount` bound to this on-chain kernel from me").
|
||||
//!
|
||||
//! GoblinPay stores the receiver-side proof and can verify it independently of
|
||||
//! any DM. Verification is pure crypto (no node, no wallet): reconstruct the
|
||||
//! canonical proof message and check the receiver signature against the
|
||||
//! recipient ed25519 address using the SAME `ed25519-dalek` grin-wallet uses.
|
||||
//! We never hand-roll ed25519; the only logic here is the documented,
|
||||
//! consensus-stable message serialization (`amount || kernel_excess ||
|
||||
//! sender_address`, matching upstream `payment_proof_message`).
|
||||
//!
|
||||
//! Note this is the RECEIVER half only: it does not (cannot) carry the payer's
|
||||
//! own sender signature, which the payer adds at finalize and never sends back.
|
||||
//! Combined with the on-chain kernel confirmation (see [`crate::confirm`]),
|
||||
//! this is the trustless "we got paid" primitive a store verifies.
|
||||
|
||||
use ed25519_dalek::{PublicKey as DalekPublicKey, Signature as DalekSignature, Verifier};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// ed25519 public keys / signatures are fixed length.
|
||||
const ADDRESS_LEN: usize = 32;
|
||||
const SIGNATURE_LEN: usize = 64;
|
||||
const COMMITMENT_LEN: usize = 33;
|
||||
|
||||
/// The receiver-side Grin payment proof for one payment (serde; the DB `proof`
|
||||
/// column stores this as JSON). All byte fields are lowercase hex.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ReceiverProof {
|
||||
/// Amount in nanogrin (as bound into the signed message).
|
||||
pub amount: u64,
|
||||
/// Tx kernel excess commitment, hex (33 bytes) — the on-chain anchor.
|
||||
pub kernel_excess: String,
|
||||
/// Payer's proof address, ed25519 hex (32 bytes). The payer requested the
|
||||
/// proof to this identity; it is bound into the signed message.
|
||||
pub sender_address: String,
|
||||
/// Our proof address, ed25519 hex (32 bytes). The signature verifies
|
||||
/// against this key.
|
||||
pub recipient_address: String,
|
||||
/// Our receiver signature over the proof message, hex (64 bytes).
|
||||
pub recipient_sig: String,
|
||||
}
|
||||
|
||||
impl ReceiverProof {
|
||||
/// Verify the receiver signature over the canonical proof message. Returns
|
||||
/// `false` on any malformed field or signature mismatch (a proof that does
|
||||
/// not verify is simply not a valid proof).
|
||||
///
|
||||
/// This checks the cryptographic acknowledgement only; on-chain existence
|
||||
/// of `kernel_excess` is a separate node read ([`crate::confirm`]).
|
||||
pub fn verify(&self) -> bool {
|
||||
verify_receiver_proof(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// Free-function form of [`ReceiverProof::verify`].
|
||||
pub fn verify_receiver_proof(proof: &ReceiverProof) -> bool {
|
||||
let Some(excess) = decode_fixed::<COMMITMENT_LEN>(&proof.kernel_excess) else {
|
||||
return false;
|
||||
};
|
||||
let Some(sender) = decode_fixed::<ADDRESS_LEN>(&proof.sender_address) else {
|
||||
return false;
|
||||
};
|
||||
let Some(recipient) = decode_fixed::<ADDRESS_LEN>(&proof.recipient_address) else {
|
||||
return false;
|
||||
};
|
||||
let Some(sig_bytes) = decode_fixed::<SIGNATURE_LEN>(&proof.recipient_sig) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let Ok(recipient_key) = DalekPublicKey::from_bytes(&recipient) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(signature) = DalekSignature::try_from(&sig_bytes[..]) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let msg = proof_message(proof.amount, &excess, &sender);
|
||||
recipient_key.verify(&msg, &signature).is_ok()
|
||||
}
|
||||
|
||||
/// Canonical payment-proof message, byte-identical to upstream
|
||||
/// `libwallet::internal::tx::payment_proof_message` (private there, so
|
||||
/// reconstructed here): `amount` big-endian u64, then the 33-byte kernel
|
||||
/// commitment, then the 32-byte sender ed25519 address. Serialization only, no
|
||||
/// crypto.
|
||||
fn proof_message(
|
||||
amount: u64,
|
||||
kernel_excess: &[u8; COMMITMENT_LEN],
|
||||
sender_address: &[u8; ADDRESS_LEN],
|
||||
) -> Vec<u8> {
|
||||
let mut msg = Vec::with_capacity(8 + COMMITMENT_LEN + ADDRESS_LEN);
|
||||
msg.extend_from_slice(&amount.to_be_bytes());
|
||||
msg.extend_from_slice(kernel_excess);
|
||||
msg.extend_from_slice(sender_address);
|
||||
msg
|
||||
}
|
||||
|
||||
/// Hex-encode bytes (lowercase), for building a proof from slate data.
|
||||
pub(crate) fn encode_hex(bytes: &[u8]) -> String {
|
||||
let mut s = String::with_capacity(bytes.len() * 2);
|
||||
for b in bytes {
|
||||
s.push_str(&format!("{b:02x}"));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Decode exactly `N` bytes of hex, or `None`.
|
||||
fn decode_fixed<const N: usize>(hex: &str) -> Option<[u8; N]> {
|
||||
let hex = hex.trim();
|
||||
if hex.len() != N * 2 {
|
||||
return None;
|
||||
}
|
||||
let mut out = [0u8; N];
|
||||
for (i, byte) in out.iter_mut().enumerate() {
|
||||
*byte = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok()?;
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use ed25519_dalek::{Keypair, PublicKey, SecretKey, Signer};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A deterministic ed25519 keypair from a 32-byte seed (no RNG, so no
|
||||
/// rand-version coupling); the same library the wallet signs with.
|
||||
fn keypair(seed: u8) -> Keypair {
|
||||
let secret = SecretKey::from_bytes(&[seed; 32]).unwrap();
|
||||
let public: PublicKey = (&secret).into();
|
||||
Keypair { secret, public }
|
||||
}
|
||||
|
||||
/// Build a valid receiver proof exactly as the wallet would: sign the
|
||||
/// canonical message with a real ed25519 key, using the same library.
|
||||
fn valid_proof() -> (ReceiverProof, Keypair) {
|
||||
let amount = 2_500_000_000u64;
|
||||
let excess = [0x09u8; COMMITMENT_LEN];
|
||||
let sender = [0x11u8; ADDRESS_LEN];
|
||||
|
||||
let recipient = keypair(7);
|
||||
let msg = proof_message(amount, &excess, &sender);
|
||||
let sig = recipient.sign(&msg);
|
||||
|
||||
let proof = ReceiverProof {
|
||||
amount,
|
||||
kernel_excess: encode_hex(&excess),
|
||||
sender_address: encode_hex(&sender),
|
||||
recipient_address: encode_hex(recipient.public.as_bytes()),
|
||||
recipient_sig: encode_hex(&sig.to_bytes()),
|
||||
};
|
||||
(proof, recipient)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_receiver_proof_verifies() {
|
||||
let (proof, _) = valid_proof();
|
||||
assert!(proof.verify());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampered_amount_is_rejected() {
|
||||
let (mut proof, _) = valid_proof();
|
||||
proof.amount += 1;
|
||||
assert!(!proof.verify(), "a different amount must not verify");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampered_kernel_excess_is_rejected() {
|
||||
let (mut proof, _) = valid_proof();
|
||||
// Flip one byte of the excess (still valid hex, right length).
|
||||
proof.kernel_excess = encode_hex(&[0x0au8; COMMITMENT_LEN]);
|
||||
assert!(!proof.verify());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_recipient_is_rejected() {
|
||||
let (mut proof, _) = valid_proof();
|
||||
// A different recipient key did not sign this message.
|
||||
let other = keypair(9);
|
||||
proof.recipient_address = encode_hex(other.public.as_bytes());
|
||||
assert!(!proof.verify());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampered_sender_address_is_rejected() {
|
||||
let (mut proof, _) = valid_proof();
|
||||
proof.sender_address = encode_hex(&[0x22u8; ADDRESS_LEN]);
|
||||
assert!(!proof.verify());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampered_signature_is_rejected() {
|
||||
let (mut proof, _) = valid_proof();
|
||||
let mut sig = decode_fixed::<SIGNATURE_LEN>(&proof.recipient_sig).unwrap();
|
||||
sig[0] ^= 0xff;
|
||||
proof.recipient_sig = encode_hex(&sig);
|
||||
assert!(!proof.verify());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_fields_are_rejected_not_panicked() {
|
||||
let (proof, _) = valid_proof();
|
||||
for mutate in [
|
||||
|p: &mut ReceiverProof| p.kernel_excess = "zz".into(),
|
||||
|p: &mut ReceiverProof| p.recipient_address = "".into(),
|
||||
|p: &mut ReceiverProof| p.recipient_sig = "00".into(),
|
||||
|p: &mut ReceiverProof| p.sender_address = "nothex".into(),
|
||||
] {
|
||||
let mut bad = proof.clone();
|
||||
mutate(&mut bad);
|
||||
assert!(!bad.verify());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_round_trips() {
|
||||
let (proof, _) = valid_proof();
|
||||
let json = serde_json::to_string(&proof).unwrap();
|
||||
let back: ReceiverProof = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(proof, back);
|
||||
assert!(back.verify());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
//! THE Milestone-2 gate: prove that gp-wallet's pinned UPSTREAM grin-wallet
|
||||
//! stack (tag v5.4.1, rev 5c20635a24a1afa48c167775081015cae6321a4f) speaks
|
||||
//! the same slatepack dialect as Goblin's actual wallet stack (the
|
||||
//! grin-wallet fork vendored at goblin/wallet, over grin_core 5.4.1).
|
||||
//!
|
||||
//! Design: split-process pipeline. The dual-crate-graph design (fork crates
|
||||
//! as renamed dev-dependencies in this test binary) resolves and compiles,
|
||||
//! but cannot link: Goblin's fork moved grin_store to heed
|
||||
//! (lmdb-master-sys) while upstream grin_store uses lmdb-zero
|
||||
//! (liblmdb-sys), and the two bundled LMDB C libraries collide at link time
|
||||
//! (duplicate mdb_* symbols; the loser calls the wrong LMDB and dies with
|
||||
//! MDB_BAD_TXN). So the goblin side lives in its own binary, the sibling
|
||||
//! `gp-goblin-sender` workspace crate, and this test drives it as a
|
||||
//! subprocess. Only armored slatepack strings and JSON cross the boundary,
|
||||
//! exactly like production.
|
||||
//!
|
||||
//! Flow (everything offline, mainnet parameters, no node, no chain):
|
||||
//! gp-goblin-sender gen: random wallet + injected spendable output
|
||||
//! -> init_send_tx -> s1.armor + meta.json
|
||||
//! gp-wallet (in process): parse S1 -> receive_tx -> S2 armor
|
||||
//! gp-goblin-sender check: parse S2 -> finalize_tx (validates the whole
|
||||
//! transaction offline: sums, signatures, range
|
||||
//! proofs) -> result.json
|
||||
//! with slate id, amount, and kernel consistency asserted at each hop.
|
||||
//!
|
||||
//! Only freshly generated random test mnemonics are used, never any real
|
||||
//! seed.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
use rand::RngCore;
|
||||
|
||||
use gp_wallet::GpWallet;
|
||||
use grin_core::global::ChainTypes;
|
||||
|
||||
const AMOUNT: u64 = 2_000_000_000; // 2 grin, in nanogrin
|
||||
|
||||
/// Self-cleaning unique temp dir.
|
||||
struct TempDir(PathBuf);
|
||||
|
||||
impl TempDir {
|
||||
fn new(tag: &str) -> TempDir {
|
||||
static N: AtomicU32 = AtomicU32::new(0);
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"gp-roundtrip-{tag}-{}-{}",
|
||||
std::process::id(),
|
||||
N.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
fs::create_dir_all(&path).unwrap();
|
||||
TempDir(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Run one gp-goblin-sender subcommand. The binary is a workspace member,
|
||||
/// so `cargo test --workspace` (and ci.sh) always builds it before tests
|
||||
/// run; execute it directly to avoid nested-cargo lock contention, and fall
|
||||
/// back to one `cargo build` when it is missing (e.g. `cargo test -p
|
||||
/// gp-wallet` on a clean tree).
|
||||
fn sender(args: &[&str]) {
|
||||
let workspace_root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.unwrap()
|
||||
.parent()
|
||||
.unwrap()
|
||||
.to_path_buf();
|
||||
let target_dir = std::env::var("CARGO_TARGET_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| workspace_root.join("target"));
|
||||
let bin = target_dir.join("debug").join("gp-goblin-sender");
|
||||
|
||||
if !bin.exists() {
|
||||
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
|
||||
let build = Command::new(cargo)
|
||||
.current_dir(&workspace_root)
|
||||
.args(["build", "--quiet", "-p", "gp-goblin-sender"])
|
||||
.output()
|
||||
.expect("failed to spawn cargo build for gp-goblin-sender");
|
||||
assert!(
|
||||
build.status.success(),
|
||||
"building gp-goblin-sender failed:\n{}",
|
||||
String::from_utf8_lossy(&build.stderr),
|
||||
);
|
||||
}
|
||||
|
||||
let output = Command::new(&bin)
|
||||
.args(args)
|
||||
.output()
|
||||
.expect("failed to spawn gp-goblin-sender");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"gp-goblin-sender {args:?} failed:\nstdout: {}\nstderr: {}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
}
|
||||
|
||||
fn read_json(path: &Path) -> serde_json::Value {
|
||||
serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn new_receiver(dir: &Path) -> GpWallet {
|
||||
let mut entropy = [0u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut entropy);
|
||||
let mnemonic = grin_keychain::mnemonic::from_entropy(&entropy).unwrap();
|
||||
GpWallet::open_at(
|
||||
dir,
|
||||
&mnemonic,
|
||||
"receiver-pw",
|
||||
"http://127.0.0.1:3413",
|
||||
ChainTypes::Mainnet,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// One full S1 -> receive_tx -> S2 -> finalize round trip, with consistency
|
||||
/// asserts at every hop. `encrypt_to_receiver` exercises cross-stack
|
||||
/// slatepack-address parsing and payload encryption.
|
||||
fn roundtrip(tag: &str, encrypt_to_receiver: bool) {
|
||||
let work = TempDir::new(tag);
|
||||
let receiver_dir = TempDir::new(&format!("{tag}-recv"));
|
||||
let receiver = new_receiver(&receiver_dir.0);
|
||||
let workdir = work.0.to_str().unwrap();
|
||||
|
||||
// Goblin side builds the S1 send slatepack.
|
||||
let amount = AMOUNT.to_string();
|
||||
let mut gen_args = vec!["gen", workdir, &amount];
|
||||
let addr_str;
|
||||
if encrypt_to_receiver {
|
||||
addr_str = receiver.slatepack_address().unwrap();
|
||||
assert!(addr_str.starts_with("grin1"), "mainnet address: {addr_str}");
|
||||
gen_args.push(&addr_str);
|
||||
}
|
||||
sender(&gen_args);
|
||||
|
||||
let meta = read_json(&work.0.join("meta.json"));
|
||||
assert_eq!(meta["amount"].as_u64().unwrap(), AMOUNT);
|
||||
let slate_id = meta["slate_id"].as_str().unwrap().to_string();
|
||||
|
||||
let s1_armor = fs::read_to_string(work.0.join("s1.armor")).unwrap();
|
||||
assert!(s1_armor.starts_with("BEGINSLATEPACK."));
|
||||
|
||||
// Upstream receiver: parse S1, receive offline, emit S2.
|
||||
let received = receiver.receive_slatepack(&s1_armor).unwrap();
|
||||
assert_eq!(received.slate_id, slate_id, "slate id must survive");
|
||||
assert_eq!(received.amount, AMOUNT, "amount must survive the armor");
|
||||
assert!(received.s2_armor.starts_with("BEGINSLATEPACK."));
|
||||
|
||||
// Goblin side: parse S2 and finalize (full offline validation of the
|
||||
// receiver's output, range proof, and partial signature).
|
||||
let s2_path = work.0.join("s2.armor");
|
||||
fs::write(&s2_path, &received.s2_armor).unwrap();
|
||||
sender(&["check", workdir, s2_path.to_str().unwrap()]);
|
||||
|
||||
let result = read_json(&work.0.join("result.json"));
|
||||
assert_eq!(result["slate_id"].as_str().unwrap(), slate_id);
|
||||
assert_eq!(result["state"].as_str().unwrap(), "Standard3");
|
||||
assert!(result["kernel_verified"].as_bool().unwrap());
|
||||
assert_eq!(result["kernels"].as_u64().unwrap(), 1);
|
||||
assert_eq!(result["inputs"].as_u64().unwrap(), 1);
|
||||
assert_eq!(result["outputs"].as_u64().unwrap(), 2);
|
||||
assert_eq!(result["kernel_excess"].as_str().unwrap().len(), 66);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goblin_sends_plain_armor_gp_wallet_receives_goblin_finalizes() {
|
||||
// Plain armor: exactly what Goblin ships today (transport encryption is
|
||||
// the NIP-44 gift wrap, not the slatepack).
|
||||
roundtrip("plain", false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goblin_sends_encrypted_to_gp_wallet_address_and_finalizes() {
|
||||
// The fork parses the upstream-derived slatepack address and encrypts
|
||||
// the S1 payload to it; gp-wallet decrypts with derivation index 0.
|
||||
roundtrip("encrypted", true);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
//! Payment-proof round trip: a payer whose S1 REQUESTS a Grin native payment
|
||||
//! proof (addressed to gp-wallet's slatepack address) pays, gp-wallet receives
|
||||
//! and produces the receiver-side proof, and that proof verifies. Tampered
|
||||
//! variants are rejected.
|
||||
//!
|
||||
//! The sender is Goblin's actual wallet stack (the `gp-goblin-sender`
|
||||
//! subprocess `genproof` subcommand, which sets InitTxArgs'
|
||||
//! `payment_proof_recipient_address`), producing a real S1 with a real
|
||||
//! PaymentInfo. gp-wallet's upstream `receive_tx` signs the receiver half; we
|
||||
//! assert the stored proof verifies with the same ed25519 library grin uses.
|
||||
//!
|
||||
//! Only freshly generated random test mnemonics; everything offline (no node,
|
||||
//! no chain). Live on-chain confirmation of the proof's kernel is deferred to
|
||||
//! the supervised mainnet round.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
use gp_wallet::{GpWallet, ReceiverProof};
|
||||
use grin_core::global::ChainTypes;
|
||||
use rand::RngCore;
|
||||
|
||||
const AMOUNT: u64 = 2_000_000_000; // 2 grin, in nanogrin
|
||||
|
||||
struct TempDir(PathBuf);
|
||||
|
||||
impl TempDir {
|
||||
fn new(tag: &str) -> TempDir {
|
||||
static N: AtomicU32 = AtomicU32::new(0);
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"gp-proof-{tag}-{}-{}",
|
||||
std::process::id(),
|
||||
N.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
fs::create_dir_all(&path).unwrap();
|
||||
TempDir(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Run one gp-goblin-sender subcommand (same helper as the other gates: build
|
||||
/// the workspace binary if missing, then execute it directly).
|
||||
fn sender(args: &[&str]) {
|
||||
let workspace_root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.unwrap()
|
||||
.parent()
|
||||
.unwrap()
|
||||
.to_path_buf();
|
||||
let target_dir = std::env::var("CARGO_TARGET_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| workspace_root.join("target"));
|
||||
let bin = target_dir.join("debug").join("gp-goblin-sender");
|
||||
|
||||
if !bin.exists() {
|
||||
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
|
||||
let build = Command::new(cargo)
|
||||
.current_dir(&workspace_root)
|
||||
.args(["build", "--quiet", "-p", "gp-goblin-sender"])
|
||||
.output()
|
||||
.expect("failed to spawn cargo build for gp-goblin-sender");
|
||||
assert!(
|
||||
build.status.success(),
|
||||
"building gp-goblin-sender failed:\n{}",
|
||||
String::from_utf8_lossy(&build.stderr),
|
||||
);
|
||||
}
|
||||
|
||||
let output = Command::new(&bin)
|
||||
.args(args)
|
||||
.output()
|
||||
.expect("failed to spawn gp-goblin-sender");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"gp-goblin-sender {args:?} failed:\nstdout: {}\nstderr: {}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
}
|
||||
|
||||
fn new_receiver(dir: &Path) -> GpWallet {
|
||||
let mut entropy = [0u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut entropy);
|
||||
let mnemonic = grin_keychain::mnemonic::from_entropy(&entropy).unwrap();
|
||||
GpWallet::open_at(
|
||||
dir,
|
||||
&mnemonic,
|
||||
"proof-receiver-pw",
|
||||
"http://127.0.0.1:3413",
|
||||
ChainTypes::Mainnet,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payer_requests_proof_receiver_produces_and_verifies_it() {
|
||||
let work = TempDir::new("req");
|
||||
let receiver_dir = TempDir::new("req-recv");
|
||||
let receiver = new_receiver(&receiver_dir.0);
|
||||
let workdir = work.0.to_str().unwrap();
|
||||
|
||||
let recipient = receiver.slatepack_address().unwrap();
|
||||
assert!(recipient.starts_with("grin1"));
|
||||
|
||||
// Payer builds an S1 that REQUESTS a proof to our address.
|
||||
sender(&["genproof", workdir, &AMOUNT.to_string(), &recipient]);
|
||||
let s1_armor = fs::read_to_string(work.0.join("s1.armor")).unwrap();
|
||||
|
||||
let received = receiver.receive_slatepack(&s1_armor).unwrap();
|
||||
assert_eq!(received.amount, AMOUNT);
|
||||
|
||||
let proof_json = received
|
||||
.proof
|
||||
.expect("a proof-requesting S1 must yield a receiver proof");
|
||||
let proof: ReceiverProof = serde_json::from_str(&proof_json).unwrap();
|
||||
|
||||
// The genuine proof verifies, and its fields line up with the receive.
|
||||
assert!(proof.verify(), "receiver proof must verify");
|
||||
assert_eq!(proof.amount, AMOUNT);
|
||||
assert_eq!(proof.kernel_excess, received.kernel_excess);
|
||||
assert_eq!(proof.kernel_excess.len(), 66); // 33-byte commitment hex
|
||||
assert_eq!(proof.recipient_address.len(), 64); // ed25519 hex
|
||||
assert_eq!(proof.sender_address.len(), 64);
|
||||
assert_eq!(proof.recipient_sig.len(), 128); // 64-byte sig hex
|
||||
|
||||
// Tampering any bound field breaks verification.
|
||||
let mut wrong_amount = proof.clone();
|
||||
wrong_amount.amount += 1;
|
||||
assert!(!wrong_amount.verify(), "wrong amount must not verify");
|
||||
|
||||
let mut wrong_excess = proof.clone();
|
||||
// Flip the last hex nibble of the excess (still valid length/hex).
|
||||
let mut chars: Vec<char> = wrong_excess.kernel_excess.chars().collect();
|
||||
let last = chars.len() - 1;
|
||||
chars[last] = if chars[last] == '0' { '1' } else { '0' };
|
||||
wrong_excess.kernel_excess = chars.into_iter().collect();
|
||||
assert!(
|
||||
!wrong_excess.verify(),
|
||||
"wrong kernel excess must not verify"
|
||||
);
|
||||
|
||||
let mut wrong_recipient = proof.clone();
|
||||
// Swap recipient for the sender address: a different key did not sign.
|
||||
wrong_recipient.recipient_address = proof.sender_address.clone();
|
||||
assert!(!wrong_recipient.verify(), "wrong recipient must not verify");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payer_without_proof_request_yields_no_proof() {
|
||||
let work = TempDir::new("noreq");
|
||||
let receiver_dir = TempDir::new("noreq-recv");
|
||||
let receiver = new_receiver(&receiver_dir.0);
|
||||
let workdir = work.0.to_str().unwrap();
|
||||
|
||||
// Plain send (no proof request), as today's Goblin sends.
|
||||
sender(&["gen", workdir, &AMOUNT.to_string()]);
|
||||
let s1_armor = fs::read_to_string(work.0.join("s1.armor")).unwrap();
|
||||
|
||||
let received = receiver.receive_slatepack(&s1_armor).unwrap();
|
||||
assert!(
|
||||
received.proof.is_none(),
|
||||
"no proof should be produced when none was requested"
|
||||
);
|
||||
// The kernel excess is always recorded, proof or not (confirmation needs it).
|
||||
assert_eq!(received.kernel_excess.len(), 66);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Initial schema. Kept minimal per the plan (~3 tables total; the optional
|
||||
-- webhook_delivery table arrives with the notifications milestone).
|
||||
|
||||
-- One row per received payment. Timestamps are ISO-8601 TEXT (UTC).
|
||||
CREATE TABLE payment (
|
||||
id TEXT PRIMARY KEY,
|
||||
amount INTEGER NOT NULL,
|
||||
payer TEXT,
|
||||
slate_id TEXT,
|
||||
kernel TEXT,
|
||||
proof TEXT,
|
||||
status TEXT NOT NULL,
|
||||
confirmed_height INTEGER,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Optional order matching: only populated when payments map to invoices.
|
||||
CREATE TABLE invoice (
|
||||
id TEXT PRIMARY KEY,
|
||||
ref TEXT,
|
||||
expected_amount INTEGER,
|
||||
expiry TEXT,
|
||||
status TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Milestone 3: persist the S2 reply armor so a crash between receive_tx and
|
||||
-- the reply dispatch is recoverable. The ingest service re-sends any payment
|
||||
-- still in status 'received' at boot (mirrors Goblin's reconcile loop).
|
||||
-- The armor contains no secrets: it is the reply slatepack the payer gets
|
||||
-- anyway; transport privacy is the NIP-44 gift wrap.
|
||||
ALTER TABLE payment ADD COLUMN s2_armor TEXT;
|
||||
@@ -0,0 +1,26 @@
|
||||
-- Milestone 4: on-chain confirmation + payment proof.
|
||||
--
|
||||
-- The `kernel`, `proof`, and `confirmed_height` columns already exist from
|
||||
-- 0001 (reserved there for exactly this milestone); this migration only adds
|
||||
-- the confirmation timestamp and an index for the confirmation poll.
|
||||
--
|
||||
-- Column use as of M4:
|
||||
-- kernel hex of the tx kernel excess commitment (33 bytes), set
|
||||
-- at receive time (gp-wallet computes it via the upstream
|
||||
-- Slate::calc_excess). The confirmation poll queries the
|
||||
-- node's get_kernel with this excess.
|
||||
-- proof JSON blob of the receiver-side Grin payment proof
|
||||
-- (amount, kernel excess, sender + recipient ed25519
|
||||
-- addresses, receiver signature), only when the payer's S1
|
||||
-- requested a proof. NULL otherwise.
|
||||
-- confirmed_height block height the kernel landed at (NULL until confirmed).
|
||||
-- confirmed_at ISO-8601 UTC time GoblinPay observed the kernel on chain.
|
||||
--
|
||||
-- Status transitions: received -> replied (S2 dispatched) -> confirmed (kernel
|
||||
-- on chain). Confirmation is independent of the reply leg: a payer can finalize
|
||||
-- and post from a cached S2, so the poll advances any payment with a kernel
|
||||
-- excess, whether or not its S2 reply has been re-observed as delivered.
|
||||
ALTER TABLE payment ADD COLUMN confirmed_at TEXT;
|
||||
|
||||
-- The poll scans not-yet-confirmed payments that carry a kernel excess.
|
||||
CREATE INDEX idx_payment_pending_confirm ON payment (status) WHERE kernel IS NOT NULL;
|
||||
@@ -0,0 +1,32 @@
|
||||
-- Milestone 5: hosted checkout and order matching. Extends the invoice stub
|
||||
-- with the checkout bearer token, the recipient identity (public key only:
|
||||
-- per-invoice derived child keys are recomputed statelessly, never stored),
|
||||
-- the optional fiat quote (the Grin conversion is a later milestone), the
|
||||
-- per-invoice matching-mode override, and the paid-linkage back to a payment.
|
||||
|
||||
ALTER TABLE invoice ADD COLUMN token TEXT;
|
||||
ALTER TABLE invoice ADD COLUMN memo TEXT;
|
||||
-- x-only pubkey hex: the server master key for memo/amount invoices, or a
|
||||
-- per-invoice derived child for derived mode.
|
||||
ALTER TABLE invoice ADD COLUMN recipient_pubkey TEXT;
|
||||
-- Fiat quote (decimal string + ISO code); expected_amount stays NULL until the
|
||||
-- conversion milestone fills the Grin amount.
|
||||
ALTER TABLE invoice ADD COLUMN fiat_amount TEXT;
|
||||
ALTER TABLE invoice ADD COLUMN fiat_currency TEXT;
|
||||
-- Per-invoice matching-mode override; NULL means the global GP_MATCH_MODE.
|
||||
ALTER TABLE invoice ADD COLUMN match_mode TEXT;
|
||||
ALTER TABLE invoice ADD COLUMN paid_payment_id TEXT;
|
||||
ALTER TABLE invoice ADD COLUMN paid_at TEXT;
|
||||
|
||||
CREATE UNIQUE INDEX idx_invoice_token ON invoice (token);
|
||||
CREATE INDEX idx_invoice_recipient ON invoice (recipient_pubkey);
|
||||
CREATE INDEX idx_invoice_ref ON invoice (ref);
|
||||
|
||||
-- Link a received payment back to the invoice and tenant user it satisfied
|
||||
-- (both optional: a bare payment with no invoice still records), and record
|
||||
-- which of our identities received it so the reply can be re-sent from the
|
||||
-- right key on reconcile.
|
||||
ALTER TABLE payment ADD COLUMN invoice_id TEXT;
|
||||
ALTER TABLE payment ADD COLUMN user_id TEXT;
|
||||
ALTER TABLE payment ADD COLUMN recipient TEXT;
|
||||
CREATE INDEX idx_payment_user ON payment (user_id);
|
||||
@@ -0,0 +1,29 @@
|
||||
-- Milestone 5b: per-user endpubs (multi-tenant receiving). Store ONLY the
|
||||
-- assignment and the rotation clock, never a private key: every endpub is a
|
||||
-- stateless child of the server nsec keyed by (user_id, epoch), recomputed on
|
||||
-- demand. All funds still land in the one Grin wallet; the endpub only decides
|
||||
-- which user an incoming payment credits.
|
||||
|
||||
CREATE TABLE user (
|
||||
id TEXT PRIMARY KEY,
|
||||
-- Per-user rotation override in seconds; NULL = global default, 0 = off.
|
||||
rotate_interval INTEGER,
|
||||
-- Current (highest) endpub epoch.
|
||||
epoch INTEGER NOT NULL DEFAULT 0,
|
||||
last_rotated_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- One row per (user, epoch). pubkey is the derived x-only hex (public, never a
|
||||
-- secret). The overlap window keeps the last N epochs watched so a payment to
|
||||
-- a just-rotated endpub still lands.
|
||||
CREATE TABLE endpub_assignment (
|
||||
user_id TEXT NOT NULL,
|
||||
epoch INTEGER NOT NULL,
|
||||
pubkey TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, epoch),
|
||||
FOREIGN KEY (user_id) REFERENCES user (id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_endpub_pubkey ON endpub_assignment (pubkey);
|
||||
@@ -0,0 +1,20 @@
|
||||
-- Milestone 6: HTTP webhook deliveries, persisted for idempotent retry with
|
||||
-- backoff. The body is the exact signed JSON (the HMAC is recomputed from it
|
||||
-- and the configured secret at send time). id is the event id, which is also
|
||||
-- the idempotency key the receiver dedupes on.
|
||||
|
||||
CREATE TABLE webhook_delivery (
|
||||
id TEXT PRIMARY KEY,
|
||||
payment_id TEXT,
|
||||
event_type TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
delivered INTEGER NOT NULL DEFAULT 0,
|
||||
next_attempt_at TEXT NOT NULL,
|
||||
last_error TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_webhook_pending ON webhook_delivery (delivered, next_attempt_at);
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Milestone 7: conversion rates. A fiat invoice is now priced into Grin at
|
||||
-- creation by the configurable price oracle (gp-core::rates), so its
|
||||
-- expected_amount is filled (it was NULL through milestone 5) and the invoice
|
||||
-- participates in amount-matching. The locked quote is recorded alongside the
|
||||
-- fiat amount/currency already stored: the rate used (fiat per GRIN, decimal
|
||||
-- string) and the source it came from (e.g. `coingecko`). The lock window is
|
||||
-- the invoice's existing `expiry` column (quoted_at is its `created_at`), so
|
||||
-- an amount-match past expiry re-quotes rather than honouring a stale rate.
|
||||
|
||||
ALTER TABLE invoice ADD COLUMN quote_rate TEXT;
|
||||
ALTER TABLE invoice ADD COLUMN quote_source TEXT;
|
||||
@@ -0,0 +1,3 @@
|
||||
# Default rustfmt style. Only the edition is pinned so formatting stays
|
||||
# reproducible across toolchains.
|
||||
edition = "2021"
|
||||
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Goblin">
|
||||
<rect width="64" height="64" rx="14" fill="#e9c542"/>
|
||||
<path fill="#201d09" d="M20 22c0-3 3-5 6-4l6 3 6-3c3-1 6 1 6 4v10c0 8-6 14-12 14S20 40 20 32z"/>
|
||||
<circle cx="26" cy="30" r="3" fill="#e9c542"/>
|
||||
<circle cx="38" cy="30" r="3" fill="#e9c542"/>
|
||||
<path fill="#201d09" d="M28 40h8l-4 5z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 396 B |
@@ -0,0 +1,106 @@
|
||||
/* GoblinPay checkout + admin styles. One hand-written file, no build step,
|
||||
no JavaScript. Mobile-first (a 390px phone is the primary surface). */
|
||||
|
||||
:root {
|
||||
--bg: #14140f;
|
||||
--panel: #1e1e17;
|
||||
--ink: #f4f1e6;
|
||||
--dim: #a8a294;
|
||||
--accent: #e9c542; /* Goblin yellow */
|
||||
--green: #57b894;
|
||||
--red: #d97070;
|
||||
--line: #33322a;
|
||||
--radius: 14px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font: 16px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 30rem;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 1.25rem 3rem;
|
||||
}
|
||||
|
||||
main.admin { max-width: 60rem; }
|
||||
|
||||
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; }
|
||||
|
||||
.amount {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
margin: 0.25rem 0 1rem;
|
||||
}
|
||||
|
||||
.status { font-weight: 600; margin: 0.5rem 0 1rem; }
|
||||
.status.open { color: var(--dim); }
|
||||
.status.paid, .status-confirmed, .status-replied { color: var(--green); }
|
||||
.status.expired { color: var(--red); }
|
||||
.status-received { color: var(--accent); }
|
||||
|
||||
.qr {
|
||||
background: #fff;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.75rem;
|
||||
width: 100%;
|
||||
max-width: 18rem;
|
||||
margin: 0 auto 1rem;
|
||||
}
|
||||
.qr svg { display: block; width: 100%; height: auto; }
|
||||
|
||||
.hint { color: var(--dim); font-size: 0.9rem; }
|
||||
.memo { color: var(--ink); background: var(--panel); padding: 0.5rem 0.75rem; border-radius: 10px; }
|
||||
|
||||
label { display: block; font-size: 0.85rem; color: var(--dim); margin: 0.75rem 0 0.25rem; }
|
||||
|
||||
.copybox, textarea {
|
||||
width: 100%;
|
||||
background: var(--panel);
|
||||
color: var(--ink);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 0.6rem 0.7rem;
|
||||
font-family: ui-monospace, "SFMono-Regular", Menlo, monospace;
|
||||
font-size: 0.8rem;
|
||||
resize: vertical;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-top: 0.75rem;
|
||||
background: var(--accent);
|
||||
color: #201d09;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
padding: 0.7rem 1.4rem;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover { filter: brightness(1.05); }
|
||||
|
||||
details.manual { margin-top: 1.5rem; border-top: 1px solid var(--line); padding-top: 0.75rem; }
|
||||
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; }
|
||||
|
||||
a { color: var(--accent); }
|
||||
|
||||
.footer { margin-top: 2rem; color: var(--dim); font-size: 0.78rem; text-align: center; }
|
||||
|
||||
/* Admin tables */
|
||||
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
||||
th, td { text-align: left; padding: 0.4rem 0.5rem; border-bottom: 1px solid var(--line); }
|
||||
th { color: var(--dim); font-weight: 600; }
|
||||
.mono { font-family: ui-monospace, monospace; font-size: 0.75rem; color: var(--dim); word-break: break-all; }
|
||||
.config ul { list-style: none; padding: 0; }
|
||||
.config li { padding: 0.2rem 0; color: var(--dim); }
|
||||
code { color: var(--ink); background: var(--panel); padding: 0.05rem 0.35rem; border-radius: 6px; }
|
||||
@@ -0,0 +1,69 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>GoblinPay admin</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="admin">
|
||||
<h1>GoblinPay admin</h1>
|
||||
|
||||
<section class="config">
|
||||
<h2>Configuration</h2>
|
||||
<ul>
|
||||
<li>Node: <code>{{ node_url }}</code></li>
|
||||
<li>Default match mode: <code>{{ match_mode }}</code></li>
|
||||
<li>Nym: <code>{% if nym %}on{% else %}off{% endif %}</code></li>
|
||||
<li>Ingest: <code>{% if ingest %}on{% else %}off{% endif %}</code></li>
|
||||
<li>Relays watched: <code>{{ relay_count }}</code></li>
|
||||
<li>Webhook: <code>{% if webhook_configured %}configured{% else %}off{% endif %}</code>
|
||||
(pending: {{ pending_webhooks }})</li>
|
||||
<li>Endpub rotation: <code>{{ rotate_interval }}s</code>, overlap <code>{{ overlap_epochs }}</code></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="balances">
|
||||
<h2>Users & balances</h2>
|
||||
{% if balances.is_empty() %}
|
||||
<p class="hint">No tenant users yet.</p>
|
||||
{% else %}
|
||||
<table>
|
||||
<thead><tr><th>User</th><th>Endpub (npub)</th><th>Epoch</th><th>Balance (GRIN)</th></tr></thead>
|
||||
<tbody>
|
||||
{% for b in balances %}
|
||||
<tr><td>{{ b.user_id }}</td><td class="mono">{{ b.npub }}</td><td>{{ b.epoch }}</td><td>{{ b.balance_grin }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="payments">
|
||||
<h2>Recent payments</h2>
|
||||
{% if payments.is_empty() %}
|
||||
<p class="hint">No payments recorded yet.</p>
|
||||
{% else %}
|
||||
<table>
|
||||
<thead><tr><th>Slate</th><th>GRIN</th><th>Status</th><th>Invoice</th><th>User</th><th>Received</th></tr></thead>
|
||||
<tbody>
|
||||
{% for p in payments %}
|
||||
<tr>
|
||||
<td class="mono">{{ p.slate_id }}</td>
|
||||
<td>{{ p.amount_grin }}</td>
|
||||
<td class="status-{{ p.status }}">{{ p.status }}</td>
|
||||
<td class="mono">{{ p.invoice_id }}</td>
|
||||
<td class="mono">{{ p.user_id }}</td>
|
||||
<td>{{ p.created_at }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<p class="footer">GoblinPay admin · JSON API under <code>/admin/*</code></p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}GoblinPay{% endblock %}</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
{% block content %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,8 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<main>
|
||||
<h1>GoblinPay</h1>
|
||||
<p>Self-hosted, receive-only Grin payment server.</p>
|
||||
</main>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
{% if is_open %}<meta http-equiv="refresh" content="10">{% endif %}
|
||||
<title>Pay with Goblin (GRIN)</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="checkout">
|
||||
<h1>Pay with Goblin</h1>
|
||||
<p class="amount">{{ info.amount_display }}</p>
|
||||
|
||||
{% if is_paid %}
|
||||
<p class="status paid">Paid ✓</p>
|
||||
<p class="hint">This invoice has been settled. You can close this page.</p>
|
||||
{% else if is_expired %}
|
||||
<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>
|
||||
|
||||
<details class="manual">
|
||||
<summary>Can't scan? Pay manually with a slatepack</summary>
|
||||
{% if wallet_available %}
|
||||
<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>
|
||||
</ol>
|
||||
<form method="post" action="/pay/{{ info.token }}/slatepack">
|
||||
<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>
|
||||
{% endif %}
|
||||
|
||||
{% if let Some(memo) = info.memo %}<p class="memo">{{ memo }}</p>{% endif %}
|
||||
<p class="footer">Powered by GoblinPay · receive-only Grin over Nostr</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,28 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Slatepack response</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="checkout">
|
||||
<h1>{% if ok %}Payment received{% else %}Could not receive{% endif %}</h1>
|
||||
<p class="status {% if ok %}paid{% else %}expired{% endif %}">{{ message }}</p>
|
||||
|
||||
{% if ok %}
|
||||
<label for="s2">Response slatepack (S2)</label>
|
||||
<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>Your wallet posts it to the chain; GoblinPay confirms it on receipt.</li>
|
||||
</ol>
|
||||
{% endif %}
|
||||
|
||||
<p><a href="/pay/{{ token }}">Back to the invoice</a></p>
|
||||
<p class="footer">Powered by GoblinPay</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user