mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-17 22:31:26 +02:00
A browser-based roguelike synchronized with crypto block timing. A run is a race against the next block: reach the exit before the block lands, or the house wins. The same codebase runs the solo dungeon, the social Tavern, and operator-selected PvP and race rulesets, with separate free and paid leaderboards. Self-hostable under the MIT License. See README.md to run it, docs/ARCHITECTURE.md for the design, and docs/DEPLOY.md to operate an instance.
269 lines
10 KiB
JavaScript
269 lines
10 KiB
JavaScript
const PaymentHandlers = require('../src/network/paymentHandlers');
|
|
const { buildCommerceDisclosure } = require('../src/config/commerceDisclosurePolicy');
|
|
|
|
function legalAcknowledgement(gameModeManager) {
|
|
const disclosure = buildCommerceDisclosure(gameModeManager, process.env);
|
|
return {
|
|
policyVersion: disclosure.policyVersion,
|
|
ageEligible: true,
|
|
termsRead: true,
|
|
riskAccepted: true,
|
|
testnetUnderstood: disclosure.service.isTestNetwork === true
|
|
};
|
|
}
|
|
|
|
function makeMocks() {
|
|
const user = { id: 'user-1', clientId: 'client1', payout_address: 'wow1address' };
|
|
return {
|
|
io: { to: () => ({ emit: () => {} }) },
|
|
gameModeManager: {
|
|
gameMode: 'PAID_SINGLE',
|
|
cryptoType: 'WOW',
|
|
currencyDecimals: 11,
|
|
singleGamePrice: 1000,
|
|
creditsPayoutEnabled: false,
|
|
formatAtomicHuman: jest.fn(() => '0.001'),
|
|
getOrCreateUser: jest.fn(async () => user),
|
|
createPaymentRequest: async () => ({
|
|
id: 'pid123',
|
|
address: 'addr',
|
|
amount: 1000,
|
|
amountFormatted: '0.001',
|
|
expiresAt: new Date()
|
|
})
|
|
},
|
|
walletService: {
|
|
startPaymentMonitoring: jest.fn(),
|
|
stopPaymentMonitoring: jest.fn()
|
|
},
|
|
debugManager: { getCurrentBlockHeight: () => 123, CONSOLE_LOGGING: false },
|
|
queueManager: {
|
|
getUserBySocket: () => user,
|
|
getPlayerIndex: () => -1,
|
|
getQueueLength: () => 0,
|
|
addPlayer: jest.fn(),
|
|
markConfirmed: jest.fn(),
|
|
startGameImmediately: jest.fn(() => false)
|
|
},
|
|
broadcastManager: { sendStatusUpdate: jest.fn() }
|
|
};
|
|
}
|
|
|
|
describe('PaymentHandlers basic', () => {
|
|
let ph;
|
|
|
|
afterEach(() => {
|
|
if (ph && typeof ph.dispose === 'function') {
|
|
ph.dispose();
|
|
}
|
|
ph = null;
|
|
});
|
|
|
|
test('createAndShowPaymentRequest emits payment_created', async () => {
|
|
const mocks = makeMocks();
|
|
const emitted = {};
|
|
mocks.io.to = () => ({ emit: (event, data) => { emitted[event] = data; } });
|
|
ph = new PaymentHandlers(mocks);
|
|
await ph.createAndShowPaymentRequest({ id: 'sock1' }, {
|
|
legalAcknowledgement: legalAcknowledgement(mocks.gameModeManager)
|
|
});
|
|
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' }, {
|
|
legalAcknowledgement: legalAcknowledgement(mocks.gameModeManager)
|
|
});
|
|
expect(mocks.gameModeManager.paymentProviders.getProvider).toHaveBeenCalledWith('WOW');
|
|
expect(gateway.startWatch).toHaveBeenCalled();
|
|
expect(mocks.walletService.startPaymentMonitoring).not.toHaveBeenCalled(); // routed away from native
|
|
});
|
|
|
|
test('direct payout invoice is not created before a required payout address exists', async () => {
|
|
const mocks = makeMocks();
|
|
const emitted = {};
|
|
mocks.io.to = () => ({ emit: (event, data) => { emitted[event] = data; } });
|
|
mocks.gameModeManager.getOrCreateUser.mockResolvedValue({ id: 'user-1', payout_address: null });
|
|
mocks.gameModeManager.requiresPayoutAddressForMode = jest.fn(() => true);
|
|
mocks.gameModeManager.createPaymentRequest = jest.fn();
|
|
ph = new PaymentHandlers(mocks);
|
|
|
|
await ph.handlePaymentRequest({ id: 'sock3' }, {
|
|
type: 'single_game',
|
|
legalAcknowledgement: legalAcknowledgement(mocks.gameModeManager)
|
|
});
|
|
|
|
expect(mocks.gameModeManager.createPaymentRequest).not.toHaveBeenCalled();
|
|
expect(emitted.payment_error).toEqual(expect.objectContaining({ code: 'PAYOUT_ADDRESS_REQUIRED' }));
|
|
});
|
|
|
|
test.each(['single_game', 'credits_package'])(
|
|
'payout-disabled %s invoice intake is not halted by an unknown/low wallet reserve',
|
|
async (paymentType) => {
|
|
const mocks = makeMocks();
|
|
mocks.gameModeManager.isPayoutEnabledForMode = jest.fn(() => false);
|
|
mocks.gameModeManager.alertService = {
|
|
checkBalanceForGameStart: jest.fn().mockResolvedValue({ halted: true, reason: 'low' })
|
|
};
|
|
mocks.gameModeManager.createPaymentRequest = jest.fn(mocks.gameModeManager.createPaymentRequest);
|
|
ph = new PaymentHandlers(mocks);
|
|
|
|
await ph.handlePaymentRequest({ id: `no-payout-${paymentType}` }, {
|
|
type: paymentType,
|
|
legalAcknowledgement: legalAcknowledgement(mocks.gameModeManager)
|
|
});
|
|
|
|
expect(mocks.gameModeManager.alertService.checkBalanceForGameStart).not.toHaveBeenCalled();
|
|
expect(mocks.gameModeManager.createPaymentRequest).toHaveBeenCalledTimes(1);
|
|
}
|
|
);
|
|
|
|
test('payout-bearing direct invoice remains fail closed under a critical reserve', async () => {
|
|
const mocks = makeMocks();
|
|
const emitted = {};
|
|
mocks.io.to = () => ({ emit: (event, data) => { emitted[event] = data; } });
|
|
mocks.gameModeManager.isPayoutEnabledForMode = jest.fn(mode => mode === 'PAID_SINGLE');
|
|
mocks.gameModeManager.alertService = {
|
|
checkBalanceForGameStart: jest.fn().mockResolvedValue({ halted: true, reason: 'critical' })
|
|
};
|
|
mocks.gameModeManager.createPaymentRequest = jest.fn();
|
|
ph = new PaymentHandlers(mocks);
|
|
|
|
await ph.handlePaymentRequest({ id: 'payout-stagenet' }, {
|
|
type: 'single_game',
|
|
legalAcknowledgement: legalAcknowledgement(mocks.gameModeManager)
|
|
});
|
|
|
|
expect(mocks.gameModeManager.createPaymentRequest).not.toHaveBeenCalled();
|
|
expect(emitted.balance_critical).toEqual(expect.objectContaining({ message: 'critical' }));
|
|
});
|
|
|
|
test('product grant transaction failure emits review-required and never confirmation success', async () => {
|
|
const mocks = makeMocks();
|
|
const emitted = {};
|
|
const socket = { id: 'product-failure', emit: (event, data) => { emitted[event] = data; } };
|
|
mocks.gameModeManager.processProductPaymentConfirmation = jest.fn()
|
|
.mockRejectedValue(new Error('receipt insert failed'));
|
|
mocks.queueManager.removePlayer = jest.fn();
|
|
ph = new PaymentHandlers(mocks);
|
|
ph.socketPaymentMap.set(socket.id, {
|
|
address: 'addr', paymentId: 'pid123', amount: 1000,
|
|
paymentType: 'credits_package', package: { credits: 10 }
|
|
});
|
|
|
|
await ph._handlePaymentStatus(socket, { id: 'pid123', address: 'addr', amount: 1000 }, null, {
|
|
confirmed: true,
|
|
complete: true,
|
|
amount: 1000,
|
|
confirmations: 2,
|
|
receipts: []
|
|
});
|
|
|
|
expect(emitted.payment_review_required).toEqual(expect.objectContaining({
|
|
code: 'PAYMENT_RECEIPT_REJECTED'
|
|
}));
|
|
expect(emitted.payment_confirmed).toBeUndefined();
|
|
expect(mocks.queueManager.removePlayer).toHaveBeenCalledWith(socket.id);
|
|
});
|
|
|
|
test('single-game payment queue preserves the consumed two-phase fairness proof', async () => {
|
|
const mocks = makeMocks();
|
|
let statusCallback;
|
|
mocks.walletService.startPaymentMonitoring.mockImplementation((_address, callback) => {
|
|
statusCallback = callback;
|
|
});
|
|
const fairnessProof = {
|
|
proofVersion: 2,
|
|
offerId: 'offer-paid',
|
|
serverSeed: 'a'.repeat(64),
|
|
commitment: 'b'.repeat(64),
|
|
clientSeed: 'c'.repeat(64)
|
|
};
|
|
const durableProof = {
|
|
...fairnessProof,
|
|
offerId: 'offer-persisted-on-invoice',
|
|
offerIssuedAt: 1700000000000
|
|
};
|
|
mocks.gameModeManager.createPaymentRequest = jest.fn(async () => ({
|
|
id: 'pid123',
|
|
address: 'addr',
|
|
amount: 1000,
|
|
amountFormatted: '0.001',
|
|
expiresAt: new Date(),
|
|
fairnessProof: durableProof
|
|
}));
|
|
ph = new PaymentHandlers(mocks);
|
|
|
|
await ph.createAndShowPaymentRequest({ id: 'sock4', emit: jest.fn() }, {
|
|
fairnessProof,
|
|
legalAcknowledgement: legalAcknowledgement(mocks.gameModeManager)
|
|
});
|
|
await statusCallback({ in_mempool: true, confirmed: false, amount: 1000 });
|
|
|
|
expect(mocks.queueManager.addPlayer).toHaveBeenCalledWith(expect.objectContaining({
|
|
paymentId: 'pid123',
|
|
fairnessProof: durableProof
|
|
}));
|
|
});
|
|
|
|
test('production refuses to queue an unbound paid invoice', async () => {
|
|
const mocks = makeMocks();
|
|
let statusCallback;
|
|
const emitted = {};
|
|
mocks.gameModeManager._requiresPaidFairnessV2 = jest.fn(() => true);
|
|
mocks.walletService.startPaymentMonitoring.mockImplementation((_address, callback) => {
|
|
statusCallback = callback;
|
|
});
|
|
const socket = {
|
|
id: 'sock-unbound',
|
|
emit: (event, data) => { emitted[event] = data; }
|
|
};
|
|
ph = new PaymentHandlers(mocks);
|
|
|
|
await ph.createAndShowPaymentRequest(socket, {
|
|
legalAcknowledgement: legalAcknowledgement(mocks.gameModeManager)
|
|
});
|
|
await statusCallback({ in_mempool: true, confirmed: false, amount: 1000 });
|
|
|
|
expect(mocks.queueManager.addPlayer).not.toHaveBeenCalled();
|
|
expect(emitted.payment_review_required).toEqual(expect.objectContaining({
|
|
code: 'PAYMENT_FAIRNESS_UNBOUND'
|
|
}));
|
|
});
|
|
|
|
test('fairness proof survives a payout-address prompt before monitoring starts', async () => {
|
|
const mocks = makeMocks();
|
|
let hasAddress = false;
|
|
let statusCallback;
|
|
mocks.gameModeManager.requiresPayoutAddressForMode = jest.fn(() => true);
|
|
mocks.gameModeManager.getOrCreateUser.mockImplementation(async () => ({
|
|
id: 'user-1',
|
|
payout_address: hasAddress ? 'wow1address' : null
|
|
}));
|
|
mocks.walletService.startPaymentMonitoring.mockImplementation((_address, callback) => {
|
|
statusCallback = callback;
|
|
});
|
|
const fairnessProof = { proofVersion: 2, offerId: 'address-offer', clientSeed: 'd'.repeat(64) };
|
|
ph = new PaymentHandlers(mocks);
|
|
|
|
await ph.createAndShowPaymentRequest({ id: 'sock5', emit: jest.fn() }, {
|
|
fairnessProof,
|
|
legalAcknowledgement: legalAcknowledgement(mocks.gameModeManager)
|
|
});
|
|
expect(statusCallback).toBeUndefined();
|
|
hasAddress = true;
|
|
await ph.createAndShowPaymentRequest({ id: 'sock5', emit: jest.fn() });
|
|
await statusCallback({ in_mempool: true, confirmed: false, amount: 1000 });
|
|
|
|
expect(mocks.queueManager.addPlayer).toHaveBeenCalledWith(expect.objectContaining({ fairnessProof }));
|
|
});
|
|
});
|