Six fixes from the 2026-06-15 deep review batch (workflow result at
2026-06-15-deep-review-raw.json). Targets the highest-impact + smallest-
scoped items; bigger restructure (B3 persistent retry queue, full B4
transactional idempotency, P1 IPaymentMethodHandler integration) is a
separate effort.
[B5] GrinRPCClient HttpClient timeout (15s).
Default HttpClient.Timeout is 100s. A stuck grin-wallet blocked the
30s payment monitor tick for up to 100s, queueing subsequent
async-void timer ticks under load. Now bounded to 15s — generous
for healthy responses (most < 200ms), short enough that the next
monitor tick gets a fair turn.
[B4 partial] Status endpoint emits the settlement webhook on the
Broadcast→Confirmed transition.
This is the direct root cause of the three stuck staging orders the
user reported 2026-06-15. The customer-side /status poll runs every
5s; the background monitor every 30s. Both end up at the same
confirmation branch, but only the monitor's branch was dispatching
InvoicePaymentSettled. Whichever caller won the race to flip the DB
to Confirmed silently locked out the OTHER caller (the monitor's
loop guard `if (invoice.Status != Broadcast) continue;` makes
Confirmed rows unreachable). Now: the Status endpoint also
dispatches the webhook when IT wins the transition. Note: still
non-idempotent — full atomic guard (SettlementWebhookSent column +
transactional lock + UpdateInvoiceStatusIfBroadcast returning bool)
is queued as the next pass (B4 complete).
[B5 follow-up] Broadcast-webhook dispatch is now awaited in
GrinCheckoutController.SubmitSlatepack (line ~282).
Pre-2026-06-15 the call was fire-and-forget; under any contention
the controller returned the customer's redirect before the POST
left the box, dropping the InvoiceProcessing event entirely. Now
the request waits for the dispatch to either succeed or fail
(bounded by the new RpcTimeout = 15s, so customer wait is
bounded).
[B2] Open-redirect guard on stored RedirectUrl.
GrinCheckoutController.Checkout previously did `Redirect(invoice
.RedirectUrl)` verbatim. Attacker who can call CreateInvoice with
a hostile URL (B1, separate) — or who later mutates the row via
DB access — could 302 customers to a phishing page under this
trusted BTCPay hostname. New IsSafeRedirect helper requires:
absolute https://, well-formed URI, non-loopback host, no
embedded user:pass@. Per-store allowlist is TODO P9.
[P3] Plugin dependency condition >=1.12.0 → >=2.3.9.
Matches README + SETUP.md + the actual BTCPay submodule we
compile against. The prior condition predated the .NET 10
cutover and would let the plugin silently install on hosts we've
never validated against.
[P4] csproj metadata for the auto-generated plugin manifest.
Added <Authors>, <Company>, <Copyright>, <PackageProjectUrl>,
<RepositoryUrl>, <RepositoryType>. <Product> + <Description> +
<Version> were already correct. BTCPayServer.PluginPacker
derives the manifest JSON from the loaded plugin's properties,
which derive from these csproj fields via
BaseBTCPayServerPlugin's reflection getters. With these set, the
plugin-store listing carries proper attribution + provenance
links.
Build verified: dotnet build → 0 errors, 9 warnings (all pre-
existing — package version mismatches in BTCPay's transitive deps +
the same _sessionActive unused-field flag that's been there since
v1.0.0).
Outstanding from the deep review (queued):
B1 — auth on POST /invoices (contract change vs current
intentionally-anonymous integration endpoint; needs operator
decision before flipping)
B3 — persistent webhook-delivery table + retry worker
(next-biggest reliability win; ~3-4h of work — migration +
new HostedService + retry policy)
B4 full — SettlementWebhookSent column + transactional guard
so the Status + Monitor races don't double-fire even in
error paths
P1 — IPaymentMethodHandler integration so Grin lives in BTCPay's
main invoice subsystem (the #1 publish-readiness blocker;
multi-day rework)
P2 — .github/workflows/dotnet.yml for CI
P9 — SSRF guard on WebhookUrl / NodeApiUrl / OwnerApiUrl at save
P10 — proper icon (512×512) + checkout screenshots for plugin-
store listing
BTCPay Server Grin Plugin
Accept Grin payments in your BTCPay Server store.
Grin is a privacy-preserving cryptocurrency using MimbleWimble. Unlike Bitcoin, Grin transactions are interactive — both sender and receiver must exchange data (slatepacks) to build a transaction. This plugin manages that exchange through a checkout UI.
How It Works
- Merchant configures their
grin-walletconnection in BTCPay store settings - Customer creates an invoice (via API or storefront integration)
- Checkout page shows a slatepack message (with QR code) for the customer to process in their wallet
- Customer pastes their wallet's response slatepack back into the checkout page
- Plugin finalizes and broadcasts the transaction
- Background service monitors confirmations and marks the invoice complete
Customer BTCPay (this plugin) grin-wallet
| | |
| --- create invoice -------> | --- issue_invoice_tx ------> |
| <-- slatepack S1 --------- | <-- slatepack S1 ----------- |
| | |
| (process S1 in wallet) | |
| | |
| --- paste response S2 ----> | --- finalize_tx (S2) ------> |
| | --- post_tx ---------------> |
| <-- "payment broadcast" --- | |
| | |
| [monitor service polls every 30s] |
| | --- retrieve_txs ----------> |
| | --- node_height -----------> |
| <-- "confirmed" ----------- | (confirmations >= threshold) |
Requirements
- BTCPay Server v2.3.9+ (.NET 10)
- PostgreSQL (used by BTCPay)
- A running
grin-walletinstance with the Owner API enabled - A synced Grin node (the wallet connects to it)
grin-wallet Setup
The plugin communicates with grin-wallet's Owner API using v3 encrypted JSON-RPC (ECDH key exchange + AES-256-GCM).
# Start the owner API (default port 3420)
grin-wallet owner_api
You'll need three things from your wallet for plugin configuration:
- Owner API URL: default
http://127.0.0.1:3420 - Wallet password: the password used to open/unlock the wallet
- API secret: contents of
.owner_api_secretin your grin-wallet data directory
Installation
From BTCPay Plugin Builder (recommended)
Coming soon — the plugin will be published to the BTCPay plugin directory.
Manual / Development
git clone https://github.com/Such-Software/btcpayserver-grin-plugin.git
cd btcpayserver-grin-plugin
git submodule update --init --recursive
# Build the plugin (.NET 10 SDK required)
dotnet build BTCPayServer.Plugins.Grin/BTCPayServer.Plugins.Grin.csproj
# Run the tests
dotnet test BTCPayServer.Plugins.Grin.Tests/BTCPayServer.Plugins.Grin.Tests.csproj
# Run BTCPay with the plugin loaded
export BTCPAY_DEBUG_PLUGINS="$(pwd)/BTCPayServer.Plugins.Grin/bin/Debug/net10.0/BTCPayServer.Plugins.Grin.dll"
dotnet run --project btcpayserver/BTCPayServer --no-launch-profile
Set your standard BTCPay environment variables (BTCPAY_NETWORK, BTCPAY_POSTGRES, BTCPAY_BTCEXPLORERURL, etc.) as needed.
Configuration
- In BTCPay, go to your store's settings
- Click Grin in the left sidebar
- Enter your wallet's Owner API URL, password, and API secret
- Optionally enter your Grin node's API URL for detailed sync status monitoring (shows sync percentage and phase in the BTCPay footer panel)
- Set minimum confirmations (default: 10, roughly 10 minutes)
- Check Enable Grin Payments and save
- Click Test Connection to verify
See SETUP.md for detailed deployment instructions including Docker networking with socat proxies.
Creating Invoices
Invoices are created via the plugin's REST API:
curl -X POST "http://localhost:23000/stores/{storeId}/plugins/grin/invoices" \
-H "Content-Type: application/json" \
-d '{"amount": 1.5, "orderId": "order-123"}'
Response:
{
"invoiceId": "a1b2c3d4e5f6",
"checkoutUrl": "http://localhost:23000/stores/{storeId}/plugins/grin/checkout/a1b2c3d4e5f6",
"amount": 1.5,
"amountNanogrin": 1500000000,
"txSlateId": "...",
"slatepackAddress": "grin1...",
"status": "Pending"
}
Direct the customer to the checkoutUrl to complete payment.
Invoice Lifecycle
| Status | Description |
|---|---|
| Pending | Invoice created, slatepack issued, awaiting customer response |
| AwaitingResponse | Reserved for future use |
| Broadcast | Transaction finalized and broadcast to the network |
| Confirmed | Transaction has enough confirmations (per store setting) |
| Expired | No response within 24 hours; wallet transaction cancelled automatically |
The background monitor service polls every 30 seconds to update confirmation counts and expire stale invoices.
USD Pricing
The checkout page shows an approximate USD value alongside the Grin amount. The price is fetched from Gate.io's spot API and cached for 2 minutes. Sub-cent amounts display as "< $0.01".
A GrinRateProvider is also registered with BTCPay's rate engine, making GRIN pairs available for rate rules and scripting.
Architecture
BTCPayServer.Plugins.Grin/
├── Plugin.cs # Service registration, nav extension
├── PluginMigrationRunner.cs # EF Core migrations on startup
├── GrinRPCClient.cs # v3 encrypted Owner API client
├── Controllers/
│ ├── UIGrinController.cs # Settings UI (store admin)
│ └── GrinCheckoutController.cs # Checkout flow + REST API
├── Data/
│ ├── GrinDbContext.cs # Plugin's own DB context
│ ├── GrinInvoice.cs # Invoice model
│ └── GrinStoreSettings.cs # Per-store wallet config
├── Services/
│ ├── GrinService.cs # Business logic, price fetching
│ ├── GrinRPCProvider.cs # Per-store RPC client cache
│ ├── GrinRateProvider.cs # BTCPay rate engine integration
│ ├── GrinPaymentMonitorService.cs # Background confirmation tracker
│ ├── GrinSyncService.cs # Node sync status polling (30s)
│ ├── GrinSyncSummaryProvider.cs # BTCPay footer panel integration
│ └── GrinDbContextFactory.cs # DB context factory
├── Views/
│ ├── UIGrin/Settings.cshtml # Store settings + invoice list
│ ├── GrinCheckout/Checkout.cshtml # Payment page
│ ├── GrinCheckout/CheckoutComplete.cshtml
│ ├── GrinCheckout/CheckoutExpired.cshtml
│ ├── Shared/GrinNav.cshtml # Sidebar nav item
│ └── Shared/Grin/GrinSyncSummary.cshtml # Footer sync panel
├── Migrations/ # EF Core migrations
└── Resources/img/ # Grin logo
Each BTCPay store connects to its own grin-wallet instance, similar to how Lightning works — the plugin doesn't hold keys, the merchant runs their own wallet.
Trust Model
Like Lightning in BTCPay, this plugin requires the merchant to run their own grin-wallet. The wallet password and API secret are stored in the BTCPay database. This means:
- Self-hosted BTCPay: Fully self-custodial. You control both the server and the wallet.
- Third-party BTCPay: The server operator has access to your wallet credentials. This is the same trust model as Lightning — if you don't run the server, you're trusting the operator.
Grin's interactive transaction model requires private keys for receiving, so there's no equivalent to Bitcoin's xpub-based watch-only wallets. True non-custodial operation requires self-hosting.
Security Notes
- Wallet credentials (password, API secret, webhook secret) are
stored in the plugin's PostgreSQL tables, encrypted at rest via
ASP.NET Core's
IDataProtector(v1.0.10+). Key ring lives in BTCPay's existing DataProtection directory — back it up. - The Owner API connection uses v3 encrypted JSON-RPC (ECDH + AES-256-GCM) — even over plaintext HTTP, the RPC payload is encrypted
- The plugin never holds private keys; all signing happens in
grin-wallet - Invoice slatepack exchange happens over HTTPS (or whatever your BTCPay instance uses)
- Error messages shown to customers are generic — internal RPC errors are logged server-side only
- Chain-reorg detection (v1.0.10+) re-checks confirmed invoices for
up to 2 hours after settlement and fires an
InvoiceInvalidwebhook if the payment is later orphaned
For the full security policy + responsible disclosure process, see SECURITY.md.
Limitations & Known Issues
This release is labelled "ready for community testing" — production- ready for early adopters, with a short list of known gaps:
- QR code on checkout is hard to scan with mobile cameras. The slatepack payload is 500–1500 chars, producing a Version 30+ QR with ~1px modules at default zoom. Workaround: customers paste the slatepack manually. Fix (animated multi-frame QR via UR / BBQr) is on the roadmap for the next release.
- Webhook delivery has no retry queue. If your consumer is unreachable for 30s+ around a confirmation event, the notification is logged-and-dropped. Acceptable for low-volume stores monitoring their logs; high-volume operators should wait for the retry feature.
- Integration tests run against pure helpers (reorg decision,
HMAC signature, encrypted columns). A real grin-wallet integration
test isn't in CI yet — manual end-to-end is documented in
SETUP.mdunder "Accept your first payment."
See CHANGELOG.md for the full release history and a detailed view of what shipped when.
Running grin-wallet as a Service
See contrib/systemd/ for example systemd unit files for running the Grin node and wallet as background services.
Contributing
PRs welcome. Bugs, feature requests, and design discussion go in GitHub issues. Security issues go via email — see SECURITY.md. The full contribution guide lives in CONTRIBUTING.md.
Quick PR checklist:
dotnet buildcleandotnet testgreen (all 23+ tests)CHANGELOG.mdupdated under the current version- One logical change per PR
License
MIT