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,
+33 -14
View File
@@ -1,52 +1,71 @@
const { buildProviderRegistry, parseRouting } = require('../src/payments/providers');
const fakeWallet = { createPaymentRequest: async () => ({}), checkPaymentStatus: async () => ({}), processBatchPayout: async () => ({}) };
const CHECKOUT = { // full LAN infra: real BTCPay (BTC/LTC) + xmrcheckout + wowcheckout
BTCPAY_URL: 'http://10.42.1.33', BTCPAY_STORE_ID: 'b', BTCPAY_API_KEY: 'k',
XMRCHECKOUT_URL: 'http://10.42.1.33:8080', XMRCHECKOUT_STORE_ID: 'x', XMRCHECKOUT_API_KEY: 'k',
WOWCHECKOUT_URL: 'http://10.42.1.33:8180', WOWCHECKOUT_STORE_ID: 'w', WOWCHECKOUT_API_KEY: 'k'
};
describe('parseRouting', () => {
test('parses JSON and compact forms, ignores garbage', () => {
expect(parseRouting('{"btc":"btcpay","xmr":"native-monero"}')).toEqual({ BTC: 'btcpay', XMR: 'native-monero' });
expect(parseRouting('BTC:btcpay, XMR:native-monero')).toEqual({ BTC: 'btcpay', XMR: 'native-monero' });
expect(parseRouting('{"btc":"btcpay","xmr":"xmrcheckout"}')).toEqual({ BTC: 'btcpay', XMR: 'xmrcheckout' });
expect(parseRouting('BTC:btcpay, XMR:xmrcheckout')).toEqual({ BTC: 'btcpay', XMR: 'xmrcheckout' });
expect(parseRouting('')).toEqual({});
expect(parseRouting('{not json')).toEqual({});
});
});
describe('buildProviderRegistry', () => {
test('native-only (no BTCPay env): every chain routes to native — behavior-preserving', () => {
test('native-only (no gateway env): every chain routes to native — behavior-preserving', () => {
const reg = buildProviderRegistry({ env: {}, walletService: fakeWallet });
expect(reg.getProvider('WOW').id).toBe('native-monero');
expect(reg.getProvider('XMR').id).toBe('native-monero');
expect(reg.getProvider('BTC').id).toBe('native-monero'); // default provider, no routing entry
});
test('with BTCPay env: BTC/LTC/GRIN -> btcpay, XMR/WOW stay native', () => {
test('real BTCPay + native wallet: BTC/LTC -> btcpay, XMR/WOW stay native', () => {
const reg = buildProviderRegistry({
env: { BTCPAY_URL: 'https://btcpay.lan', BTCPAY_STORE_ID: 's', BTCPAY_API_KEY: 'k' },
env: { BTCPAY_URL: 'u', BTCPAY_STORE_ID: 's', BTCPAY_API_KEY: 'k' },
walletService: fakeWallet
});
expect(reg.getProvider('BTC').id).toBe('btcpay');
expect(reg.getProvider('LTC').id).toBe('btcpay');
expect(reg.getProvider('GRIN').id).toBe('btcpay');
expect(reg.getProvider('XMR').id).toBe('native-monero');
expect(reg.getProvider('WOW').id).toBe('native-monero');
});
test('BTCPay-only (no wallet): all five chains route to btcpay', () => {
test('full LAN infra: BTC/LTC -> btcpay, XMR -> xmrcheckout, WOW -> wowcheckout', () => {
const reg = buildProviderRegistry({ env: CHECKOUT, walletService: fakeWallet });
expect(reg.getProvider('BTC').id).toBe('btcpay');
expect(reg.getProvider('LTC').id).toBe('btcpay');
expect(reg.getProvider('XMR').id).toBe('xmrcheckout'); // dedicated gateway wins over native
expect(reg.getProvider('WOW').id).toBe('wowcheckout');
});
test('a checkout gateway wins over the native wallet for its chain', () => {
const reg = buildProviderRegistry({
env: { BTCPAY_URL: 'https://btcpay.lan', BTCPAY_STORE_ID: 's', BTCPAY_API_KEY: 'k' },
env: { XMRCHECKOUT_URL: 'http://10.42.1.33:8080', XMRCHECKOUT_STORE_ID: 'x', XMRCHECKOUT_API_KEY: 'k' },
walletService: fakeWallet
});
expect(reg.getProvider('XMR').id).toBe('xmrcheckout');
expect(reg.getProvider('WOW').id).toBe('native-monero'); // no wowcheckout -> native
});
test('BTCPAY_CHAINS widens the real BTCPay gateway', () => {
const reg = buildProviderRegistry({
env: { BTCPAY_URL: 'u', BTCPAY_STORE_ID: 's', BTCPAY_API_KEY: 'k', BTCPAY_CHAINS: 'btc,ltc,xmr' },
walletService: null
});
expect(reg.getProvider('XMR').id).toBe('btcpay');
expect(reg.getProvider('WOW').id).toBe('btcpay');
expect(reg.getProvider('BTC').id).toBe('btcpay');
});
test('PAYMENT_ROUTING override wins', () => {
test('PAYMENT_ROUTING override wins over defaults', () => {
const reg = buildProviderRegistry({
env: { BTCPAY_URL: 'u', BTCPAY_STORE_ID: 's', BTCPAY_API_KEY: 'k', PAYMENT_ROUTING: 'WOW:btcpay' },
env: { ...CHECKOUT, PAYMENT_ROUTING: 'XMR:native-monero' },
walletService: fakeWallet
});
expect(reg.getProvider('WOW').id).toBe('btcpay'); // overrode the native default
expect(reg.getProvider('XMR').id).toBe('native-monero'); // untouched
expect(reg.getProvider('XMR').id).toBe('native-monero'); // overrode the xmrcheckout default
expect(reg.getProvider('WOW').id).toBe('wowcheckout'); // untouched
});
});
+68
View File
@@ -0,0 +1,68 @@
/**
* Proves GameModeManager.createPaymentRequest routes invoice creation through the payment-provider
* registry: a configured gateway is used, and with the default registry it delegates to the native
* walletService (byte-for-byte legacy path).
*/
const GameModeManager = require('../src/game/gameModeManager');
function mockDb() {
return {
query: jest.fn(async (sql) => {
const s = String(sql);
if (/FROM users WHERE id/i.test(s)) return { rows: [{ id: 7, credits: 0, total_credits_purchased: 0 }] };
if (/INSERT INTO payments/i.test(s)) return { rows: [{ id: 101, expires_at: null }] };
return { rows: [] }; // reuse lookup, expire-stale, everything else
}),
withTransaction: jest.fn(async (cb) => cb({ query: jest.fn().mockResolvedValue({ rows: [] }) }))
};
}
const mockDebug = { CONSOLE_LOGGING: false };
const mockConfig = {
getConfig: () => ({
paymentsEnabled: true,
currency: { symbol: 'WOW', decimals: 11 },
modes: { direct: { enabled: true, price: 100000000000n }, credits: { enabled: false, packages: [] } },
payouts: { rules: { direct: { multipliers: {} }, credits: { enabled: false, multipliers: {} } } },
preferences: {}
}),
getLegacyGameMode: () => 'PAID_SINGLE',
eventBus: { on: () => {} }
};
describe('createPaymentRequest routes through the provider registry', () => {
test('a configured gateway provider is used for invoice creation', async () => {
const gateway = {
id: 'xmrcheckout',
createInvoice: jest.fn(async () => ({ invoiceId: 'inv-uuid', address: 'GATEWAYADDR', addressIndex: null, expiresAt: null })),
getProvider() { return this; }
};
const registry = { getProvider: jest.fn(() => gateway) };
const walletService = { createPaymentRequest: jest.fn() };
const gmm = new GameModeManager(mockDb(), walletService, mockDebug, mockConfig, registry);
const res = await gmm.createPaymentRequest('sock1', 'single_game', { userId: 7 });
expect(registry.getProvider).toHaveBeenCalledWith(gmm.cryptoType);
expect(gateway.createInvoice).toHaveBeenCalledWith(
expect.objectContaining({ chain: gmm.cryptoType, userId: 7, orderId: 'sock1' })
);
expect(res.address).toBe('GATEWAYADDR');
expect(res.invoiceId).toBe('inv-uuid');
expect(walletService.createPaymentRequest).not.toHaveBeenCalled(); // routed away from native
});
test('the default registry delegates to the native walletService (legacy path preserved)', async () => {
const walletService = {
createPaymentRequest: jest.fn(async () => ({ address: 'NATIVEADDR', addressIndex: 3, expiresAt: null }))
};
// No injected registry -> constructor builds one whose only provider wraps this walletService.
const gmm = new GameModeManager(mockDb(), walletService, mockDebug, mockConfig);
const res = await gmm.createPaymentRequest('sock2', 'single_game', { userId: 7 });
expect(walletService.createPaymentRequest).toHaveBeenCalledWith(expect.anything(), expect.any(String), 7, 'sock2');
expect(res.address).toBe('NATIVEADDR');
expect(res.invoiceId).toBe('NATIVEADDR'); // native invoices keyed by subaddress
});
});