mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-17 22:31:26 +02:00
feat(payments): modular payment-provider system — BTCPay (first plugin) + native Monero
Pillar 3, reframed around the operator's BTCPay infra. The game no longer implements per-chain wallet/daemon code directly; it talks to pluggable payment providers behind one contract (createInvoice/getInvoiceStatus/ startWatch/sendPayout/validateAddress/supportsChain). - paymentProvider.js: PaymentProvider base + PaymentProviderRegistry with operator-configurable chain->provider routing and support-based fallback. - btcpayProvider.js: first plugin. BTCPay Greenfield API client covering all five Smirk chains (BTC/LTC native, XMR via xmrcheckout, WOW via wowcheckout, GRIN via the in-house plugin). Invoice priced in the chain's own crypto; address read from the invoice payment-methods. Injectable fetch for tests. - nativeMoneroProvider.js: second plugin. Wraps the existing walletRPCService (subaddress invoicing + transfer_split payouts) so our own XMR/WOW stack is just another routable option. Behavior-preserving delegation. - paymentProviders_ultra.test.js: registry routing/fallback, BTCPay invoice+ status mapping with mock fetch, native provider delegation. 6 tests. Not yet wired into the live payment flow (env config + registry bootstrap is the next step). Suite 51 suites / 356 tests green.
This commit is contained in:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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' }]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user