feat(payments): provider-registry bootstrap from env (single wiring seam)

buildProviderRegistry({env, walletService}) assembles the registry:
- native Monero/Wownero provider registered whenever a walletService exists;
- BTCPay auto-registers when BTCPAY_URL/STORE_ID/API_KEY are set;
- default routing sends BTC/LTC/GRIN to BTCPay and keeps XMR/WOW on native;
- PAYMENT_ROUTING (JSON or CHAIN:provider,... ) overrides any mapping.

Behavior-preserving: with no BTCPAY_* env (prod today) only the native
provider registers and all chains route to it — identical to the current
flow. Inert until index.js calls it; this just makes P3 instantiable behind
one factory. parseRouting + bootstrap covered by 5 tests.
This commit is contained in:
jwinterm
2026-07-10 16:58:56 -04:00
parent 58ec541b42
commit bcd00271c5
2 changed files with 124 additions and 0 deletions
+72
View File
@@ -0,0 +1,72 @@
const { PaymentProvider, PaymentProviderRegistry } = require('./paymentProvider');
const BTCPayProvider = require('./btcpayProvider');
const NativeMoneroProvider = require('./nativeMoneroProvider');
/**
* Parse an operator routing override. Accepts JSON ({"BTC":"btcpay","XMR":"native-monero"})
* or a compact "CHAIN:provider,CHAIN:provider" string. Returns {} on empty/garbage.
*/
function parseRouting(raw) {
if (!raw) return {};
const s = String(raw).trim();
if (!s) return {};
if (s[0] === '{') {
try {
const obj = JSON.parse(s);
const out = {};
for (const k of Object.keys(obj)) out[String(k).toUpperCase()] = String(obj[k]);
return out;
} catch (_) { return {}; }
}
const out = {};
for (const pair of s.split(',')) {
const [chain, prov] = pair.split(':').map(x => (x || '').trim());
if (chain && prov) out[chain.toUpperCase()] = prov;
}
return out;
}
/**
* Assemble the payment-provider registry from environment/config. Single wiring seam for Pillar 3.
*
* 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.
*/
function buildProviderRegistry({ env = process.env, walletService = null } = {}) {
const registry = new PaymentProviderRegistry();
let hasNative = false, hasBtcpay = false;
if (walletService) {
registry.register(new NativeMoneroProvider({ walletService }));
hasNative = true;
}
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;
}
// 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'; }
Object.assign(routing, parseRouting(env.PAYMENT_ROUTING)); // operator override wins
registry.setRouting(routing);
registry.setDefault(hasNative ? 'native-monero' : (hasBtcpay ? 'btcpay' : null));
return registry;
}
module.exports = {
PaymentProvider,
PaymentProviderRegistry,
BTCPayProvider,
NativeMoneroProvider,
buildProviderRegistry,
parseRouting
};
@@ -0,0 +1,52 @@
const { buildProviderRegistry, parseRouting } = require('../src/payments/providers');
const fakeWallet = { createPaymentRequest: async () => ({}), checkPaymentStatus: async () => ({}), processBatchPayout: async () => ({}) };
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('')).toEqual({});
expect(parseRouting('{not json')).toEqual({});
});
});
describe('buildProviderRegistry', () => {
test('native-only (no BTCPay 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', () => {
const reg = buildProviderRegistry({
env: { BTCPAY_URL: 'https://btcpay.lan', 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', () => {
const reg = buildProviderRegistry({
env: { BTCPAY_URL: 'https://btcpay.lan', BTCPAY_STORE_ID: 's', BTCPAY_API_KEY: 'k' },
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', () => {
const reg = buildProviderRegistry({
env: { BTCPAY_URL: 'u', BTCPAY_STORE_ID: 's', BTCPAY_API_KEY: 'k', PAYMENT_ROUTING: 'WOW:btcpay' },
walletService: fakeWallet
});
expect(reg.getProvider('WOW').id).toBe('btcpay'); // overrode the native default
expect(reg.getProvider('XMR').id).toBe('native-monero'); // untouched
});
});