From 74753c17b20f05a95e467f3cdc8d902f12107994 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Fri, 10 Jul 2026 17:17:49 -0400 Subject: [PATCH] feat(payments): wire provider registry into invoice creation + fit real BTCPay infra MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/game/gameModeManager.js | 42 +++++++++--- src/payments/providers/btcpayProvider.js | 16 +++-- src/payments/providers/index.js | 61 ++++++++++++----- .../providers/nativeMoneroProvider.js | 1 + test/paymentProviderBootstrap_ultra.test.js | 47 +++++++++---- test/paymentProviderWiring_ultra.test.js | 68 +++++++++++++++++++ 6 files changed, 190 insertions(+), 45 deletions(-) create mode 100644 test/paymentProviderWiring_ultra.test.js diff --git a/src/game/gameModeManager.js b/src/game/gameModeManager.js index 3685319..428a91e 100644 --- a/src/game/gameModeManager.js +++ b/src/game/gameModeManager.js @@ -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, diff --git a/src/payments/providers/btcpayProvider.js b/src/payments/providers/btcpayProvider.js index edfdda5..3051ad7 100644 --- a/src/payments/providers/btcpayProvider.js +++ b/src/payments/providers/btcpayProvider.js @@ -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 ` 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; diff --git a/src/payments/providers/index.js b/src/payments/providers/index.js index 6e0da7e..7cd6cd4 100644 --- a/src/payments/providers/index.js +++ b/src/payments/providers/index.js @@ -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; } diff --git a/src/payments/providers/nativeMoneroProvider.js b/src/payments/providers/nativeMoneroProvider.js index 3003518..c1f1554 100644 --- a/src/payments/providers/nativeMoneroProvider.js +++ b/src/payments/providers/nativeMoneroProvider.js @@ -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, diff --git a/test/paymentProviderBootstrap_ultra.test.js b/test/paymentProviderBootstrap_ultra.test.js index c21a47d..4e34ac6 100644 --- a/test/paymentProviderBootstrap_ultra.test.js +++ b/test/paymentProviderBootstrap_ultra.test.js @@ -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 }); }); diff --git a/test/paymentProviderWiring_ultra.test.js b/test/paymentProviderWiring_ultra.test.js new file mode 100644 index 0000000..e438e2a --- /dev/null +++ b/test/paymentProviderWiring_ultra.test.js @@ -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 + }); +});