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:
2ro
2026-07-02 04:29:54 -04:00
commit bd67bfc92e
74 changed files with 24862 additions and 0 deletions
+15
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -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
+184
View File
@@ -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).
Executable
+7
View File
@@ -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
+86
View File
@@ -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.
+75
View File
@@ -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&section=' . 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'),
);
}
}
+37
View File
@@ -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"] }
+801
View File
@@ -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"));
}
}
+73
View File
@@ -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);
}
}
+166
View File
@@ -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()));
}
}
+356
View File
@@ -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);
}
}
+90
View File
@@ -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]), "-_-_");
}
}
+430
View File
@@ -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 &params.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(&params.order_ref)
.bind(expected_amount)
.bind(params.expiry_secs.map(|s| format!("{s:+} seconds")))
.bind(&token)
.bind(&params.memo)
.bind(&recipient_pubkey)
.bind(&fiat_amount)
.bind(&fiat_currency)
.bind(stored_mode)
.bind(&quote_rate)
.bind(&quote_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"));
}
}
+26
View File
@@ -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))
}
+513
View File
@@ -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());
}
}
+130
View File
@@ -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}\"")));
}
}
+513
View File
@@ -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(_))
));
}
}
+99
View File
@@ -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));
}
}
+383
View File
@@ -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);
}
}
+24
View File
@@ -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 }
+464
View File
@@ -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);
}
}
+53
View File
@@ -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"] }
+309
View File
@@ -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());
}
}
+692
View File
@@ -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")
);
}
}
+206
View File
@@ -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)
}
+235
View File
@@ -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);
}
}
+22
View File
@@ -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;
+192
View File
@@ -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
}
+150
View File
@@ -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)
}
}
+180
View File
@@ -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);
}
}
+254
View File
@@ -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));
}
}
+35
View File
@@ -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);
}
}
+491
View File
@@ -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) = &notify.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."
);
}
}
+399
View File
@@ -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);
}
}
+50
View File
@@ -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"
+3
View File
@@ -0,0 +1,3 @@
# Templates live at the workspace root (see the repo layout in the plan).
[general]
dirs = ["../../templates"]
+386
View File
@@ -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 }))
}
+28
View File
@@ -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,
}
}
+245
View File
@@ -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")
}
}
}
+151
View File
@@ -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;
}
});
}
+141
View File
@@ -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()
}
}
+233
View File
@@ -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 } = &params.amount else {
return Ok(params);
};
let (amount, currency) = (amount.clone(), currency.clone());
match oracle.quote(&amount, &currency).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()
}
+13
View File
@@ -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;
+297
View File
@@ -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"));
}
}
+261
View File
@@ -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");
}
}
+91
View File
@@ -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
}
+87
View File
@@ -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()
}
+235
View File
@@ -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);
}
+291
View File
@@ -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;
}
+50
View File
@@ -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 }
+219
View File
@@ -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());
}
}
+523
View File
@@ -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}");
}
}
+230
View File
@@ -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());
}
}
+185
View File
@@ -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);
}
+173
View File
@@ -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);
}
+25
View File
@@ -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
);
+6
View File
@@ -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;
+26
View File
@@ -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;
+32
View File
@@ -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);
+29
View File
@@ -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);
+20
View File
@@ -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);
+11
View File
@@ -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;
+3
View File
@@ -0,0 +1,3 @@
# Default rustfmt style. Only the edition is pinned so formatting stays
# reproducible across toolchains.
edition = "2021"
+7
View File
@@ -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

+106
View File
@@ -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; }
+69
View File
@@ -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 &amp; 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 &middot; JSON API under <code>/admin/*</code></p>
</main>
</body>
</html>
+12
View File
@@ -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>
+8
View File
@@ -0,0 +1,8 @@
{% extends "base.html" %}
{% block content %}
<main>
<h1>GoblinPay</h1>
<p>Self-hosted, receive-only Grin payment server.</p>
</main>
{% endblock %}
+52
View File
@@ -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 &#10003;</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&#8230;</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&#39;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. &#8230; 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 &middot; receive-only Grin over Nostr</p>
</main>
</body>
</html>
+28
View File
@@ -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>