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).
This commit is contained in:
jwinterm
2026-07-10 18:06:24 -04:00
parent 74753c17b2
commit f7c9fb7366
5 changed files with 163 additions and 19 deletions
+43 -16
View File
@@ -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) {
+45 -2
View File
@@ -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();
@@ -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) {
+13
View File
@@ -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
});
});
+57
View File
@@ -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
});
});