diff --git a/src/payments/providers/btcpayProvider.js b/src/payments/providers/btcpayProvider.js new file mode 100644 index 0000000..edfdda5 --- /dev/null +++ b/src/payments/providers/btcpayProvider.js @@ -0,0 +1,122 @@ +const { PaymentProvider } = require('./paymentProvider'); +const ChainProfile = require('../../chain/chainProfile'); +const Atomic = require('../../money/atomic'); + +/** + * BTCPay Server provider — the first payment plugin. Talks to the BTCPay Greenfield API, so it + * covers every chain the operator's BTCPay handles: BTC/LTC natively, XMR via xmrcheckout, WOW via + * wowcheckout, and Grin via the in-house plugin. Config (env): BTCPAY_URL, BTCPAY_STORE_ID, + * BTCPAY_API_KEY. The invoice is denominated in the chain's own crypto (currency = ticker). + * + * fetchImpl is injectable for tests; defaults to the global fetch (Node >= 18). + */ +class BTCPayProvider extends PaymentProvider { + constructor({ baseUrl, storeId, apiKey, chains = ['BTC', 'LTC', 'XMR', 'WOW', 'GRIN'], fetchImpl = null } = {}) { + super('btcpay'); + this.baseUrl = String(baseUrl || '').replace(/\/+$/, ''); + this.storeId = storeId; + this.apiKey = apiKey; + this.chains = new Set(chains.map(c => String(c).toUpperCase())); + this.fetch = fetchImpl || (typeof fetch !== 'undefined' ? fetch : null); + } + + supportsChain(chain) { + return this.chains.has(String(chain || '').toUpperCase()); + } + + _headers() { + return { 'Content-Type': 'application/json', 'Authorization': `token ${this.apiKey}` }; + } + + async _req(method, path, body) { + if (!this.fetch) throw new Error('btcpay: no fetch available'); + if (!this.baseUrl || !this.storeId || !this.apiKey) throw new Error('btcpay: not configured (BTCPAY_URL/STORE_ID/API_KEY)'); + const res = await this.fetch(`${this.baseUrl}${path}`, { + method, + headers: this._headers(), + body: body ? JSON.stringify(body) : undefined + }); + const text = await res.text(); + let json = null; + try { json = text ? JSON.parse(text) : null; } catch (_) { /* non-json */ } + if (!res.ok) { + const msg = (json && (json.message || (json[0] && json[0].message))) || `btcpay ${res.status}`; + throw new Error(msg); + } + return json; + } + + // Normalize the BTCPay invoice status to our contract. + static _mapStatus(s) { + switch (String(s || '')) { + case 'Settled': + case 'Complete': return { status: 'paid', complete: true }; + case 'Processing': return { status: 'processing', complete: false }; + case 'Expired': return { status: 'expired', complete: false }; + case 'Invalid': return { status: 'invalid', complete: false }; + case 'New': + default: return { status: 'pending', complete: false }; + } + } + + async createInvoice({ chain, amountAtomic, description, orderId } = {}) { + const ch = String(chain || '').toUpperCase(); + const amount = Atomic.format(amountAtomic, ChainProfile.decimalsFor(ch)); // decimal string in the chain's units + const invoice = await this._req('POST', `/api/v1/stores/${this.storeId}/invoices`, { + amount, + currency: ch, // price the invoice in the crypto itself + metadata: { orderId: orderId || null, itemDesc: description || 'Game entry' } + }); + // The address for the chosen chain comes from the invoice's payment methods. + let address = null, uri = null; + try { + const methods = await this._req('GET', `/api/v1/stores/${this.storeId}/invoices/${invoice.id}/payment-methods`); + const list = Array.isArray(methods) ? methods : []; + const match = list.find(m => String(m.paymentMethod || m.cryptoCode || '').toUpperCase().startsWith(ch)) || list[0]; + if (match) { address = match.destination || match.address || null; uri = match.paymentLink || null; } + } catch (_) { /* address can be fetched later; invoice still valid */ } + return { + invoiceId: invoice.id, + address, + uri: uri || invoice.checkoutLink || null, + amountAtomic: String(amountAtomic), + expiresAt: invoice.expirationTime || null, + raw: invoice + }; + } + + async getInvoiceStatus(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); + return { + status: mapped.status, + complete: mapped.complete, + paidAtomic: null, // BTCPay reports fiat/crypto paid via payment-methods; not needed for a paid/settled gate + confirmations: null, + raw: inv + }; + } + + // Poll-based watch (BTCPay also supports webhooks; a webhook bridge can call onUpdate directly). + 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); + onUpdate(st); + if (st.complete || st.status === 'expired' || st.status === 'invalid') this.stopWatch(id); + } catch (_) { /* keep polling */ } + }, intervalMs); + if (timer.unref) timer.unref(); + this._watchers.set(id, timer); + } + stopWatch(invoiceRef) { + const id = typeof invoiceRef === 'object' ? invoiceRef.invoiceId : invoiceRef; + if (this._watchers && this._watchers.has(id)) { clearInterval(this._watchers.get(id)); this._watchers.delete(id); } + } +} + +module.exports = BTCPayProvider; diff --git a/src/payments/providers/nativeMoneroProvider.js b/src/payments/providers/nativeMoneroProvider.js new file mode 100644 index 0000000..3003518 --- /dev/null +++ b/src/payments/providers/nativeMoneroProvider.js @@ -0,0 +1,75 @@ +const { PaymentProvider } = require('./paymentProvider'); + +/** + * Native Monero/Wownero provider — wraps the existing walletRPCService (subaddress invoicing + + * transfer_split payouts) so "our own XMR/WOW stuff" is just another plugin behind the same + * contract. Behavior-preserving: it delegates to the exact calls the game already makes today. + * Supports XMR/WOW only; other chains route to BTCPay. + */ +class NativeMoneroProvider extends PaymentProvider { + constructor({ walletService, chains = ['XMR', 'WOW'] } = {}) { + super('native-monero'); + this.wallet = walletService; + this.chains = new Set(chains.map(c => String(c).toUpperCase())); + } + + supportsChain(chain) { return this.chains.has(String(chain || '').toUpperCase()); } + + _addr(ref) { return typeof ref === 'object' ? (ref.address || ref.invoiceId) : ref; } + + _normalize(st) { + const complete = !!(st && st.confirmed && st.complete); + return { + status: complete ? 'paid' : (st && st.detected ? 'processing' : 'pending'), + complete, + paidAtomic: st && st.amount != null ? String(st.amount) : null, + confirmations: st && st.confirmations != null ? st.confirmations : null, + raw: st + }; + } + + async createInvoice({ amountAtomic, description, userId, orderId } = {}) { + const req = await this.wallet.createPaymentRequest(amountAtomic, description || 'Game entry', userId, orderId); + const address = req && (req.address || req.subaddress) || null; + return { + invoiceId: address, // native invoices are keyed by their subaddress + address, + uri: (req && req.uri) || null, + amountAtomic: String(amountAtomic), + expiresAt: (req && req.expiresAt) || null, + raw: req + }; + } + + async getInvoiceStatus(invoiceRef) { + return this._normalize(await this.wallet.checkPaymentStatus(this._addr(invoiceRef))); + } + + 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); + } + } + stopWatch(invoiceRef) { + if (typeof this.wallet.stopPaymentMonitoring === 'function') this.wallet.stopPaymentMonitoring(this._addr(invoiceRef)); + } + + async sendPayout({ address, amountAtomic } = {}) { + const result = await this.wallet.processBatchPayout([{ address, amount: amountAtomic }]); + const txids = (result && (result.tx_hash_list || (result.txHash ? [result.txHash] : []))) || []; + return { txids, raw: result }; + } + + async validateAddress(chain, address) { + try { + if (typeof this.wallet.rpcCall === 'function') { + const r = await this.wallet.rpcCall('validate_address', { address }); + return { valid: !!(r && r.valid) }; + } + } catch (_) { /* fall through */ } + return { valid: true }; + } +} + +module.exports = NativeMoneroProvider; diff --git a/src/payments/providers/paymentProvider.js b/src/payments/providers/paymentProvider.js new file mode 100644 index 0000000..a9d87a3 --- /dev/null +++ b/src/payments/providers/paymentProvider.js @@ -0,0 +1,77 @@ +/** + * Modular payment-provider system (Pillar 3, reframed). + * + * Instead of the game implementing per-chain wallet/daemon code, it talks to pluggable PAYMENT + * PROVIDERS. BTCPay Server is the first plugin (it already handles BTC/LTC natively + XMR/WOW via + * checkout plugins + Grin via our own plugin — all five Smirk chains). The existing native + * Monero/Wownero wallet-RPC integration is a second plugin, so an operator can route each chain to + * whichever provider they run (e.g. BTCPay for BTC/LTC/GRIN, native-RPC for XMR/WOW). + * + * A provider implements this contract (all amounts are atomic-unit strings/BigInt for the chain): + * async createInvoice({ chain, amountAtomic, description, userId, orderId }) -> + * { invoiceId, address, uri, amountAtomic, expiresAt, raw } + * async getInvoiceStatus(invoiceRef) -> + * { status: 'pending'|'processing'|'paid'|'expired'|'invalid', paidAtomic, confirmations, complete, raw } + * startWatch(invoiceRef, onUpdate, intervalMs) -> void (poll or webhook-bridge) + * stopWatch(invoiceRef) -> void + * async sendPayout({ chain, address, amountAtomic }) -> { txid | txids, raw } (optional) + * async validateAddress(chain, address) -> { valid, reason } (optional) + * supportsChain(chain) -> boolean + */ + +class PaymentProvider { + /** @param {string} id stable provider id, e.g. 'btcpay' | 'native-monero' */ + constructor(id) { + this.id = id; + } + supportsChain(_chain) { return true; } + async createInvoice(_req) { throw new Error(`${this.id}: createInvoice not implemented`); } + async getInvoiceStatus(_ref) { throw new Error(`${this.id}: getInvoiceStatus not implemented`); } + startWatch(_ref, _onUpdate, _intervalMs) { /* optional */ } + stopWatch(_ref) { /* optional */ } + async sendPayout(_req) { throw new Error(`${this.id}: sendPayout not implemented`); } + async validateAddress(_chain, _address) { return { valid: true }; } +} + +/** + * Routes each chain to a registered provider. Operator-configurable: setRouting({ BTC:'btcpay', + * XMR:'native-monero', ... }) with a default. Unknown chains fall back to the default provider. + */ +class PaymentProviderRegistry { + constructor({ defaultProviderId = null } = {}) { + this.providers = new Map(); + this.routing = {}; + this.defaultProviderId = defaultProviderId; + } + register(provider) { + if (!provider || !provider.id) throw new Error('provider must have an id'); + this.providers.set(provider.id, provider); + if (!this.defaultProviderId) this.defaultProviderId = provider.id; + return this; + } + setRouting(map = {}) { + const out = {}; + for (const k of Object.keys(map)) out[String(k).toUpperCase()] = map[k]; + this.routing = out; + return this; + } + setDefault(providerId) { this.defaultProviderId = providerId; return this; } + + providerIdForChain(chain) { + const c = String(chain || '').toUpperCase(); + return this.routing[c] || this.defaultProviderId; + } + getProvider(chain) { + const id = this.providerIdForChain(chain); + const p = id ? this.providers.get(id) : null; + if (p && (typeof p.supportsChain !== 'function' || p.supportsChain(chain))) return p; + // Fall back to any registered provider that supports the chain. + for (const prov of this.providers.values()) { + if (typeof prov.supportsChain === 'function' && prov.supportsChain(chain)) return prov; + } + return p || null; + } + get(id) { return this.providers.get(id) || null; } +} + +module.exports = { PaymentProvider, PaymentProviderRegistry }; diff --git a/test/paymentProviders_ultra.test.js b/test/paymentProviders_ultra.test.js new file mode 100644 index 0000000..6852fa7 --- /dev/null +++ b/test/paymentProviders_ultra.test.js @@ -0,0 +1,100 @@ +/** + * Modular payment-provider system (Pillar 3, reframed around BTCPay). + */ +const { PaymentProviderRegistry } = require('../src/payments/providers/paymentProvider'); +const BTCPayProvider = require('../src/payments/providers/btcpayProvider'); +const NativeMoneroProvider = require('../src/payments/providers/nativeMoneroProvider'); + +function recordingFetch(routes) { + const calls = []; + const fn = async (url, opts) => { + const method = (opts && opts.method) || 'GET'; + let body = null; + try { body = opts && opts.body ? JSON.parse(opts.body) : null; } catch (_) {} + calls.push({ url, method, body }); + for (const r of routes) { + if (r.method === method && r.match.test(url)) { + return { ok: r.ok !== false, status: r.status || 200, text: async () => JSON.stringify(r.body) }; + } + } + return { ok: false, status: 404, text: async () => '{}' }; + }; + fn.calls = calls; + return fn; +} + +describe('PaymentProviderRegistry routing', () => { + const btc = new BTCPayProvider({ baseUrl: 'x', storeId: 's', apiKey: 'k', chains: ['BTC', 'LTC', 'GRIN'], fetchImpl: async () => ({}) }); + const native = new NativeMoneroProvider({ walletService: {}, chains: ['XMR', 'WOW'] }); + + test('routes each chain to its configured provider', () => { + const reg = new PaymentProviderRegistry().register(btc).register(native) + .setRouting({ BTC: 'btcpay', LTC: 'btcpay', GRIN: 'btcpay', XMR: 'native-monero', WOW: 'native-monero' }); + expect(reg.getProvider('BTC').id).toBe('btcpay'); + expect(reg.getProvider('GRIN').id).toBe('btcpay'); + expect(reg.getProvider('WOW').id).toBe('native-monero'); + }); + + test('a chain routed to a provider that cannot serve it falls back to one that can', () => { + const reg = new PaymentProviderRegistry().register(btc).register(native).setRouting({ XMR: 'btcpay' }); + expect(reg.getProvider('XMR').id).toBe('native-monero'); // btcpay(chains) excludes XMR here + }); +}); + +describe('BTCPayProvider (Greenfield)', () => { + test('creates an invoice priced in the chain and reads the address from payment-methods', async () => { + const fetchImpl = recordingFetch([ + { method: 'POST', match: /\/stores\/store1\/invoices$/, body: { id: 'inv1', checkoutLink: 'https://pay/inv1', expirationTime: 1700 } }, + { method: 'GET', match: /\/invoices\/inv1\/payment-methods$/, body: [{ paymentMethod: 'BTC', destination: 'bc1qaddr', paymentLink: 'bitcoin:bc1qaddr' }] } + ]); + const p = new BTCPayProvider({ baseUrl: 'https://btcpay.lan/', storeId: 'store1', apiKey: 'KEY', fetchImpl }); + const inv = await p.createInvoice({ chain: 'BTC', amountAtomic: 100000000n, description: 'entry', orderId: 'o1' }); + + expect(inv.invoiceId).toBe('inv1'); + expect(inv.address).toBe('bc1qaddr'); + const post = fetchImpl.calls.find(c => c.method === 'POST'); + expect(post.url).toBe('https://btcpay.lan/api/v1/stores/store1/invoices'); + expect(post.body.currency).toBe('BTC'); + expect(post.body.amount).toBe('1'); // 1e8 atomic / 1e8 = 1 BTC + }); + + test('maps invoice status to the contract', async () => { + const mk = (status) => new BTCPayProvider({ + baseUrl: 'x', storeId: 's', apiKey: 'k', + fetchImpl: recordingFetch([{ method: 'GET', match: /\/invoices\/inv1$/, body: { id: 'inv1', status } }]) + }); + expect((await mk('Settled').getInvoiceStatus('inv1')).complete).toBe(true); + expect((await mk('New').getInvoiceStatus('inv1')).status).toBe('pending'); + expect((await mk('Expired').getInvoiceStatus('inv1')).status).toBe('expired'); + }); + + test('errors clearly when unconfigured', async () => { + const p = new BTCPayProvider({ fetchImpl: async () => ({}) }); + await expect(p.getInvoiceStatus('x')).rejects.toThrow(/not configured/); + }); +}); + +describe('NativeMoneroProvider wraps walletRPCService', () => { + const wallet = { + createPaymentRequest: jest.fn(async () => ({ address: 'Wo3xaddr' })), + checkPaymentStatus: jest.fn(async () => ({ confirmed: true, complete: true, amount: 100000000000, confirmations: 3 })), + processBatchPayout: jest.fn(async () => ({ tx_hash_list: ['tx1'] })) + }; + + test('supports only XMR/WOW; delegates invoice + status + payout', async () => { + const np = new NativeMoneroProvider({ walletService: wallet }); + expect(np.supportsChain('WOW')).toBe(true); + expect(np.supportsChain('BTC')).toBe(false); + + const inv = await np.createInvoice({ chain: 'WOW', amountAtomic: '100000000000', userId: 1 }); + expect(inv.address).toBe('Wo3xaddr'); + + const st = await np.getInvoiceStatus('Wo3xaddr'); + expect(st.status).toBe('paid'); + expect(st.complete).toBe(true); + + const po = await np.sendPayout({ chain: 'WOW', address: 'Wo3ydest', amountAtomic: '50000000000' }); + expect(po.txids).toEqual(['tx1']); + expect(wallet.processBatchPayout).toHaveBeenCalledWith([{ address: 'Wo3ydest', amount: '50000000000' }]); + }); +});