mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-17 22:31:26 +02:00
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:
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user