feat(payments): wire provider registry into invoice creation + fit real BTCPay infra

Recon of the operator's LAN BTCPay box (ssh four-btcpay / 10.42.1.33) showed the
real topology: BTCPay Server 2.3.9 handles BTC/LTC only (BTCPAY_CHAINS=ltc,btc),
while XMR and WOW run through separate 'xmrcheckout' (:8080) and 'wowcheckout'
(:8180) apps — and crucially, both expose a BTCPay-Greenfield-COMPATIBLE API
(/api/v1/stores/{id}/invoices, token auth, Settled/New/Expired status). Grin
isn't deployed there yet. So one Greenfield client class serves all three.

- btcpayProvider: id is now constructor-configurable so the same class registers
  as 'btcpay' (BTC/LTC), 'xmrcheckout' (XMR), and 'wowcheckout' (WOW).
- buildProviderRegistry: activates each gateway independently from its *_URL/
  _STORE_ID/_API_KEY trio (BTCPAY_*, XMRCHECKOUT_*, WOWCHECKOUT_*), with
  topology-correct default routing (BTC/LTC->btcpay, XMR/WOW->their checkout or
  native), BTCPAY_CHAINS to widen, PAYMENT_ROUTING to override.
- gameModeManager: takes an optional paymentProviders registry (defaults to a
  native-wrapping registry built from walletService). createPaymentRequest now
  creates the invoice via getProvider(cryptoType).createInvoice(...). The native
  provider delegates to walletService.createPaymentRequest, so the shipped
  single-chain config is byte-for-byte unchanged — same subaddress, same
  monitoring maps. paymentRequest now also carries invoiceId for the monitoring
  wire to come.
- nativeMoneroProvider: surfaces addressIndex so the call site stays provider-agnostic.

