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
+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
});
});