From f7c9fb7366fd256bc32e9e43809dd02ac57e8627 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Fri, 10 Jul 2026 18:06:24 -0400 Subject: [PATCH] feat(payments): wire gateway invoice monitoring through the provider registry Completes gateway support (code side). network/paymentHandlers._monitorAddress now selects the provider for the chain and calls provider.startWatch; the whole confirmation callback is extracted to _handlePaymentStatus so native and gateway payments share the exact same logic. stopMonitoringForSocket stops via the provider too. - nativeMoneroProvider.startWatch passes the RAW wallet status through untouched, so native monitoring is byte-for-byte the legacy path (getInvoiceStatus still returns the normalized contract shape for other callers). - btcpayProvider.getWalletStatus maps a Greenfield invoice to that same raw shape: Settled -> confirmed+complete, Processing -> in_mempool, with paid/required amounts read from the invoice payment-methods (totalPaid/amount) and converted to atomic units via ChainProfile decimals. startWatch polls it and stops on complete/expired/invalid. - paymentHandlers falls back to walletService.startPaymentMonitoring when no registry is present, so existing behavior/tests are preserved. Behavior-preserving: no gateway env => native provider selected => identical path. Tests: +7 (getWalletStatus status/decimal mapping x5, native raw passthrough, gateway monitoring routing). Suite 53 suites / 372 tests green. Not yet live-verified against the real box (needs a real store API key). --- src/network/paymentHandlers.js | 59 ++++++++++++++----- src/payments/providers/btcpayProvider.js | 47 ++++++++++++++- .../providers/nativeMoneroProvider.js | 6 +- test/paymentHandlers.test.js | 13 ++++ test/paymentProviders_ultra.test.js | 57 ++++++++++++++++++ 5 files changed, 163 insertions(+), 19 deletions(-) diff --git a/src/network/paymentHandlers.js b/src/network/paymentHandlers.js index 398dc24..3579b60 100644 --- a/src/network/paymentHandlers.js +++ b/src/network/paymentHandlers.js @@ -293,6 +293,14 @@ class PaymentHandlers { } _monitorAddress(socket, paymentRequest, amount, cryptoType, currentUser, paymentType = 'single_game') { + // Select the provider that owns this chain: native wallet-RPC by default, or a BTCPay/ + // checkout gateway when this.cryptoType is routed to one. Native watches by subaddress; + // a gateway watches by invoice id. The confirmation handler is identical either way. + const registry = this.gameModeManager && this.gameModeManager.paymentProviders; + const provider = registry ? registry.getProvider(cryptoType) : null; + const isGateway = !!(provider && provider.id !== 'native-monero'); + const watchRef = isGateway ? (paymentRequest.invoiceId || paymentRequest.address) : paymentRequest.address; + // Record mapping so we can stop later (replace existing entry) this.socketPaymentMap.set(socket.id, { address: paymentRequest.address, @@ -301,10 +309,39 @@ class PaymentHandlers { cryptoType, paymentType, package: paymentRequest.package, + provider: provider || null, + watchRef, createdAt: Date.now() }); - this.walletService.startPaymentMonitoring(paymentRequest.address, async (status) => { + // Expire request after 30 minutes if not confirmed + const expiryTimeout = setTimeout(() => { + const mapping = this.socketPaymentMap.get(socket.id); + if (mapping && mapping.address === paymentRequest.address) { // still active and not replaced + this.stopMonitoringForSocket(socket.id); + this.broadcastManager.sendStatusUpdate(socket.id, 'warning', 'Payment request expired. Type \'enter\' again to create a new payment request.'); + } + }, 30 * 60 * 1000); + // Store & unref so tests / process can exit (the 30-min timer must never keep the + // event loop alive on its own — in production the server keeps the process running). + if (expiryTimeout && expiryTimeout.unref) expiryTimeout.unref(); + this._expiryTimeouts.set(socket.id, expiryTimeout); + + // Start watching. onUpdate delivers a raw wallet-style status; for native that IS the + // walletService status (unchanged legacy path), for a gateway it's mapped from the + // Greenfield invoice. Fall back to direct wallet monitoring when no provider is registered. + const onStatus = (status) => this._handlePaymentStatus(socket, paymentRequest, currentUser, status); + if (provider && typeof provider.startWatch === 'function') { + provider.startWatch(watchRef, onStatus, 2000); + } else { + this.walletService.startPaymentMonitoring(paymentRequest.address, onStatus, 2000); + } + } + + // Handle one payment status update (raw wallet-style shape: in_mempool/confirmed/complete/ + // amount/required/confirmations). Extracted verbatim from the monitor callback so native and + // gateway providers share the exact same confirmation logic. + async _handlePaymentStatus(socket, paymentRequest, currentUser, status) { if (status.in_mempool && !status.confirmed) { if (this.mempoolNotified.has(paymentRequest.address)) return; this.mempoolNotified.set(paymentRequest.address, Date.now()); @@ -520,20 +557,6 @@ class PaymentHandlers { // Clean up monitoring for this socket (even if duplicate) this.stopMonitoringForSocket(socket.id); } - }, 2000); - - // Expire request after 30 minutes if not confirmed - const expiryTimeout = setTimeout(() => { - const mapping = this.socketPaymentMap.get(socket.id); - if (mapping && mapping.address === paymentRequest.address) { // still active and not replaced - this.stopMonitoringForSocket(socket.id); - this.broadcastManager.sendStatusUpdate(socket.id, 'warning', 'Payment request expired. Type \'enter\' again to create a new payment request.'); - } - }, 30 * 60 * 1000); - // Store & unref so tests / process can exit (the 30-min timer must never keep the - // event loop alive on its own — in production the server keeps the process running). - if (expiryTimeout && expiryTimeout.unref) expiryTimeout.unref(); - this._expiryTimeouts.set(socket.id, expiryTimeout); } /** @@ -551,7 +574,11 @@ class PaymentHandlers { stopMonitoringForSocket(socketId) { const mapping = this.socketPaymentMap.get(socketId); if (mapping) { - this.walletService.stopPaymentMonitoring(mapping.address); + if (mapping.provider && typeof mapping.provider.stopWatch === 'function') { + mapping.provider.stopWatch(mapping.watchRef || mapping.address); // native delegates to stopPaymentMonitoring + } else { + this.walletService.stopPaymentMonitoring(mapping.address); + } this.socketPaymentMap.delete(socketId); this.mempoolNotified.delete(mapping.address); if (mapping.paymentId != null) { diff --git a/src/payments/providers/btcpayProvider.js b/src/payments/providers/btcpayProvider.js index 3051ad7..335a96d 100644 --- a/src/payments/providers/btcpayProvider.js +++ b/src/payments/providers/btcpayProvider.js @@ -2,6 +2,18 @@ const { PaymentProvider } = require('./paymentProvider'); const ChainProfile = require('../../chain/chainProfile'); const Atomic = require('../../money/atomic'); +// Convert a decimal crypto string (e.g. "1.5") to an integer atomic amount for `decimals` places. +// Truncates any digits beyond `decimals` (the gateway already quantizes to chain precision). +function decimalToAtomic(dec, decimals) { + const s = String(dec == null ? '' : dec).trim(); + if (!s || s === '-') return 0; + const neg = s[0] === '-'; + const [ip, fpRaw = ''] = s.replace('-', '').split('.'); + const fp = (fpRaw + '0'.repeat(decimals)).slice(0, decimals); + const atomic = BigInt(ip || '0') * (10n ** BigInt(decimals)) + BigInt(fp || '0'); + return Number(neg ? -atomic : atomic); +} + /** * 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 @@ -102,16 +114,47 @@ class BTCPayProvider extends PaymentProvider { }; } + // Read the invoice as a RAW wallet-style status (the shape the confirmation callback consumes), + // so a gateway payment flows through the exact same paymentHandlers path as a native one. + // Settled -> confirmed+complete; Processing -> in_mempool. Paid/required amounts (atomic) come + // from the invoice payment-methods (totalPaid / amount), converted via the chain's decimals. + async getWalletStatus(invoiceRef) { + const id = typeof invoiceRef === 'object' ? invoiceRef.invoiceId : invoiceRef; + const inv = await this._req('GET', `/api/v1/stores/${this.storeId}/invoices/${id}`); + const mapped = BTCPayProvider._mapStatus(inv.status); + let amount = 0, required = 0; + if (mapped.status === 'processing' || mapped.status === 'paid') { + try { + const methods = await this._req('GET', `/api/v1/stores/${this.storeId}/invoices/${id}/payment-methods`); + const list = Array.isArray(methods) ? methods : []; + const m = list[0] || {}; + const dec = ChainProfile.decimalsFor(m.cryptoCode || m.paymentMethod || ''); + amount = decimalToAtomic(m.totalPaid != null ? m.totalPaid : m.paymentMethodPaid, dec); + required = decimalToAtomic(m.amount, dec); + } catch (_) { /* amount stays 0; a Settled invoice still confirms via `complete` */ } + } + return { + in_mempool: mapped.status === 'processing', + confirmed: mapped.status === 'paid', + complete: mapped.complete, + amount, + required, + confirmations: null, + _terminal: mapped.status === 'expired' || mapped.status === 'invalid' + }; + } + // Poll-based watch (BTCPay also supports webhooks; a webhook bridge can call onUpdate directly). + // onUpdate receives the raw wallet-style status from getWalletStatus. startWatch(invoiceRef, onUpdate, intervalMs = 4000) { const id = typeof invoiceRef === 'object' ? invoiceRef.invoiceId : invoiceRef; if (this._watchers && this._watchers.has(id)) return; this._watchers = this._watchers || new Map(); const timer = setInterval(async () => { try { - const st = await this.getInvoiceStatus(id); + const st = await this.getWalletStatus(id); onUpdate(st); - if (st.complete || st.status === 'expired' || st.status === 'invalid') this.stopWatch(id); + if (st.complete || st._terminal) this.stopWatch(id); } catch (_) { /* keep polling */ } }, intervalMs); if (timer.unref) timer.unref(); diff --git a/src/payments/providers/nativeMoneroProvider.js b/src/payments/providers/nativeMoneroProvider.js index c1f1554..6439e03 100644 --- a/src/payments/providers/nativeMoneroProvider.js +++ b/src/payments/providers/nativeMoneroProvider.js @@ -46,10 +46,14 @@ class NativeMoneroProvider extends PaymentProvider { return this._normalize(await this.wallet.checkPaymentStatus(this._addr(invoiceRef))); } + // The monitoring contract passes the RAW wallet status through untouched — the shape + // checkPaymentStatus produces ({ in_mempool, confirmed, complete, amount, required, + // confirmations }) — so the confirmation callback in paymentHandlers is byte-for-byte + // the legacy path for native. (getInvoiceStatus above still returns the normalized shape.) startWatch(invoiceRef, onUpdate, intervalMs = 2000) { const address = this._addr(invoiceRef); if (typeof this.wallet.startPaymentMonitoring === 'function') { - this.wallet.startPaymentMonitoring(address, (st) => onUpdate(this._normalize(st)), intervalMs); + this.wallet.startPaymentMonitoring(address, (st) => onUpdate(st), intervalMs); } } stopWatch(invoiceRef) { diff --git a/test/paymentHandlers.test.js b/test/paymentHandlers.test.js index 96dcd69..885cf0a 100644 --- a/test/paymentHandlers.test.js +++ b/test/paymentHandlers.test.js @@ -55,6 +55,19 @@ describe('PaymentHandlers basic', () => { await ph.createAndShowPaymentRequest({ id: 'sock1' }); expect(emitted.payment_created).toBeDefined(); expect(emitted.payment_created.paymentId).toBe('pid123'); + // No paymentProviders registry on the mock -> legacy fallback to direct wallet monitoring. expect(mocks.walletService.startPaymentMonitoring).toHaveBeenCalled(); }); + + test('monitoring routes through a routed gateway provider instead of the wallet', async () => { + const mocks = makeMocks(); + const gateway = { id: 'wowcheckout', startWatch: jest.fn(), stopWatch: jest.fn() }; + mocks.gameModeManager.paymentProviders = { getProvider: jest.fn(() => gateway) }; + mocks.io.to = () => ({ emit: () => {} }); + ph = new PaymentHandlers(mocks); + await ph.createAndShowPaymentRequest({ id: 'sock2' }); + expect(mocks.gameModeManager.paymentProviders.getProvider).toHaveBeenCalledWith('WOW'); + expect(gateway.startWatch).toHaveBeenCalled(); + expect(mocks.walletService.startPaymentMonitoring).not.toHaveBeenCalled(); // routed away from native + }); }); diff --git a/test/paymentProviders_ultra.test.js b/test/paymentProviders_ultra.test.js index 6852fa7..8365467 100644 --- a/test/paymentProviders_ultra.test.js +++ b/test/paymentProviders_ultra.test.js @@ -72,6 +72,51 @@ describe('BTCPayProvider (Greenfield)', () => { const p = new BTCPayProvider({ fetchImpl: async () => ({}) }); await expect(p.getInvoiceStatus('x')).rejects.toThrow(/not configured/); }); + + describe('getWalletStatus (raw shape the confirmation callback consumes)', () => { + const mk = (invStatus, methods, chains) => new BTCPayProvider({ + baseUrl: 'x', storeId: 's', apiKey: 'k', chains, + fetchImpl: recordingFetch([ + { method: 'GET', match: /\/invoices\/inv1$/, body: { id: 'inv1', status: invStatus } }, + { method: 'GET', match: /\/invoices\/inv1\/payment-methods$/, body: methods } + ]) + }); + + test('Settled -> confirmed+complete with paid/required in atomic units (XMR, 12 decimals)', async () => { + const st = await mk('Settled', [{ cryptoCode: 'XMR', destination: 'a', totalPaid: '1.5', amount: '1.5' }], ['XMR']).getWalletStatus('inv1'); + expect(st.confirmed).toBe(true); + expect(st.complete).toBe(true); + expect(st.in_mempool).toBe(false); + expect(st.amount).toBe(1500000000000); // 1.5 * 1e12 + expect(st.required).toBe(1500000000000); + }); + + test('WOW uses 11 decimals', async () => { + const st = await mk('Settled', [{ cryptoCode: 'WOW', totalPaid: '1.5', amount: '1.5' }], ['WOW']).getWalletStatus('inv1'); + expect(st.amount).toBe(150000000000); // 1.5 * 1e11 + }); + + test('Processing -> in_mempool, not confirmed', async () => { + const st = await mk('Processing', [{ cryptoCode: 'XMR', totalPaid: '0.5', amount: '1.5' }], ['XMR']).getWalletStatus('inv1'); + expect(st.in_mempool).toBe(true); + expect(st.confirmed).toBe(false); + expect(st.complete).toBe(false); + expect(st.amount).toBe(500000000000); + }); + + test('New -> pending, no amount', async () => { + const st = await mk('New', [], ['XMR']).getWalletStatus('inv1'); + expect(st.confirmed).toBe(false); + expect(st.in_mempool).toBe(false); + expect(st.amount).toBe(0); + }); + + test('Expired -> terminal flag so the watcher stops', async () => { + const st = await mk('Expired', [], ['XMR']).getWalletStatus('inv1'); + expect(st._terminal).toBe(true); + expect(st.complete).toBe(false); + }); + }); }); describe('NativeMoneroProvider wraps walletRPCService', () => { @@ -97,4 +142,16 @@ describe('NativeMoneroProvider wraps walletRPCService', () => { expect(po.txids).toEqual(['tx1']); expect(wallet.processBatchPayout).toHaveBeenCalledWith([{ address: 'Wo3ydest', amount: '50000000000' }]); }); + + test('startWatch passes the RAW wallet status through untouched (native path preserved)', () => { + let captured; + const w = { startPaymentMonitoring: jest.fn((addr, cb) => { captured = cb; }) }; + const np = new NativeMoneroProvider({ walletService: w }); + const seen = []; + np.startWatch('Wo3xaddr', (s) => seen.push(s), 2000); + expect(w.startPaymentMonitoring).toHaveBeenCalledWith('Wo3xaddr', expect.any(Function), 2000); + const raw = { in_mempool: true, confirmed: false, complete: false, amount: 42, required: 100, confirmations: 0 }; + captured(raw); + expect(seen[0]).toBe(raw); // same object, not normalized — callback sees exactly what the wallet emitted + }); });