Behavior-preserving: no gateway env => native-only, every chain routes native.
Monitoring + payout paths intentionally left on the native path this pass;
gateway monitoring (provider.startWatch) is the flagged next step. Tests: +4
(provider wiring routes to a gateway; default registry delegates to native).
Suite 53 suites / 365 tests green.
This commit is contained in:
jwinterm
2026-07-10 17:17:49 -04:00
parent bcd00271c5
commit 74753c17b2
6 changed files with 190 additions and 45 deletions
+34 -8
View File
@@ -18,6 +18,7 @@ const paymentConfig = require('../config/paymentConfig');
const money = require('../money/atomic');
const Entitlements = require('../multiplayer/entitlements');
const ProductGrants = require('../payments/productGrants');
const { buildProviderRegistry } = require('../payments/providers');
// A wallet "not enough (unlocked) money" error is raised BEFORE the tx is broadcast, so it
// is SAFE to retry (no double-pay risk) — unlike an ambiguous post-broadcast failure. Monero
@@ -31,12 +32,18 @@ const DEFAULT_SINGLE_GAME_PRICE = 5000000000; // 0.005 XMR or 0.05 WOW dependi
const DEFAULT_CREDITS_PACKAGE_PRICE = 50000000000;
class GameModeManager {
constructor(databaseManager, walletRPCService, debugManager, paymentConfigManager = null) {
constructor(databaseManager, walletRPCService, debugManager, paymentConfigManager = null, paymentProviders = null) {
this.db = databaseManager;
this.walletService = walletRPCService;
this.debugManager = debugManager;
this.paymentConfigManager = paymentConfigManager || null;
// Modular payment providers (Pillar 3). Defaults to a registry whose only member is the
// native Monero/Wownero provider wrapping this walletService — so with no gateway env set
// every chain routes to the existing wallet-RPC path and behavior is unchanged. Injectable
// for tests. See src/payments/providers/index.js + the btcpay-infra-topology memo.
this.paymentProviders = paymentProviders || buildProviderRegistry({ walletService: walletRPCService });
this.cryptoType = process.env.CRYPTO_TYPE || 'XMR';
this.currencyDecimals = this.inferCurrencyDecimals(this.cryptoType);
@@ -1214,13 +1221,31 @@ class GameModeManager {
`, [user.id]);
const expiredAddresses = expiredResult.rows.map(r => r.subaddress);
// Create payment request using wallet RPC with correct parameters
const paymentResult = await this.walletService.createPaymentRequest(
amount,
description,
user.id,
socketId
);
// Create the payment request through the routed provider. The native provider (default
// for every chain when no gateway env is set) delegates to walletService.createPaymentRequest,
// so this is byte-for-byte the legacy path for the shipped single-chain config — same
// subaddress, same walletService monitoring maps. A BTCPay/xmrcheckout/wowcheckout gateway
// takes over only when the operator routes this.cryptoType to it.
const provider = this.paymentProviders && this.paymentProviders.getProvider(this.cryptoType);
let paymentResult;
if (provider) {
const invoice = await provider.createInvoice({
chain: this.cryptoType,
amountAtomic: amount,
description,
userId: user.id,
orderId: socketId
});
paymentResult = {
address: invoice.address,
addressIndex: invoice.addressIndex ?? (invoice.raw && invoice.raw.addressIndex) ?? null,
expiresAt: invoice.expiresAt,
invoiceId: invoice.invoiceId || invoice.address
};
} else {
// Defensive fallback: registry somehow empty -> exact legacy call.
paymentResult = await this.walletService.createPaymentRequest(amount, description, user.id, socketId);
}
const expiresAt = paymentResult.expiresAt || new Date(Date.now() + 30 * 60 * 1000);
@@ -1247,6 +1272,7 @@ class GameModeManager {
return {
id: insertedRow?.id,
address: paymentResult.address,
invoiceId: paymentResult.invoiceId || paymentResult.address,
amount: amount,
amountFormatted: this.formatAtomicHuman(amount, 4),
currency: this.cryptoType,
+10 -6
View File
@@ -3,16 +3,20 @@ const ChainProfile = require('../../chain/chainProfile');
const Atomic = require('../../money/atomic');
/**
* BTCPay Server provider — the first payment plugin. Talks to the BTCPay Greenfield API, so it
* covers every chain the operator's BTCPay handles: BTC/LTC natively, XMR via xmrcheckout, WOW via
* wowcheckout, and Grin via the in-house plugin. Config (env): BTCPAY_URL, BTCPAY_STORE_ID,
* BTCPAY_API_KEY. The invoice is denominated in the chain's own crypto (currency = ticker).
* BTCPay Greenfield provider — the first payment plugin. One client class serves every endpoint
* that speaks the BTCPay Greenfield API. On the operator's LAN that is THREE endpoints (see the
* btcpay-infra-topology memo): real BTCPay Server for BTC/LTC, plus the `xmrcheckout` and
* `wowcheckout` shims which expose Greenfield-compatible `/api/v1/stores/{id}/invoices` routes for
* XMR and WOW. So the same class is registered up to three times with different `id`/baseUrl/store/
* key/chains (id defaults to 'btcpay'). Grin is not deployed yet. The invoice is denominated by
* `currency` — pass the chain ticker (e.g. 'XMR') for a native-crypto amount. Auth is
* `Authorization: token <apiKey>` on all three.
*
* fetchImpl is injectable for tests; defaults to the global fetch (Node >= 18).
*/
class BTCPayProvider extends PaymentProvider {
constructor({ baseUrl, storeId, apiKey, chains = ['BTC', 'LTC', 'XMR', 'WOW', 'GRIN'], fetchImpl = null } = {}) {
super('btcpay');
constructor({ id = 'btcpay', baseUrl, storeId, apiKey, chains = ['BTC', 'LTC', 'XMR', 'WOW', 'GRIN'], fetchImpl = null } = {}) {
super(id);
this.baseUrl = String(baseUrl || '').replace(/\/+$/, '');
this.storeId = storeId;
this.apiKey = apiKey;
+44 -17
View File
@@ -26,38 +26,65 @@ function parseRouting(raw) {
return out;
}
// Chains parsed from a "btc,ltc" style env list, uppercased.
function parseChains(raw, fallback) {
if (!raw) return fallback;
const list = String(raw).split(',').map(s => s.trim().toUpperCase()).filter(Boolean);
return list.length ? list : fallback;
}
/**
* Assemble the payment-provider registry from environment/config. Single wiring seam for Pillar 3.
* Assemble the payment-provider registry from environment/config. Single wiring seam for Pillar 3,
* shaped to the operator's real infra (see btcpay-infra-topology memo): three Greenfield endpoints
* — real BTCPay (BTC/LTC), xmrcheckout (XMR), wowcheckout (WOW) — plus the native wallet-RPC.
*
* Behavior-preserving: with no BTCPAY_* env set (prod today), only the native Monero/Wownero
* provider is registered and every chain routes to it — identical to the current flow. Set
* BTCPAY_URL/BTCPAY_STORE_ID/BTCPAY_API_KEY and BTC/LTC/GRIN route to BTCPay automatically, while
* XMR/WOW stay on the native wallet-RPC. PAYMENT_ROUTING overrides any chain->provider mapping.
* Behavior-preserving: with NO gateway env set (prod today) only the native Monero/Wownero provider
* is registered and every chain routes to it — identical to the current flow. Each endpoint
* activates independently when its *_URL/_STORE_ID/_API_KEY trio is present:
* BTCPAY_URL/BTCPAY_STORE_ID/BTCPAY_API_KEY -> id 'btcpay' (BTCPAY_CHAINS, default BTC,LTC)
* XMRCHECKOUT_URL/XMRCHECKOUT_STORE_ID/..._API_KEY -> id 'xmrcheckout' (XMR)
* WOWCHECKOUT_URL/WOWCHECKOUT_STORE_ID/..._API_KEY -> id 'wowcheckout' (WOW)
* Default routing sends each chain to its gateway when present, else native for XMR/WOW.
* PAYMENT_ROUTING (JSON or "CHAIN:provider,...") overrides any chain->provider mapping.
*/
function buildProviderRegistry({ env = process.env, walletService = null } = {}) {
const registry = new PaymentProviderRegistry();
let hasNative = false, hasBtcpay = false;
const routing = {};
let hasNative = false;
if (walletService) {
registry.register(new NativeMoneroProvider({ walletService }));
hasNative = true;
routing.XMR = 'native-monero';
routing.WOW = 'native-monero';
}
const baseUrl = env.BTCPAY_URL, storeId = env.BTCPAY_STORE_ID, apiKey = env.BTCPAY_API_KEY;
if (baseUrl && storeId && apiKey) {
registry.register(new BTCPayProvider({ baseUrl, storeId, apiKey }));
hasBtcpay = true;
// Real BTCPay (BTC/LTC by default; operator may widen via BTCPAY_CHAINS).
if (env.BTCPAY_URL && env.BTCPAY_STORE_ID && env.BTCPAY_API_KEY) {
const chains = parseChains(env.BTCPAY_CHAINS, ['BTC', 'LTC']);
registry.register(new BTCPayProvider({ id: 'btcpay', baseUrl: env.BTCPAY_URL, storeId: env.BTCPAY_STORE_ID, apiKey: env.BTCPAY_API_KEY, chains }));
for (const c of chains) routing[c] = 'btcpay';
}
// Sensible defaults, only for providers that are actually present.
const routing = {};
if (hasBtcpay) { routing.BTC = 'btcpay'; routing.LTC = 'btcpay'; routing.GRIN = 'btcpay'; }
if (hasNative) { routing.XMR = 'native-monero'; routing.WOW = 'native-monero'; }
else if (hasBtcpay) { routing.XMR = 'btcpay'; routing.WOW = 'btcpay'; }
// xmrcheckout / wowcheckout — Greenfield-compatible shims, same provider class, different id.
const shims = [
{ prefix: 'XMRCHECKOUT', id: 'xmrcheckout', chain: 'XMR' },
{ prefix: 'WOWCHECKOUT', id: 'wowcheckout', chain: 'WOW' }
];
for (const s of shims) {
const url = env[`${s.prefix}_URL`], storeId = env[`${s.prefix}_STORE_ID`], apiKey = env[`${s.prefix}_API_KEY`];
if (url && storeId && apiKey) {
registry.register(new BTCPayProvider({ id: s.id, baseUrl: url, storeId, apiKey, chains: [s.chain] }));
routing[s.chain] = s.id; // a dedicated checkout gateway wins over native for its chain
}
}
Object.assign(routing, parseRouting(env.PAYMENT_ROUTING)); // operator override wins
Object.assign(routing, parseRouting(env.PAYMENT_ROUTING)); // explicit operator override wins over all
registry.setRouting(routing);
registry.setDefault(hasNative ? 'native-monero' : (hasBtcpay ? 'btcpay' : null));
// Default provider (for chains with no explicit route): native if present, else the first registered gateway.
const firstId = registry.providers.keys().next().value || null;
registry.setDefault(hasNative ? 'native-monero' : firstId);
return registry;
}
@@ -34,6 +34,7 @@ class NativeMoneroProvider extends PaymentProvider {
return {
invoiceId: address, // native invoices are keyed by their subaddress
address,
addressIndex: (req && req.addressIndex != null) ? req.addressIndex : null, // Monero-specific; enables monitoring restore after restart
uri: (req && req.uri) || null,
amountAtomic: String(amountAtomic),
expiresAt: (req && req.expiresAt) || null,