feat: productionize paid modes and PvP capture

This commit is contained in:
jwinterm
2026-07-21 20:41:27 -04:00
parent 8fb284f8ed
commit 89398335c7
230 changed files with 31755 additions and 2777 deletions
+33 -1
View File
@@ -4,14 +4,19 @@ const { AppError } = require('../src/utils/errors');
describe('AddressManager', () => {
const sampleAddress = 'WW3UyJFX6GPKdM21APyVYjGhfX3ebyHvPPgtFC3wHxkYTCGg8k2UM9DLTCzcgoaLHSKnvJKRtCPgJ1sxvgYiFCoK19vLk83Jn';
function createManager({ existingAddress = null } = {}) {
function createManager({ existingAddress = null, cryptoType = null, network = null, walletValidation = null } = {}) {
const emitMock = jest.fn();
const toMock = jest.fn().mockReturnValue({ emit: emitMock });
const broadcastMock = { sendStatusUpdate: jest.fn() };
const gameModeManager = {
cryptoType,
network,
setUserPayoutAddress: jest.fn().mockResolvedValue(true),
getOrCreateUser: jest.fn().mockResolvedValue({ payout_address: existingAddress })
};
if (walletValidation) {
gameModeManager.validatePayoutAddress = jest.fn().mockResolvedValue(walletValidation);
}
const manager = new AddressManager({
gameModeManager,
@@ -47,4 +52,31 @@ describe('AddressManager', () => {
expect(manager.pending.has('socket-3')).toBe(false);
expect(emitMock).toHaveBeenCalledWith('address_confirmed', expect.objectContaining({ message: expect.stringContaining('already set') }));
});
test('accepts stagenet XMR prefixes and rejects mainnet prefixes on a stagenet server', async () => {
const { manager, gameModeManager } = createManager({
cryptoType: 'XMR',
network: 'stagenet',
walletValidation: { valid: true, nettype: 'stagenet' }
});
const stagenetAddress = `5${'1'.repeat(94)}`;
const mainnetAddress = `4${'1'.repeat(94)}`;
expect(manager.isValidAddress(stagenetAddress)).toBe(true);
expect(manager.isValidAddress(mainnetAddress)).toBe(false);
expect(await manager.saveAddress('socket-stage', stagenetAddress)).toBe(stagenetAddress);
expect(gameModeManager.setUserPayoutAddress).toHaveBeenCalledWith('socket-stage', stagenetAddress);
});
test('wallet-RPC validation fails closed before an address is persisted', async () => {
const { manager, gameModeManager } = createManager({
cryptoType: 'XMR',
network: 'stagenet',
walletValidation: { valid: false, nettype: 'mainnet' }
});
const stagenetShapedAddress = `7${'1'.repeat(94)}`;
expect(await manager.saveAddress('socket-bad', stagenetShapedAddress)).toBe(false);
expect(gameModeManager.setUserPayoutAddress).not.toHaveBeenCalled();
});
});
+225
View File
@@ -0,0 +1,225 @@
const fs = require('fs');
const path = require('path');
const createAdminRoutes = require('../src/routes/admin');
const AlertService = require('../src/services/alertService');
function makeRouter(db) {
return createAdminRoutes({
db,
gameModeManager: {},
walletRPCService: {},
socketHandlers: {},
io: {},
alertService: null
});
}
function handlerFor(router, method, routePath) {
const layer = router.stack.find(item => item.route?.path === routePath
&& item.route.methods[method]);
if (!layer) throw new Error(`Missing ${method.toUpperCase()} ${routePath}`);
return layer.route.stack[layer.route.stack.length - 1].handle;
}
function invoke(handler, req) {
return new Promise((resolve, reject) => {
const res = {
statusCode: 200,
status(code) {
this.statusCode = code;
return this;
},
json(payload) {
resolve({ status: this.statusCode, payload });
return this;
}
};
handler(req, res, reject);
});
}
describe('admin financial operations visibility', () => {
test.each(['processing', 'needs_review'])(
'payout list accepts the %s filter and reports every operational total',
async status => {
const db = {
query: jest.fn()
.mockResolvedValueOnce({ rows: [{
id: 7,
game_id: 8,
user_id: 9,
amount: '1200',
payout_address: 'address',
status,
tx_hash: null,
fee: null,
retry_count: 1,
last_error: 'ambiguous result',
created_at: new Date('2026-07-21T00:00:00Z'),
processed_at: null
}] })
.mockResolvedValueOnce({ rows: [{
pending_count: '1',
processing_count: '2',
failed_count: '3',
review_count: '4',
completed_count: '5',
permanently_failed_count: '6',
batched_count: '7',
total_volume: '8000'
}] })
};
const handler = handlerFor(makeRouter(db), 'get', '/api/admin/stats/payouts');
const response = await invoke(handler, { query: { status } });
expect(response.status).toBe(200);
expect(db.query.mock.calls[0][1]).toEqual([50, 0, status]);
expect(response.payload.payouts[0]).toMatchObject({ status, lastError: 'ambiguous result' });
expect(response.payload.totals).toMatchObject({
pending: 1,
processing: 2,
failed: 3,
needsReview: 4,
completed: 5,
permanentlyFailed: 6,
batched: 7
});
}
);
test('refund attention filter lists requested, processing, and needs_review rows', async () => {
const db = {
query: jest.fn()
.mockResolvedValueOnce({ rows: [{
id: 11,
payment_id: 12,
user_id: 13,
payment_status: 'refunded',
status: 'needs_review',
amount: '900',
payout_address: 'refund-address',
credits_deducted: '2',
purchase_progress_deducted: '2',
race_entries_deducted: 0,
tx_hash: null,
error_message: 'wallet outcome unknown',
reason: 'operator request',
created_at: new Date('2026-07-21T00:00:00Z'),
updated_at: new Date('2026-07-21T00:01:00Z')
}] })
.mockResolvedValueOnce({ rows: [{
recorded_count: '1',
requested_count: '2',
processing_count: '3',
completed_count: '4',
review_count: '5',
completed_volume: '600'
}] })
};
const handler = handlerFor(makeRouter(db), 'get', '/api/admin/stats/refunds');
const response = await invoke(handler, { query: { status: 'attention' } });
expect(db.query.mock.calls[0][1]).toEqual([
50,
0,
['requested', 'processing', 'needs_review']
]);
expect(response.payload.refunds[0]).toMatchObject({
id: 11,
status: 'needs_review',
error: 'wallet outcome unknown'
});
expect(response.payload.totals).toMatchObject({
recorded: 1,
requested: 2,
processing: 3,
completed: 4,
needsReview: 5
});
});
test('dashboard sends the retry endpoint its required explicit confirmation', () => {
const html = fs.readFileSync(path.join(__dirname, '../html/admin.html'), 'utf8');
const retryFunction = html.match(/async function retryPayout[\s\S]*?async function loadRefunds/)[0];
expect(retryFunction).toContain("'Content-Type': 'application/json'");
expect(retryFunction).toContain('body: JSON.stringify({ confirm: true })');
expect(html).toContain('<option value="processing">Processing</option>');
expect(html).toContain('<option value="needs_review">Needs Review</option>');
expect(html).toContain('id="refundStatusFilter"');
});
});
describe('financial review alerts', () => {
let logSpy;
let errorSpy;
beforeEach(() => {
logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
logSpy.mockRestore();
errorSpy.mockRestore();
});
test('alerts for manual-review and stale nonterminal rows without mutating them', async () => {
const db = { query: jest.fn().mockResolvedValue({ rows: [{
payout_review_count: '2',
refund_review_count: '1',
stale_payout_processing_count: '1',
stale_refund_nonterminal_count: '2'
}] }) };
const service = new AlertService({ db, walletService: null, debugManager: {} });
service.sendAlert = jest.fn().mockResolvedValue({ sent: true });
service.resolveAlert = jest.fn().mockResolvedValue({ sent: false });
await service.checkFinancialReviewRows();
expect(db.query).toHaveBeenCalledTimes(1);
expect(db.query.mock.calls[0][1]).toEqual([service.financialReviewStaleMs]);
expect(service.sendAlert).toHaveBeenCalledWith(
'financial_needs_review',
expect.objectContaining({ level: 'crit' })
);
expect(service.sendAlert).toHaveBeenCalledWith(
'financial_nonterminal_stale',
expect.objectContaining({ level: 'crit' })
);
expect(service.resolveAlert).not.toHaveBeenCalled();
expect(db.query.mock.calls[0][0]).not.toMatch(/\bUPDATE\b/i);
});
test('resolves both aggregate alerts after all review queues clear', async () => {
const db = { query: jest.fn().mockResolvedValue({ rows: [{
payout_review_count: '0',
refund_review_count: '0',
stale_payout_processing_count: '0',
stale_refund_nonterminal_count: '0'
}] }) };
const service = new AlertService({ db, walletService: null, debugManager: {} });
service.sendAlert = jest.fn();
service.resolveAlert = jest.fn().mockResolvedValue({ sent: true });
await service.checkFinancialReviewRows();
expect(service.sendAlert).not.toHaveBeenCalled();
expect(service.resolveAlert).toHaveBeenCalledWith('financial_needs_review');
expect(service.resolveAlert).toHaveBeenCalledWith('financial_nonterminal_stale');
});
test('periodic checks include the financial review query', async () => {
const service = new AlertService({ db: {}, walletService: null, debugManager: {} });
service.checkWalletBalance = jest.fn().mockResolvedValue();
service.checkWalletConnection = jest.fn().mockResolvedValue();
service.checkPendingPayouts = jest.fn().mockResolvedValue();
service.checkFinancialReviewRows = jest.fn().mockResolvedValue();
await service.runChecks();
expect(service.checkFinancialReviewRows).toHaveBeenCalledTimes(1);
});
});
+9 -1
View File
@@ -38,6 +38,7 @@ describe('admin routes factory', () => {
'/api/admin/users/search',
'/api/admin/stats/overview',
'/api/admin/stats/payouts',
'/api/admin/stats/refunds',
'/api/admin/stats/games',
'/api/admin/users',
'/api/admin/users/:id',
@@ -52,7 +53,14 @@ describe('admin routes factory', () => {
describe('auth (smirk) routes factory', () => {
test('builds a router and registers the smirk endpoints', () => {
const router = createAuthRoutes({ db: { query: jest.fn() } });
const query = jest.fn();
const router = createAuthRoutes({
db: { query, withTransaction: jest.fn() },
sessionManager: {
generateSecureToken: jest.fn(),
disconnectUserSessions: jest.fn()
}
});
const paths = router.stack.filter(l => l.route).map(l => l.route.path);
expect(paths).toEqual(expect.arrayContaining([
'/api/auth/smirk/challenge',
+57
View File
@@ -0,0 +1,57 @@
const AlertService = require('../src/services/alertService');
describe('AlertService exact reserve gate', () => {
const keys = ['BALANCE_CRITICAL', 'BALANCE_WARN', 'LOW_BALANCE_THRESHOLD'];
let previous;
let logSpy;
let errorSpy;
beforeEach(() => {
previous = Object.fromEntries(keys.map(key => [key, process.env[key]]));
process.env.BALANCE_CRITICAL = '10000000000000001';
process.env.BALANCE_WARN = '10000000000000002';
delete process.env.LOW_BALANCE_THRESHOLD;
logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
logSpy.mockRestore();
errorSpy.mockRestore();
for (const key of keys) {
if (previous[key] === undefined) delete process.env[key];
else process.env[key] = previous[key];
}
});
test('compares balances above Number.MAX_SAFE_INTEGER without rounding', async () => {
const service = new AlertService({
walletService: {
isHealthy: true,
getBalance: jest.fn(async () => ({ unlocked_balance: '10000000000000000' }))
},
db: null,
debugManager: { CONSOLE_LOGGING: false }
});
expect(service.balanceCriticalThreshold).toBe(10000000000000001n);
await expect(service.checkBalanceForGameStart()).resolves.toEqual(expect.objectContaining({ halted: true }));
});
test('wallet balance read errors fail closed for new paid games', async () => {
const service = new AlertService({
walletService: {
isHealthy: true,
getBalance: jest.fn(async () => { throw new Error('rpc unavailable'); })
},
db: null,
debugManager: { CONSOLE_LOGGING: false }
});
await expect(service.checkBalanceForGameStart()).resolves.toEqual(expect.objectContaining({
halted: true,
reason: expect.stringContaining('could not be verified')
}));
expect(service.isBalanceCritical).toBe(true);
});
});
+141 -2
View File
@@ -52,12 +52,35 @@ const TWO_PENDING = [
{ id: 10, user_id: 1, game_id: 'g1', payout_address: 'addrA', amount: '200000000000', multiplier: 2, reason: 'escape' },
{ id: 11, user_id: 2, game_id: 'g2', payout_address: 'addrB', amount: '300000000000', multiplier: 3, reason: 'escape' }
];
const ONE_PENDING = [TWO_PENDING[0]];
const SHARED_TX_HASH = 'e'.repeat(64);
describe('Batch payout dispatch', () => {
test('shared runtime dispatch predicate stops internal batch paths before DB or wallet work', async () => {
const walletService = {
getBalance: jest.fn(),
processPayout: jest.fn(),
processBatchPayout: jest.fn()
};
const { gmm, db } = buildGmm(walletService);
gmm.payoutDispatchAllowed = jest.fn(() => false);
gmm._scheduleBatchPayout();
await gmm._processPendingPayouts();
expect(gmm.payoutDispatchAllowed).toHaveBeenCalled();
expect(gmm._batchPayoutTimer).toBeNull();
expect(db.withTransaction).not.toHaveBeenCalled();
expect(db.query).not.toHaveBeenCalled();
expect(walletService.processPayout).not.toHaveBeenCalled();
expect(walletService.processBatchPayout).not.toHaveBeenCalled();
});
test('marks the whole batch completed with the shared tx_hash in one transaction', async () => {
const walletService = {
processPayout: jest.fn(),
processBatchPayout: jest.fn().mockResolvedValue({ tx_hash_list: ['txSHARED'], totalFee: 200 })
processBatchPayout: jest.fn().mockResolvedValue({ success: true, tx_hash_list: [SHARED_TX_HASH], totalFee: 200 }),
getBalance: jest.fn().mockResolvedValue({ balance: '10000000000000', unlocked_balance: '10000000000000' })
};
const { gmm, db } = buildGmm(walletService);
@@ -79,7 +102,7 @@ describe('Batch payout dispatch', () => {
expect(batchUpdate).toMatch(/status = 'completed'/);
const batchUpdateCall = db._mockClient.query.mock.calls.find(c => /UPDATE payouts SET tx_hash/i.test(c[0]) && /id = ANY/i.test(c[0]));
expect(batchUpdateCall[1][0]).toBe('txSHARED'); // tx_hash param
expect(batchUpdateCall[1][0]).toBe(SHARED_TX_HASH); // tx_hash param
expect(batchUpdateCall[1][3]).toEqual([10, 11]); // ids array
// Per-user stats incremented for each winner inside the same transaction (BIGINT, no float).
@@ -112,6 +135,78 @@ describe('Batch payout dispatch', () => {
expect(failedCall).toBeUndefined();
});
test('single payout retains a valid wallet hash when the completion DB write fails', async () => {
const walletService = {
getBalance: jest.fn().mockResolvedValue({ balance: '10000000000000', unlocked_balance: '10000000000000' }),
processPayout: jest.fn().mockResolvedValue({ success: true, txHash: SHARED_TX_HASH, fee: 20 }),
processBatchPayout: jest.fn()
};
const { gmm, db } = buildGmm(walletService);
db._mockClient.query
.mockResolvedValueOnce({ rows: ONE_PENDING })
.mockResolvedValueOnce({ rows: [] })
.mockRejectedValueOnce(new Error('database write failed after broadcast'));
await gmm._processPendingPayouts();
const review = db.query.mock.calls.find(call => call[1]?.[0] === 'needs_review');
expect(review).toBeDefined();
expect(review[0]).toMatch(/tx_hash = COALESCE\(\$3, tx_hash\)/i);
expect(review[1][2]).toBe(SHARED_TX_HASH);
expect(review[1][3]).toBe(10);
expect(db.query.mock.calls.some(call => call[1]?.[0] === 'pending')).toBe(false);
expect(walletService.processPayout).toHaveBeenCalledTimes(1);
});
test('batch retains its one valid wallet hash when the completion DB write fails', async () => {
const walletService = {
getBalance: jest.fn().mockResolvedValue({ balance: '10000000000000', unlocked_balance: '10000000000000' }),
processPayout: jest.fn(),
processBatchPayout: jest.fn().mockResolvedValue({
success: true,
tx_hash_list: [SHARED_TX_HASH],
totalFee: 200
})
};
const { gmm, db } = buildGmm(walletService);
db._mockClient.query
.mockResolvedValueOnce({ rows: TWO_PENDING })
.mockResolvedValueOnce({ rows: [] })
.mockRejectedValueOnce(new Error('database write failed after batch broadcast'));
await gmm._processPendingPayouts();
const review = db.query.mock.calls.find(call => call[1]?.[0] === 'needs_review');
expect(review).toBeDefined();
expect(review[0]).toMatch(/tx_hash = COALESCE\(\$4, tx_hash\)/i);
expect(review[1][2]).toEqual([10, 11]);
expect(review[1][3]).toBe(SHARED_TX_HASH);
expect(db.query.mock.calls.some(call => call[1]?.[0] === 'pending')).toBe(false);
expect(walletService.processBatchPayout).toHaveBeenCalledTimes(1);
});
test('multiple valid wallet hashes are not guessed onto every liability', async () => {
const walletService = {
getBalance: jest.fn().mockResolvedValue({ balance: '10000000000000', unlocked_balance: '10000000000000' }),
processPayout: jest.fn(),
processBatchPayout: jest.fn().mockResolvedValue({
success: true,
tx_hash_list: ['a'.repeat(64), 'b'.repeat(64)],
totalFee: 200
})
};
const { gmm, db } = buildGmm(walletService);
db._mockClient.query
.mockResolvedValueOnce({ rows: TWO_PENDING })
.mockResolvedValue({ rows: [] });
await gmm._processPendingPayouts();
const review = db.query.mock.calls.find(call => call[1]?.[0] === 'needs_review');
expect(review).toBeDefined();
expect(db._mockClient.query.mock.calls.some(call => /status = 'completed'/i.test(call[0]))).toBe(false);
});
test('MONERO LOCKING: defers the batch to pending when unlocked balance is insufficient (no transfer attempted)', async () => {
// TWO_PENDING needs 200000000000 + 300000000000 = 500000000000 atomic.
// Unlocked balance is far below that (outputs locked ~10 blocks) -> defer, do not send.
@@ -138,6 +233,50 @@ describe('Batch payout dispatch', () => {
expect(deferSql[1][0]).toEqual([10, 11]); // ids array param
});
test('defers without transfer when unlocked balance cannot be verified', async () => {
const walletService = {
getBalance: jest.fn().mockRejectedValue(new Error('wallet unavailable')),
processPayout: jest.fn(),
processBatchPayout: jest.fn()
};
const { gmm, db } = buildGmm(walletService);
db._mockClient.query
.mockResolvedValueOnce({ rows: TWO_PENDING })
.mockResolvedValue({ rows: [] });
await gmm._processPendingPayouts();
expect(walletService.processPayout).not.toHaveBeenCalled();
expect(walletService.processBatchPayout).not.toHaveBeenCalled();
const deferred = db.query.mock.calls.find(call => /wallet unlocked balance could not be verified/.test(call[1]?.[1] || ''));
expect(deferred).toBeDefined();
expect(deferred[1][0]).toEqual([10, 11]);
});
test('rechecks a gate that closes after claim and wallet probe before transfer', async () => {
const walletService = {
getBalance: jest.fn().mockResolvedValue({
balance: '10000000000000',
unlocked_balance: '10000000000000'
}),
processPayout: jest.fn(),
processBatchPayout: jest.fn()
};
const { gmm, db } = buildGmm(walletService);
let checks = 0;
gmm.payoutDispatchAllowed = jest.fn(() => ++checks === 1);
db._mockClient.query
.mockResolvedValueOnce({ rows: TWO_PENDING })
.mockResolvedValue({ rows: [] });
await gmm._processPendingPayouts();
expect(walletService.getBalance).toHaveBeenCalledTimes(1);
expect(walletService.processPayout).not.toHaveBeenCalled();
expect(walletService.processBatchPayout).not.toHaveBeenCalled();
expect(db.query.mock.calls.some(call => call[1]?.[1] === 'Payout dispatch paused before wallet transfer')).toBe(true);
});
test('a pre-broadcast insufficient-funds batch error is retried (pending), not needs_review', async () => {
const walletService = {
getBalance: jest.fn().mockResolvedValue({ balance: '10000000000000', unlocked_balance: '10000000000000' }),
+164
View File
@@ -0,0 +1,164 @@
'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
const {
CANARY_HANDSHAKE_CONFIRM,
CANARY_HANDSHAKE_PROTOCOL,
computeProof,
createCanaryDatabaseIdentityHandler,
equalProof,
readDatabaseIdentity
} = require('../src/services/canaryDatabaseIdentity');
function protectedNonceFile(nonce = 'ab'.repeat(32)) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'wowngeon-canary-nonce-'));
const fileName = path.join(directory, 'nonce');
fs.writeFileSync(fileName, `${nonce}\n`, { mode: 0o600 });
return { directory, fileName, nonce };
}
function enabledEnv(fileName, overrides = {}) {
return {
NODE_ENV: 'production',
CRYPTO_TYPE: 'XMR',
MONERO_NETWORK: 'stagenet',
PORT: '3102',
DB_NAME: 'monerogue_canary_direct_e2e',
CANARY_EXPECT_DATABASE: 'monerogue_canary_direct_e2e',
CANARY_DATABASE_HANDSHAKE: CANARY_HANDSHAKE_CONFIRM,
CANARY_DATABASE_NONCE_FILE: fileName,
...overrides
};
}
function responseRecorder() {
return {
statusCode: 200,
body: null,
status(code) { this.statusCode = code; return this; },
json(body) { this.body = body; return this; }
};
}
describe('isolated canary database identity endpoint', () => {
test('is absent by default and fails closed on partial or non-stagenet configuration', () => {
expect(createCanaryDatabaseIdentityHandler({ db: {}, env: {} })).toBeNull();
expect(() => createCanaryDatabaseIdentityHandler({
db: {}, env: { CANARY_EXPECT_DATABASE: 'monerogue_canary_direct_e2e' }
})).toThrow(/CANARY_DATABASE_HANDSHAKE/);
const fixture = protectedNonceFile();
try {
expect(() => createCanaryDatabaseIdentityHandler({
db: {}, env: enabledEnv(fixture.fileName, { MONERO_NETWORK: 'mainnet' })
})).toThrow(/XMR stagenet/);
expect(() => createCanaryDatabaseIdentityHandler({
db: {}, env: enabledEnv(fixture.fileName, { PORT: '3000' })
})).toThrow(/3102/);
expect(() => createCanaryDatabaseIdentityHandler({
db: {}, env: enabledEnv(fixture.fileName, { DB_NAME: 'monerogue' })
})).toThrow(/DB_NAME/);
fs.chmodSync(fixture.fileName, 0o644);
expect(() => createCanaryDatabaseIdentityHandler({
db: {}, env: enabledEnv(fixture.fileName)
})).toThrow(/group\/world/);
fs.chmodSync(fixture.fileName, 0o600);
const link = path.join(fixture.directory, 'nonce-link');
fs.symlinkSync(fixture.fileName, link);
expect(() => createCanaryDatabaseIdentityHandler({
db: {}, env: enabledEnv(link)
})).toThrow();
} finally {
fs.rmSync(fixture.directory, { recursive: true, force: true });
}
});
test('signs the application pool exact cluster, OID, database, and caller challenge', async () => {
const fixture = protectedNonceFile();
const identity = {
cluster_id: '7612345678901234567',
database_oid: '24591',
database_name: 'monerogue_canary_direct_e2e'
};
const db = { query: jest.fn().mockResolvedValue({ rows: [identity] }) };
try {
const handler = createCanaryDatabaseIdentityHandler({
db, env: enabledEnv(fixture.fileName)
});
const challenge = '12'.repeat(32);
const response = responseRecorder();
await handler({
get: () => challenge,
socket: { remoteAddress: '::ffff:127.0.0.1' }
}, response);
expect(response.statusCode).toBe(200);
expect(response.body).toEqual(expect.objectContaining({
protocol: CANARY_HANDSHAKE_PROTOCOL,
challenge,
database: {
clusterId: identity.cluster_id,
databaseOid: identity.database_oid,
databaseName: identity.database_name
}
}));
const expected = computeProof(fixture.nonce, response.body.database, challenge);
expect(equalProof(response.body.proof, expected)).toBe(true);
expect(JSON.stringify(response.body)).not.toContain(fixture.nonce);
expect(db.query.mock.calls[0][0]).toContain('pg_control_system()');
} finally {
fs.rmSync(fixture.directory, { recursive: true, force: true });
}
});
test('rejects malformed challenges and refuses an application pool on another database', async () => {
const fixture = protectedNonceFile();
try {
const wrongDb = { query: jest.fn().mockResolvedValue({ rows: [{
cluster_id: '7612345678901234567',
database_oid: '24592',
database_name: 'monerogue_canary_credits_e2e'
}] }) };
const handler = createCanaryDatabaseIdentityHandler({
db: wrongDb, env: enabledEnv(fixture.fileName)
});
const malformed = responseRecorder();
const remote = responseRecorder();
await handler({
get: () => '34'.repeat(32), socket: { remoteAddress: '192.0.2.10' }
}, remote);
expect(remote.statusCode).toBe(404);
expect(wrongDb.query).not.toHaveBeenCalled();
await handler({
get: () => 'not-a-challenge', socket: { remoteAddress: '127.0.0.1' }
}, malformed);
expect(malformed.statusCode).toBe(400);
expect(wrongDb.query).not.toHaveBeenCalled();
const mismatch = responseRecorder();
await handler({
get: () => '34'.repeat(32), socket: { remoteAddress: '::1' }
}, mismatch);
expect(mismatch.statusCode).toBe(503);
expect(mismatch.body).toEqual({ error: 'Canary database identity mismatch.' });
} finally {
fs.rmSync(fixture.directory, { recursive: true, force: true });
}
});
test('requires one unambiguous row from PostgreSQL identity functions', async () => {
await expect(readDatabaseIdentity({
query: jest.fn().mockResolvedValue({ rows: [] })
})).rejects.toThrow(/ambiguous/);
await expect(readDatabaseIdentity({
query: jest.fn().mockResolvedValue({ rows: [{
cluster_id: '', database_oid: '1', database_name: 'db'
}] })
})).rejects.toThrow(/cluster identity/);
});
});
+234
View File
@@ -0,0 +1,234 @@
'use strict';
const {
CloneMigrationVerificationError,
MINIMUM_RELEASE_MIGRATION,
cloneMigrationConfirmation,
discoverMigrationManifest,
isDisposableCloneDatabaseName,
runCloneMigrations,
validateCloneMigrationEnvironment,
verifyExactMigrationLedger
} = require('../src/scripts/migrate-disposable-clone');
function safeEnvironment(overrides = {}) {
const database = overrides.DB_NAME || 'monerogue_restore_release_20260721';
return {
DB_HOST: '127.0.0.1',
DB_PORT: '5432',
DB_NAME: database,
DB_USER: 'monerogue_migrator',
CLONE_MIGRATION_EXPECT_DATABASE: database,
CLONE_MIGRATION_CONFIRM: cloneMigrationConfirmation(database),
...overrides
};
}
class FakePool {
constructor(config, { actualDatabase = config.database, hideLatest = false } = {}) {
this.config = config;
this.actualDatabase = actualDatabase;
this.hideLatest = hideLatest;
this.ledger = new Set();
this.transactions = [];
this.ended = false;
}
async connect() {
const pool = this;
let pending = null;
return {
async query(sql, params = []) {
if (/current_database\(\)/.test(sql)) {
return { rows: [{ database_name: pool.actualDatabase }], rowCount: 1 };
}
if (sql === 'BEGIN') {
pending = null;
return { rows: [], rowCount: null };
}
if (/INSERT INTO schema_migrations/.test(sql)) {
pending = params[0];
return { rows: [], rowCount: 1 };
}
if (sql === 'COMMIT') {
if (pending) pool.ledger.add(pending);
pool.transactions.push(pending);
pending = null;
return { rows: [], rowCount: null };
}
if (sql === 'ROLLBACK') {
pending = null;
return { rows: [], rowCount: null };
}
return { rows: [], rowCount: null };
},
release() {}
};
}
async query(sql, params = []) {
if (/CREATE TABLE IF NOT EXISTS schema_migrations/.test(sql)) {
return { rows: [], rowCount: null };
}
if (/SELECT 1 FROM schema_migrations/.test(sql)) {
return { rows: [], rowCount: this.ledger.has(params[0]) ? 1 : 0 };
}
if (/SELECT filename FROM schema_migrations/.test(sql)) {
let filenames = [...this.ledger].sort();
if (this.hideLatest) filenames = filenames.slice(0, -1);
return { rows: filenames.map(filename => ({ filename })), rowCount: filenames.length };
}
throw new Error(`unexpected fake pool query: ${sql}`);
}
async end() {
this.ended = true;
}
}
describe('disposable clone migration target guard', () => {
test.each([
'monerogue_clone_release_a1',
'wownerogue_restore_20260721',
'wowngeon_canary_direct_e2e'
])('accepts an explicitly disposable application database: %s', database => {
expect(isDisposableCloneDatabaseName(database)).toBe(true);
expect(validateCloneMigrationEnvironment(safeEnvironment({ DB_NAME: database }), []))
.toEqual(expect.objectContaining({ database, expectedDatabase: database }));
});
test.each([
'monerogue',
'wownerogue',
'wowngeon',
'monerogue_prod_clone',
'monerogue_clone_prd',
'monerogue_clone_production2026',
'monerogue_master_restore',
'wownerogue_clone_live',
'wownerogue_clone_live1',
'monerogue_mainnet_restore',
'postgres_restore_20260721',
'monerogue_staging_20260721',
'monerogue-clone-release'
])('refuses a live-looking, unscoped, or malformed name: %s', database => {
expect(isDisposableCloneDatabaseName(database)).toBe(false);
expect(() => validateCloneMigrationEnvironment(safeEnvironment({ DB_NAME: database }), []))
.toThrow(/clone, restore, or canary/);
});
test('requires an exact expected database, database-bound confirmation, and local host', () => {
expect(() => validateCloneMigrationEnvironment(safeEnvironment({
CLONE_MIGRATION_EXPECT_DATABASE: 'monerogue_restore_other'
}), [])).toThrow(/exactly equal/);
expect(() => validateCloneMigrationEnvironment(safeEnvironment({
CLONE_MIGRATION_CONFIRM: 'yes'
}), [])).toThrow(/exactly bind/);
expect(() => validateCloneMigrationEnvironment(safeEnvironment({
DB_HOST: 'database.internal'
}), [])).toThrow(/loopback/);
expect(() => validateCloneMigrationEnvironment(safeEnvironment({
DB_HOST: '/tmp/untrusted-postgresql-socket'
}), [])).toThrow(/loopback/);
expect(validateCloneMigrationEnvironment(safeEnvironment({
DB_HOST: '/var/run/postgresql'
}), [])).toEqual(expect.objectContaining({ host: '/var/run/postgresql' }));
});
test('forbids argv and credential-bearing DATABASE_URL before constructing a pool', async () => {
expect(() => validateCloneMigrationEnvironment(safeEnvironment(), ['--database', 'x']))
.toThrow(/command-line/);
expect(() => validateCloneMigrationEnvironment(safeEnvironment({
DATABASE_URL: 'postgres://user:secret@127.0.0.1/db'
}), [])).toThrow(/DATABASE_URL/);
const poolFactory = jest.fn();
await expect(runCloneMigrations({
env: safeEnvironment(), argv: ['--password', 'secret'], poolFactory
})).rejects.toThrow(/command-line/);
expect(poolFactory).not.toHaveBeenCalled();
});
});
describe('disposable clone migration execution', () => {
let logSpy;
beforeEach(() => {
logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(() => {
logSpy.mockRestore();
});
test('discovers the contiguous repository manifest through migration 40 dynamically', () => {
const manifest = discoverMigrationManifest();
expect(manifest.latestOrdinal).toBeGreaterThanOrEqual(MINIMUM_RELEASE_MIGRATION);
expect(manifest.latestFilename).toBe('040_paid_match_entropy_precommit.sql');
expect(manifest.files).toHaveLength(40);
});
test('runs the normal ordered migration implementation and proves the exact ledger', async () => {
const secret = 'do-not-print-this-database-password';
const env = safeEnvironment({ DB_PASSWORD: secret });
let pool;
const output = [];
const result = await runCloneMigrations({
env,
argv: [],
poolFactory: config => {
pool = new FakePool(config);
return pool;
},
logger: { info: line => output.push(line) }
});
expect(pool.transactions).toEqual(result.files);
expect(result.latestFilename).toBe('040_paid_match_entropy_precommit.sql');
expect(output).toContain('clone_migration_status=ok');
expect(output).toContain('latest_migration=040_paid_match_entropy_precommit.sql');
expect(output.join('\n')).not.toContain(secret);
expect(pool.config).toEqual(expect.objectContaining({
database: env.DB_NAME,
host: '127.0.0.1',
max: 1,
application_name: 'wowngeon-disposable-clone-migrations'
}));
expect(pool.ended).toBe(true);
});
test('checks server database identity before applying a migration', async () => {
let pool;
await expect(runCloneMigrations({
env: safeEnvironment(),
argv: [],
poolFactory: config => {
pool = new FakePool(config, { actualDatabase: 'monerogue_restore_wrong' });
return pool;
},
logger: { info: jest.fn() }
})).rejects.toThrow(/exact expected clone name/);
expect(pool.transactions).toEqual([]);
expect(pool.ended).toBe(true);
});
test('fails closed if the post-run ledger is not exactly the repository manifest', async () => {
await expect(runCloneMigrations({
env: safeEnvironment(),
argv: [],
poolFactory: config => new FakePool(config, { hideLatest: true }),
logger: { info: jest.fn() }
})).rejects.toBeInstanceOf(CloneMigrationVerificationError);
});
test('rejects missing, extra, or reordered ledger entries', () => {
const manifest = discoverMigrationManifest();
const rows = manifest.files.map(filename => ({ filename }));
expect(verifyExactMigrationLedger(rows, manifest)).toBe(true);
expect(() => verifyExactMigrationLedger(rows.slice(1), manifest)).toThrow(/count/);
expect(() => verifyExactMigrationLedger([...rows].reverse(), manifest)).toThrow(/differs/);
expect(() => verifyExactMigrationLedger([...rows, { filename: '999_unknown.sql' }], manifest))
.toThrow(/count/);
});
});
+100
View File
@@ -0,0 +1,100 @@
const STORAGE_KEY = 'wowngeon_paid_ack';
function policy(version, isTestNetwork = false) {
return {
policyVersion: version,
paidAcknowledgementRequired: true,
minimumAge: 18,
service: {
isTestNetwork,
currencyLabel: isTestNetwork ? 'sXMR' : 'WOW',
cryptoType: isTestNetwork ? 'XMR' : 'WOW'
},
links: { terms: '/terms', privacy: '/privacy', responsiblePlay: '/responsible-play' }
};
}
function response(value) {
return Promise.resolve({ ok: true, json: () => Promise.resolve(value) });
}
describe('browser commerce acknowledgement cache', () => {
let values;
beforeEach(() => {
jest.resetModules();
values = new Map();
global.sessionStorage = {
getItem: jest.fn(key => values.has(key) ? values.get(key) : null),
setItem: jest.fn((key, value) => values.set(key, value)),
removeItem: jest.fn(key => values.delete(key))
};
global.fetch = jest.fn();
delete global.CommerceConsent;
});
afterEach(() => {
delete global.fetch;
delete global.sessionStorage;
delete global.CommerceConsent;
});
test('accepts only the five-field canonical cache and clears extra data', async () => {
global.fetch.mockReturnValueOnce(response(policy('browser-v1')));
const consent = require('../html/js/legal/commerceConsent');
await consent.loadPolicy();
values.set(STORAGE_KEY, JSON.stringify({
policyVersion: 'browser-v1',
ageEligible: true,
termsRead: true,
riskAccepted: true,
testnetUnderstood: false,
acknowledgedAt: 'must-not-be-stored'
}));
expect(consent.acknowledgement()).toBeNull();
expect(values.has(STORAGE_KEY)).toBe(false);
});
test('server version rejection clears acceptance and refetches current policy', async () => {
global.fetch
.mockReturnValueOnce(response(policy('browser-v1')))
.mockReturnValueOnce(response(policy('browser-v2')));
const consent = require('../html/js/legal/commerceConsent');
await consent.loadPolicy();
values.set(STORAGE_KEY, JSON.stringify({
policyVersion: 'browser-v1',
ageEligible: true,
termsRead: true,
riskAccepted: true,
testnetUnderstood: false
}));
expect(consent.acknowledgement()).toEqual(expect.objectContaining({ policyVersion: 'browser-v1' }));
const refreshed = await consent.reject({ code: 'PAID_ACK_VERSION', policyVersion: 'browser-v2' });
expect(refreshed.policyVersion).toBe('browser-v2');
expect(global.fetch).toHaveBeenCalledTimes(2);
expect(consent.acknowledgement()).toBeNull();
expect(values.has(STORAGE_KEY)).toBe(false);
});
test('disconnect-facing clear removes both memory and session copies', async () => {
global.fetch.mockReturnValueOnce(response(policy('browser-v1')));
const consent = require('../html/js/legal/commerceConsent');
await consent.loadPolicy();
values.set(STORAGE_KEY, JSON.stringify({
policyVersion: 'browser-v1',
ageEligible: true,
termsRead: true,
riskAccepted: true,
testnetUnderstood: false
}));
expect(consent.acknowledgement()).not.toBeNull();
consent.clear();
expect(consent.acknowledgement()).toBeNull();
expect(values.has(STORAGE_KEY)).toBe(false);
});
});
+216
View File
@@ -0,0 +1,216 @@
const {
buildCommerceDisclosure,
safeContactUrl,
validatePaidAcknowledgement
} = require('../src/config/commerceDisclosurePolicy');
const PaymentHandlers = require('../src/network/paymentHandlers');
function manager(overrides = {}) {
return {
cryptoType: 'XMR',
currencyLabel: 'sXMR',
network: 'stagenet',
isTestNetwork: true,
gameName: 'Monerogue',
paymentsEnabled: true,
freePlayEnabled: true,
directModeEnabled: true,
creditsModeEnabled: true,
payoutsEnabled: true,
isPayoutEnabledForMode: mode => mode === 'PAID_SINGLE',
...overrides
};
}
function env(overrides = {}) {
return {
NODE_ENV: 'production',
LEGAL_POLICY_VERSION: '2026-07-21-v1',
TERMS_EFFECTIVE_DATE: '2026-07-21',
MINIMUM_AGE: '21',
PAID_ACKNOWLEDGEMENT_REQUIRED: 'true',
OPERATOR_NAME: 'Test Operator',
OPERATOR_CONTACT_URL: 'mailto:support@example.invalid',
MATCH_ENABLED: 'false',
...overrides
};
}
describe('commerce disclosure and paid-action policy', () => {
test('describes active test-network payout mode without conflating it with mainnet', () => {
const disclosure = buildCommerceDisclosure(manager(), env());
expect(disclosure).toEqual(expect.objectContaining({
policyVersion: '2026-07-21-v1',
minimumAge: 21,
paidAcknowledgementRequired: true
}));
expect(disclosure.service).toEqual(expect.objectContaining({
network: 'stagenet',
isTestNetwork: true,
paidPrestigeOnly: false,
soloPayoutsEnabled: true
}));
});
test('describes paid prestige separately when every payout path is off', () => {
const disclosure = buildCommerceDisclosure(manager({
cryptoType: 'WOW', currencyLabel: 'WOW', network: 'mainnet', isTestNetwork: false,
payoutsEnabled: false, isPayoutEnabledForMode: () => false
}), env());
expect(disclosure.service.paidPrestigeOnly).toBe(true);
expect(disclosure.service.anyPayoutsEnabled).toBe(false);
});
test('does not advertise a stale solo payout flag when that paid mode is disabled', () => {
const disclosure = buildCommerceDisclosure(manager({
directModeEnabled: false,
creditsModeEnabled: true,
isPayoutEnabledForMode: mode => mode === 'PAID_SINGLE'
}), env());
expect(disclosure.service.directPaidEntryEnabled).toBe(false);
expect(disclosure.service.paidCreditsEnabled).toBe(true);
expect(disclosure.service.soloPayoutsEnabled).toBe(false);
const activeCreditsPayout = buildCommerceDisclosure(manager({
directModeEnabled: false,
creditsModeEnabled: true,
isPayoutEnabledForMode: mode => mode === 'PAID_CREDITS'
}), env());
expect(activeCreditsPayout.service.soloPayoutsEnabled).toBe(true);
const noPaidModes = buildCommerceDisclosure(manager({
directModeEnabled: false,
creditsModeEnabled: false,
isPayoutEnabledForMode: () => true
}), env());
expect(noPaidModes.service.soloPayoutsEnabled).toBe(false);
expect(noPaidModes.service.anyPayoutsEnabled).toBe(false);
});
test('fails closed on missing, stale, incomplete, or testnet-unaware acknowledgement', () => {
const disclosure = buildCommerceDisclosure(manager(), env());
const good = {
policyVersion: disclosure.policyVersion,
ageEligible: true,
termsRead: true,
riskAccepted: true,
testnetUnderstood: true
};
expect(validatePaidAcknowledgement(null, disclosure).ok).toBe(false);
expect(validatePaidAcknowledgement({ ...good, policyVersion: 'old' }, disclosure).code).toBe('PAID_ACK_VERSION');
expect(validatePaidAcknowledgement({ ...good, riskAccepted: false }, disclosure).code).toBe('PAID_ACK_INCOMPLETE');
expect(validatePaidAcknowledgement({ ...good, testnetUnderstood: false }, disclosure).code).toBe('PAID_ACK_TESTNET');
expect(validatePaidAcknowledgement({ ...good, extra: true }, disclosure).code).toBe('PAID_ACK_MALFORMED');
expect(validatePaidAcknowledgement({
policyVersion: disclosure.policyVersion,
ageEligible: true,
termsRead: true,
riskAccepted: true
}, disclosure).code).toBe('PAID_ACK_MALFORMED');
expect(validatePaidAcknowledgement(good, disclosure)).toEqual({
ok: true,
code: 'ACKNOWLEDGED',
acknowledgement: Object.freeze(good)
});
});
test('mainnet acknowledgement is canonical and carries an explicit false testnet flag', () => {
const disclosure = buildCommerceDisclosure(manager({
cryptoType: 'WOW',
currencyLabel: 'WOW',
network: 'mainnet',
isTestNetwork: false
}), env());
const good = {
policyVersion: disclosure.policyVersion,
ageEligible: true,
termsRead: true,
riskAccepted: true,
testnetUnderstood: false
};
expect(validatePaidAcknowledgement(good, disclosure)).toEqual({
ok: true,
code: 'ACKNOWLEDGED',
acknowledgement: Object.freeze(good)
});
expect(validatePaidAcknowledgement({ ...good, testnetUnderstood: true }, disclosure).code)
.toBe('PAID_ACK_MALFORMED');
});
test('keeps acknowledgement UI enabled for paid match entitlements while invoice intake is off', () => {
const disclosure = buildCommerceDisclosure(manager({
paymentsEnabled: false,
directModeEnabled: false,
creditsModeEnabled: false,
freePlayEnabled: true,
_getMatchEconomies: () => ({ free: true, credits_prestige: true })
}), env({ PAID_ACKNOWLEDGEMENT_REQUIRED: 'false', MATCH_ENABLED: 'true' }));
expect(disclosure.service.paymentsEnabled).toBe(false);
expect(disclosure.paidAcknowledgementRequired).toBe(true);
});
test('allows no acknowledgement only where the policy is actually disabled', () => {
const disclosure = buildCommerceDisclosure(manager({
paymentsEnabled: false,
directModeEnabled: false,
creditsModeEnabled: false,
freePlayEnabled: false
}), {
NODE_ENV: 'test', PAID_ACKNOWLEDGEMENT_REQUIRED: 'false'
});
expect(validatePaidAcknowledgement(null, disclosure)).toEqual({
ok: true, code: 'NOT_REQUIRED', acknowledgement: null
});
});
test('contact URLs reject executable and unrelated schemes', () => {
expect(safeContactUrl('javascript:alert(1)')).toBeNull();
expect(safeContactUrl('data:text/html,boom')).toBeNull();
expect(safeContactUrl('mailto:support@example.invalid')).toBe('mailto:support@example.invalid');
expect(safeContactUrl('https://example.invalid/support')).toBe('https://example.invalid/support');
});
test('payment handler refuses an invoice before wallet or database work when acknowledgement is absent', async () => {
const previousNodeEnv = process.env.NODE_ENV;
const previousVersion = process.env.LEGAL_POLICY_VERSION;
process.env.NODE_ENV = 'production';
process.env.LEGAL_POLICY_VERSION = 'server-enforcement-v1';
const emit = jest.fn();
const createPaymentRequest = jest.fn();
const handler = new PaymentHandlers({
io: { to: () => ({ emit }) },
gameModeManager: {
...manager({ isTestNetwork: false, network: 'mainnet' }),
createPaymentRequest,
isPayoutEnabledForMode: () => false
},
walletService: {},
debugManager: { CONSOLE_LOGGING: false },
queueManager: {},
broadcastManager: { sendStatusUpdate: jest.fn() },
sessionManager: null
});
try {
await handler.handlePaymentRequest({ id: 'socket-1' }, { type: 'single_game' });
expect(createPaymentRequest).not.toHaveBeenCalled();
expect(emit).toHaveBeenCalledWith('commerce_ack_required', expect.objectContaining({
code: 'PAID_ACK_REQUIRED', policyVersion: 'server-enforcement-v1'
}));
expect(emit).toHaveBeenCalledWith('payment_error', expect.objectContaining({
code: 'PAID_ACK_REQUIRED'
}));
} finally {
handler.dispose();
if (previousNodeEnv === undefined) delete process.env.NODE_ENV;
else process.env.NODE_ENV = previousNodeEnv;
if (previousVersion === undefined) delete process.env.LEGAL_POLICY_VERSION;
else process.env.LEGAL_POLICY_VERSION = previousVersion;
}
});
});
+15
View File
@@ -0,0 +1,15 @@
const DatabaseManager = require('../src/db/databaseManager');
describe('DatabaseManager readiness freshness', () => {
test('requires both a connection and a recent successful query', () => {
const db = new DatabaseManager();
expect(db.isHealthy()).toBe(false);
db.connected = true;
db.lastSuccessfulQueryAt = Date.now();
expect(db.isHealthy(1000)).toBe(true);
db.lastSuccessfulQueryAt = Date.now() - 2000;
expect(db.isHealthy(1000)).toBe(false);
});
});
+32
View File
@@ -0,0 +1,32 @@
const DebugManager = require('../src/debug/debugManager');
describe('DebugManager daemon freshness', () => {
const originalEnv = process.env.NODE_ENV;
const originalSource = process.env.BLOCK_SOURCE;
beforeEach(() => {
process.env.NODE_ENV = 'production';
process.env.BLOCK_SOURCE = 'daemon';
});
afterAll(() => {
if (originalEnv === undefined) delete process.env.NODE_ENV;
else process.env.NODE_ENV = originalEnv;
if (originalSource === undefined) delete process.env.BLOCK_SOURCE;
else process.env.BLOCK_SOURCE = originalSource;
});
test('rejects a stale cached height until a recent RPC poll succeeds', () => {
const manager = new DebugManager({ broadcastBlockHeight: jest.fn() });
manager.lastProductionBlockHeight = 123;
manager.lastSuccessfulRpcAt = 0;
expect(manager.isChainHealthy()).toBe(false);
manager.lastSuccessfulRpcAt = Date.now();
expect(manager.isChainHealthy(1000)).toBe(true);
manager.lastSuccessfulRpcAt = Date.now() - 2000;
expect(manager.isChainHealthy(1000)).toBe(false);
});
});
+31
View File
@@ -0,0 +1,31 @@
const { DigestAuthClient, parseDigestChallenge } = require('../src/payments/digestAuth');
describe('wallet RPC Digest authentication', () => {
test('parses quoted qop lists and required fields', () => {
expect(parseDigestChallenge('Digest realm="wallet", nonce="n1", qop="auth,auth-int", algorithm=MD5'))
.toMatchObject({ realm: 'wallet', nonce: 'n1', qop: 'auth,auth-int', algorithm: 'MD5' });
});
test('caches the challenge and increments nonce-count without another unauthenticated probe', async () => {
const unauthorized = new Error('unauthorized');
unauthorized.response = {
status: 401,
headers: { 'www-authenticate': 'Digest realm="wallet", nonce="nonce", qop="auth"' }
};
const http = { post: jest.fn()
.mockRejectedValueOnce(unauthorized)
.mockResolvedValue({ data: { result: {} } }) };
const client = new DigestAuthClient(http, {
username: 'user',
password: 'pass',
randomBytes: () => Buffer.alloc(16, 1)
});
await client.post('http://127.0.0.1:38083/json_rpc', { id: '1' });
await client.post('http://127.0.0.1:38083/json_rpc', { id: '2' });
expect(http.post).toHaveBeenCalledTimes(3);
expect(http.post.mock.calls[1][2].headers.Authorization).toContain('nc=00000001');
expect(http.post.mock.calls[2][2].headers.Authorization).toContain('nc=00000002');
});
});
+96
View File
@@ -0,0 +1,96 @@
const fs = require('fs');
const path = require('path');
describe('database economic identity immutability', () => {
const readMigration = name => fs.readFileSync(
path.join(__dirname, `../src/migrations/${name}`), 'utf8'
);
test('committed solo owner is frozen on and after commitment', () => {
const initial = readMigration('032_solo_liability_invariants.sql');
const forward = readMigration('038_economic_identity_immutability.sql');
for (const sql of [initial, forward]) {
expect(sql).toMatch(/NEW\.user_id IS DISTINCT FROM OLD\.user_id/i);
expect(sql).toMatch(/OLD\.payout_committed_at IS NULL[\s\S]*NEW\.payout_committed_at IS NOT NULL[\s\S]*NEW\.user_id IS DISTINCT FROM OLD\.user_id/i);
}
});
test('receipt-backed or final invoice economics, destination, product, and provider are frozen', () => {
const forward = readMigration('038_economic_identity_immutability.sql');
for (const field of [
'user_id', 'payment_type', 'expected_amount', 'subaddress', 'address_index',
'provider_id', 'provider_invoice_id', 'payment_mode', 'credit_package_id',
'product_id', 'product_grants', 'description', 'expires_at'
]) {
expect(forward).toMatch(new RegExp(`NEW\\.${field} IS DISTINCT FROM OLD\\.${field}`, 'i'));
}
expect(forward).toMatch(/OLD\.status IN \('confirmed', 'refunded', 'expired'\)/i);
expect(forward).toMatch(/EXISTS \(SELECT 1 FROM payment_receipts r WHERE r\.payment_id = OLD\.id\)/i);
expect(forward).toMatch(/OLD\.fairness_bound_at IS NULL AND NEW\.fairness_bound_at IS NOT NULL/i);
});
test('confirmed-payment refunds change operational status without rewriting immutable description', () => {
const refundService = fs.readFileSync(
path.join(__dirname, '../src/services/paymentRefundService.js'), 'utf8'
);
const update = refundService.match(/UPDATE payments[\s\S]*?WHERE id = \$1/gi) || [];
expect(update).toHaveLength(1);
expect(update[0]).toMatch(/SET status = 'refunded'/i);
expect(update[0]).not.toMatch(/description/i);
});
test('payment receipt authorization evidence is append-only', () => {
const forward = readMigration('038_economic_identity_immutability.sql');
expect(forward).toMatch(/CREATE TRIGGER trg_payment_receipts_append_only/i);
expect(forward).toMatch(/BEFORE UPDATE OR DELETE ON payment_receipts/i);
expect(forward).toMatch(/payment receipt evidence is append-only/i);
});
test('payout economics are immutable with only the exact saved-address claim exception', () => {
const sql = readMigration('038_economic_identity_immutability.sql');
for (const field of ['user_id', 'game_id', 'match_id', 'amount', 'multiplier', 'reason']) {
expect(sql).toMatch(new RegExp(`NEW\\.${field} IS DISTINCT FROM OLD\\.${field}`, 'i'));
}
expect(sql).toMatch(/OLD\.payout_address = 'PENDING_NO_ADDRESS'/i);
expect(sql).toMatch(/OLD\.status = 'needs_review'/i);
expect(sql).toMatch(/NEW\.status = 'pending'/i);
expect(sql).toMatch(/OLD\.tx_hash IS NULL[\s\S]*NEW\.tx_hash IS NULL/i);
expect(sql).toMatch(/u\.id = OLD\.user_id[\s\S]*u\.payout_address = NEW\.payout_address/i);
expect(sql).toMatch(/CREATE TRIGGER trg_payouts_immutable_obligation/i);
});
test('unresolved solo-win obligations are recordable but excluded from automatic address claim', () => {
const initial = readMigration('038_economic_identity_immutability.sql');
const forward = readMigration('039_unresolved_solo_payout_review.sql');
const runtime = fs.readFileSync(
path.join(__dirname, '../src/game/gameModeManager.js'), 'utf8'
);
expect(runtime).toContain("'solo_winner_identity_review'");
expect(forward).toContain("'solo_winner_identity_review'");
expect(forward).toMatch(/status = 'needs_review'[\s\S]*tx_hash IS NULL/i);
expect(initial).toMatch(
/OLD\.reason IN \('match_winner_no_address', 'solo_winner_no_address'\)/i
);
expect(initial).not.toMatch(
/OLD\.reason IN \([^)]*solo_winner_identity_review/i
);
});
test('paid entropy precommit witness is shaped, immutable, and locks active fairness identity', () => {
const sql = readMigration('040_paid_match_entropy_precommit.sql');
expect(sql).toMatch(/ADD COLUMN IF NOT EXISTS entropy_precommit_tip_height BIGINT/i);
expect(sql).toMatch(/ADD COLUMN IF NOT EXISTS entropy_precommit_verified_at TIMESTAMPTZ/i);
expect(sql).toMatch(/entropy_precommit_tip_height < start_block_height/i);
expect(sql).toMatch(/NEW\.entropy_precommit_tip_height IS DISTINCT FROM OLD\.entropy_precommit_tip_height/i);
expect(sql).toMatch(/NEW\.entropy_precommit_verified_at IS DISTINCT FROM OLD\.entropy_precommit_verified_at/i);
expect(sql).toMatch(/NOT \(OLD\.status = 'pending' AND NEW\.status = 'starting'\)/i);
for (const field of [
'economy', 'variant', 'ruleset_id', 'difficulty_preset', 'max_players',
'seed_hash', 'dungeon', 'start_block_height'
]) {
expect(sql).toMatch(new RegExp(`NEW\\.${field} IS DISTINCT FROM OLD\\.${field}`, 'i'));
}
expect(sql).toMatch(/CREATE TRIGGER trg_matches_immutable_entropy_precommit/i);
});
});
+548
View File
@@ -0,0 +1,548 @@
const EnvironmentValidator = require('../src/config/environmentValidator');
function config(overrides = {}) {
const base = {
paymentsEnabled: true,
currency: { symbol: 'XMR' },
modes: {
direct: { enabled: true, price: 10000n },
credits: {
enabled: true,
packages: [{ id: 'small', credits: 10, price: 90000000000n }]
}
},
payouts: {
enabled: false,
rules: {
direct: {
enabled: false,
multipliers: { escape: 2, escapeWithTreasure: 3 },
minPayout: 1n,
maxPayout: 50000n
},
credits: {
enabled: false,
multipliers: { escape: 2, escapeWithTreasure: 3 },
baseValue: 10000n,
minPayout: 1n,
maxPayout: 50000n
}
}
}
};
return {
...base,
...overrides,
modes: {
...base.modes,
...(overrides.modes || {})
},
payouts: {
...base.payouts,
...(overrides.payouts || {}),
rules: {
direct: {
...base.payouts.rules.direct,
...(overrides.payouts?.rules?.direct || {})
},
credits: {
...base.payouts.rules.credits,
...(overrides.payouts?.rules?.credits || {})
}
}
}
};
}
function productionEnv(overrides = {}) {
return {
NODE_ENV: 'production',
CRYPTO_TYPE: 'XMR',
MONERO_NETWORK: 'stagenet',
PAYMENTS_ENABLED: 'true',
PAYOUTS_ENABLED: 'false',
DIRECT_PAYOUTS_ENABLED: 'false',
CREDITS_PAYOUTS_ENABLED: 'false',
PRIMARY_WALLET_ENDPOINT: 'http://127.0.0.1:38083',
WALLET_RPC_USER: 'monerogue',
WALLET_RPC_PASSWORD: 'wallet-rpc-correct-horse-2026',
PRIMARY_RPC_ENDPOINT: 'http://127.0.0.1:38081',
DB_PASSWORD: 'correct-horse-battery-staple-2026',
ADMIN_API_KEY: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
OPERATOR_NAME: 'Test Operator LLC',
OPERATOR_CONTACT_URL: 'mailto:support@operator.invalid',
LEGAL_POLICY_VERSION: '2026-07-21-v1',
TERMS_EFFECTIVE_DATE: '2026-07-21',
MINIMUM_AGE: '18',
PAID_ACKNOWLEDGEMENT_REQUIRED: 'true',
BLOCK_SOURCE: 'daemon',
TRUST_PROXY: 'true',
MATCH_PAID_ENTROPY_DELAY_BLOCKS: '2',
MATCH_PAID_ENTROPY_CONFIRMATIONS: '2',
PAYOUT_MAX_PER_GAME: '50000',
BALANCE_CRITICAL: '50000',
BALANCE_WARN: '100000',
CREDITS_PACKAGES: '[{"id":"small","credits":10,"price":"90000000000"}]',
...overrides
};
}
const silentLogger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn()
};
describe('EnvironmentValidator production launch gate', () => {
beforeEach(() => jest.clearAllMocks());
test('accepts explicit paid-credits plus free-play style config with payouts disabled', () => {
const validator = new EnvironmentValidator({ env: productionEnv(), logger: silentLogger });
const result = validator.assertValid(config());
expect(result.errors).toEqual([]);
expect(result.money).toEqual(expect.objectContaining({
paymentsEnabled: true,
payoutsEnabled: false
}));
});
test('accepts explicitly capped stagenet crypto match payouts', () => {
const env = productionEnv({
PAYOUTS_ENABLED: 'true',
MATCH_ENABLED: 'true',
MATCH_CRYPTO_RACE_ENABLED: 'true',
MATCH_PAYOUTS_ENABLED: 'true',
MATCH_PAYOUT_MAX: '50000',
MATCH_ENTRY_FEE_ATOMIC: '10000',
MATCH_HOUSE_FEE_PERCENT: '5',
MATCH_RULESET_ID: 'race',
MATCH_MAX_PLAYERS: '4'
});
const result = new EnvironmentValidator({ env, logger: silentLogger })
.assertValid(config({
payouts: { enabled: true },
products: {
cosmetic: [{
id: 'race_2',
price: 20000n,
grants: { race_entries: 2, race_entry_value_atomic: '10000' }
}]
}
}));
expect(result.money.matchPayoutsEnabled).toBe(true);
});
test.each([
['MATCH_PAID_ENTROPY_DELAY_BLOCKS', '1'],
['MATCH_PAID_ENTROPY_CONFIRMATIONS', '1'],
['MATCH_PAID_ENTROPY_DELAY_BLOCKS', '2.5'],
['MATCH_PAID_ENTROPY_CONFIRMATIONS', '9007199254740992'],
['MATCH_PAID_ENTROPY_DELAY_BLOCKS', '101']
])('rejects unsafe production paid entropy setting %s=%s', (key, value) => {
const result = new EnvironmentValidator({
env: productionEnv({ MATCH_ENABLED: 'true', [key]: value }),
logger: silentLogger
}).validate(config());
expect(result.errors).toContain(
`${key} must be an explicit safe integer from 2 through 100 when production match mode is enabled.`
);
});
test('rejects crypto co-op and a cap below the configured maximum pot liability', () => {
const env = productionEnv({
PAYOUTS_ENABLED: 'true',
MATCH_ENABLED: 'true',
MATCH_CRYPTO_RACE_ENABLED: 'true',
MATCH_PAYOUTS_ENABLED: 'true',
MATCH_PAYOUT_MAX: '100',
MATCH_ENTRY_FEE_ATOMIC: '10000',
MATCH_HOUSE_FEE_PERCENT: '5',
MATCH_RULESET_ID: 'coop-escape',
MATCH_MAX_PLAYERS: '4'
});
const result = new EnvironmentValidator({ env, logger: silentLogger })
.validate(config({ payouts: { enabled: true } }));
expect(result.errors).toEqual(expect.arrayContaining([
expect.stringContaining('single-winner crypto payout semantics'),
expect.stringContaining('below the configured 4-player winner liability')
]));
});
test('rejects mismatched or underfunded race-ticket products', () => {
const env = productionEnv({
PAYOUTS_ENABLED: 'true',
MATCH_ENABLED: 'true',
MATCH_CRYPTO_RACE_ENABLED: 'true',
MATCH_PAYOUTS_ENABLED: 'true',
MATCH_PAYOUT_MAX: '50000',
MATCH_ENTRY_FEE_ATOMIC: '10000',
MATCH_HOUSE_FEE_PERCENT: '5',
MATCH_RULESET_ID: 'race',
MATCH_MAX_PLAYERS: '4'
});
const result = new EnvironmentValidator({ env, logger: silentLogger }).validate(config({
payouts: { enabled: true },
products: {
cosmetic: [
{
id: 'wrong-value',
price: 20000n,
grants: { race_entries: 2, race_entry_value_atomic: '9000' }
},
{
id: 'underfunded',
price: 19999n,
grants: { race_entries: 2, race_entry_value_atomic: '10000' }
}
]
}
}));
expect(result.errors).toEqual(expect.arrayContaining([
expect.stringContaining('wrong-value backing must equal MATCH_ENTRY_FEE_ATOMIC'),
expect.stringContaining('underfunded price must fund at least 20000')
]));
});
test('rejects a 100% or over-precision match house fee', () => {
for (const value of ['100', '5.555']) {
const result = new EnvironmentValidator({
env: productionEnv({ MATCH_HOUSE_FEE_PERCENT: value }),
logger: silentLogger
}).validate(config());
expect(result.errors).toContain(
'MATCH_HOUSE_FEE_PERCENT must be at least 0 and less than 100.'
);
}
});
test('missing production secrets and RPC endpoints are fatal', () => {
const env = productionEnv({
ADMIN_API_KEY: 'change-me',
DB_PASSWORD: '',
PRIMARY_WALLET_ENDPOINT: '',
PRIMARY_RPC_ENDPOINT: ''
});
const result = new EnvironmentValidator({ env, logger: silentLogger }).validate(config());
expect(result.errors).toEqual(expect.arrayContaining([
expect.stringContaining('PRIMARY_WALLET_ENDPOINT'),
expect.stringContaining('PRIMARY_RPC_ENDPOINT'),
expect.stringContaining('ADMIN_API_KEY'),
expect.stringContaining('DB_PASSWORD')
]));
});
test('wallet RPC authentication requires a complete credential pair', () => {
const result = new EnvironmentValidator({
env: productionEnv({ WALLET_RPC_USER: 'rpc-user', WALLET_RPC_PASSWORD: '' }),
logger: silentLogger
}).validate(config());
expect(result.errors).toContain('WALLET_RPC_USER and WALLET_RPC_PASSWORD must be set together.');
});
test('production payments reject unauthenticated or remotely exposed wallet RPC', () => {
const missing = productionEnv({ WALLET_RPC_USER: '', WALLET_RPC_PASSWORD: '' });
const missingResult = new EnvironmentValidator({ env: missing, logger: silentLogger }).validate(config());
expect(missingResult.errors).toContain(
'Production financial workers require WALLET_RPC_USER and WALLET_RPC_PASSWORD for wallet-rpc Digest authentication.'
);
const remote = productionEnv({ PRIMARY_WALLET_ENDPOINT: 'http://10.42.1.20:38083' });
const remoteResult = new EnvironmentValidator({ env: remote, logger: silentLogger }).validate(config());
expect(remoteResult.errors).toContain(
'Production wallet-rpc over plaintext HTTP must bind to a loopback address.'
);
});
test('payout-only recovery still requires an authenticated loopback wallet', () => {
const payoutOnlyConfig = config({
paymentsEnabled: false,
modes: {
direct: { enabled: false },
credits: { enabled: false, packages: [] }
},
payouts: { enabled: true }
});
const env = productionEnv({
PAYMENTS_ENABLED: 'false',
PAYOUTS_ENABLED: 'true',
DIRECT_PAYMENT_ENABLED: 'false',
CREDITS_ENABLED: 'false',
MONERO_NETWORK: 'mainnet',
WALLET_RPC_USER: '',
WALLET_RPC_PASSWORD: '',
PRIMARY_WALLET_ENDPOINT: 'http://10.42.1.20:38083'
});
const errors = new EnvironmentValidator({ env, logger: silentLogger })
.validate(payoutOnlyConfig).errors;
expect(errors).toEqual(expect.arrayContaining([
expect.stringContaining('Production financial workers require WALLET_RPC_USER'),
expect.stringContaining('plaintext HTTP must bind to a loopback'),
expect.stringContaining('ALLOW_MAINNET_PAYOUTS=true')
]));
env.PRIMARY_WALLET_ENDPOINT = '';
expect(new EnvironmentValidator({ env, logger: silentLogger })
.validate(payoutOnlyConfig).errors)
.toEqual(expect.arrayContaining([
expect.stringContaining('Financial workers require a wallet'),
expect.stringContaining('PRIMARY_WALLET_ENDPOINT')
]));
});
test('payout-only recovery cannot bypass the mainnet acknowledgement', () => {
const payoutOnlyConfig = config({
paymentsEnabled: false,
modes: {
direct: { enabled: false },
credits: { enabled: false, packages: [] }
},
payouts: { enabled: true }
});
const env = productionEnv({
PAYMENTS_ENABLED: 'false',
PAYOUTS_ENABLED: 'true',
DIRECT_PAYMENT_ENABLED: 'false',
CREDITS_ENABLED: 'false',
MONERO_NETWORK: 'mainnet'
});
const validator = new EnvironmentValidator({ env, logger: silentLogger });
expect(validator.validate(payoutOnlyConfig).errors).toContain(
'Mainnet payouts require the explicit safety acknowledgement ALLOW_MAINNET_PAYOUTS=true.'
);
env.ALLOW_MAINNET_PAYOUTS = 'true';
expect(validator.validate(payoutOnlyConfig).errors).toEqual([]);
});
test('production requires explicit payout intent instead of dangerous defaults', () => {
const env = productionEnv();
delete env.PAYOUTS_ENABLED;
delete env.DIRECT_PAYOUTS_ENABLED;
delete env.CREDITS_PAYOUTS_ENABLED;
const result = new EnvironmentValidator({ env, logger: silentLogger }).validate(config());
expect(result.errors).toEqual(expect.arrayContaining([
expect.stringContaining('explicit PAYOUTS_ENABLED'),
expect.stringContaining('explicit DIRECT_PAYOUTS_ENABLED'),
expect.stringContaining('explicit CREDITS_PAYOUTS_ENABLED')
]));
});
test('production paid play requires truthful operator metadata and acknowledgement policy', () => {
const result = new EnvironmentValidator({
env: productionEnv({
OPERATOR_NAME: 'CHANGE_ME',
OPERATOR_CONTACT_URL: 'javascript:alert(1)',
LEGAL_POLICY_VERSION: '',
TERMS_EFFECTIVE_DATE: '2026-02-30',
MINIMUM_AGE: '0',
PAID_ACKNOWLEDGEMENT_REQUIRED: 'false'
}),
logger: silentLogger
}).validate(config());
expect(result.errors).toEqual(expect.arrayContaining([
expect.stringContaining('PAID_ACKNOWLEDGEMENT_REQUIRED=true'),
expect.stringContaining('LEGAL_POLICY_VERSION'),
expect.stringContaining('TERMS_EFFECTIVE_DATE'),
expect.stringContaining('MINIMUM_AGE'),
expect.stringContaining('OPERATOR_NAME'),
expect.stringContaining('OPERATOR_CONTACT_URL')
]));
});
test('rejects misspelled booleans and legacy modes instead of using defaults', () => {
const env = productionEnv({
GAME_MODE: 'FRE',
PAYOUTS_ENABLED: 'flase',
DIRECT_PAYOUTS_ENABLED: 'treu'
});
const result = new EnvironmentValidator({ env, logger: silentLogger }).validate(config());
expect(result.errors).toEqual(expect.arrayContaining([
expect.stringContaining('GAME_MODE must be'),
expect.stringContaining('PAYOUTS_ENABLED must be an explicit boolean'),
expect.stringContaining('DIRECT_PAYOUTS_ENABLED must be an explicit boolean')
]));
});
test('rejects malformed atomic amounts and payout multipliers', () => {
const env = productionEnv({
DIRECT_GAME_PRICE: '0.01 XMR',
PAYOUT_MAX_PER_GAME: '-1',
DIRECT_PAYOUT_ESCAPE: 'double'
});
const result = new EnvironmentValidator({ env, logger: silentLogger }).validate(config());
expect(result.errors).toEqual(expect.arrayContaining([
expect.stringContaining('DIRECT_GAME_PRICE must be a non-negative integer'),
expect.stringContaining('PAYOUT_MAX_PER_GAME must be a non-negative integer'),
expect.stringContaining('DIRECT_PAYOUT_ESCAPE must be a finite number')
]));
});
test('rejects unknown payment modes instead of silently disabling them', () => {
const result = new EnvironmentValidator({
env: productionEnv({ PAYMENT_MODES: 'direct,magic' }),
logger: silentLogger
}).validate(config());
expect(result.errors).toContain('PAYMENT_MODES must contain only direct and/or credits.');
});
test('rejects an invalid trusted-proxy hop count', () => {
const result = new EnvironmentValidator({
env: productionEnv({ TRUST_PROXY_HOPS: 'all' }),
logger: silentLogger
}).validate(config());
expect(result.errors).toContain('TRUST_PROXY_HOPS must be an integer from 1 through 8.');
});
test('mainnet payout dispatch needs a deliberate acknowledgement', () => {
const payoutConfig = config({
payouts: {
enabled: true,
rules: { direct: { enabled: true }, credits: { enabled: true } }
}
});
const env = productionEnv({
MONERO_NETWORK: 'mainnet',
PAYOUTS_ENABLED: 'true',
DIRECT_PAYOUTS_ENABLED: 'true',
CREDITS_PAYOUTS_ENABLED: 'true'
});
const validator = new EnvironmentValidator({ env, logger: silentLogger });
expect(validator.validate(payoutConfig).errors)
.toContain('Mainnet payouts require the explicit safety acknowledgement ALLOW_MAINNET_PAYOUTS=true.');
env.ALLOW_MAINNET_PAYOUTS = 'true';
expect(validator.validate(payoutConfig).errors).toEqual([]);
});
test('stagenet 2x/3x payouts do not require the mainnet acknowledgement', () => {
const payoutConfig = config({
payouts: {
enabled: true,
rules: { direct: { enabled: true }, credits: { enabled: true } }
}
});
const env = productionEnv({
PAYOUTS_ENABLED: 'true',
DIRECT_PAYOUTS_ENABLED: 'true',
CREDITS_PAYOUTS_ENABLED: 'true'
});
const result = new EnvironmentValidator({ env, logger: silentLogger }).validate(payoutConfig);
expect(result.errors).toEqual([]);
expect(result.money.payoutsEnabled).toBe(true);
});
test('rejects direct 3x above cap and credits 2x below minimum', () => {
const env = productionEnv({
PAYOUTS_ENABLED: 'true',
DIRECT_PAYOUTS_ENABLED: 'true',
CREDITS_PAYOUTS_ENABLED: 'true'
});
const payoutConfig = config({
modes: { direct: { enabled: true, price: 20000n } },
payouts: {
enabled: true,
rules: {
direct: { enabled: true, maxPayout: 50000n },
credits: { enabled: true, baseValue: 1000n, minPayout: 3000n }
}
}
});
const result = new EnvironmentValidator({ env, logger: silentLogger }).validate(payoutConfig);
expect(result.errors).toEqual(expect.arrayContaining([
'Direct escapeWithTreasure payout (60000 atomic units) exceeds its configured maximum (50000).',
'Credits escape payout (2000 atomic units) is below its configured minimum (3000).'
]));
});
test('payout launch gate requires a reserve that covers the largest accepted liability', () => {
const payoutConfig = config({
payouts: {
enabled: true,
rules: { direct: { enabled: true }, credits: { enabled: false } }
}
});
const missing = productionEnv({
PAYOUTS_ENABLED: 'true',
DIRECT_PAYOUTS_ENABLED: 'true',
PAYOUT_MAX_PER_GAME: '60000',
BALANCE_CRITICAL: '',
BALANCE_WARN: ''
});
expect(new EnvironmentValidator({ env: missing, logger: silentLogger })
.validate(payoutConfig).errors).toEqual(expect.arrayContaining([
expect.stringContaining('BALANCE_CRITICAL must be an explicit positive'),
expect.stringContaining('BALANCE_WARN must be an explicit positive')
]));
const underfunded = productionEnv({
PAYOUTS_ENABLED: 'true',
DIRECT_PAYOUTS_ENABLED: 'true',
PAYOUT_MAX_PER_GAME: '60000',
BALANCE_CRITICAL: '59999',
BALANCE_WARN: '59998'
});
expect(new EnvironmentValidator({ env: underfunded, logger: silentLogger })
.validate(payoutConfig).errors).toEqual(expect.arrayContaining([
expect.stringContaining('BALANCE_CRITICAL must be at least PAYOUT_MAX_PER_GAME (60000'),
expect.stringContaining('BALANCE_WARN must be greater than or equal to BALANCE_CRITICAL')
]));
});
test.each([
['BLOCK_SOURCE', 'simulated'],
['SIMULATED_BLOCKS', 'true'],
['FORCE_SIMULATED_BLOCKS', 'true']
])('rejects production simulation via %s', (key, value) => {
const result = new EnvironmentValidator({
env: productionEnv({ [key]: value }),
logger: silentLogger
}).validate(config());
expect(result.errors).toContain('Simulated blocks are forbidden when NODE_ENV=production.');
});
test('assertValid throws a typed error for startup to fail closed', () => {
const validator = new EnvironmentValidator({
env: productionEnv({ ADMIN_API_KEY: 'weak' }),
logger: silentLogger
});
expect(() => validator.assertValid(config())).toThrow(expect.objectContaining({
name: 'EnvironmentValidationError',
code: 'INVALID_ENVIRONMENT'
}));
});
});
describe('EnvironmentValidator development ergonomics', () => {
test('missing wallet/daemon endpoints warn without hard-stopping development', () => {
const env = { NODE_ENV: 'development', CRYPTO_TYPE: 'XMR', MONERO_NETWORK: 'stagenet' };
const result = new EnvironmentValidator({ env, logger: silentLogger }).validate(config());
expect(result.errors).toEqual([]);
expect(result.warnings.some(message => message.includes('PRIMARY_WALLET_ENDPOINT'))).toBe(true);
expect(result.warnings.some(message => message.includes('PRIMARY_RPC_ENDPOINT'))).toBe(true);
});
});
+251
View File
@@ -0,0 +1,251 @@
const FairnessOfferManager = require('../src/game/fairnessOfferManager');
const Game = require('../src/game/game');
const DungeonGenerator = require('../src/game/dungeon');
const { attachDungeonVerification } = require('../src/game/fairnessVerifier');
const {
hashSeed,
deriveSeed,
createGameProof,
getPreGameCommitment,
getPostGameReveal,
verifyGameProof
} = require('../src/game/provablyFair');
const SERVER_SEED = 'a'.repeat(64);
const CLIENT_SEED = '12'.repeat(32);
describe('two-phase solo fairness offers', () => {
test('publishes a commitment before client seed and consumes it exactly once', () => {
let now = 1000;
const offers = new FairnessOfferManager({
now: () => now,
seedFactory: () => SERVER_SEED,
idFactory: () => 'offer-1',
ttlMs: 60000
});
const published = offers.ensureOffer('socket-a');
expect(published).toEqual(expect.objectContaining({
offerId: 'offer-1',
commitment: hashSeed(SERVER_SEED),
issuedAt: 1000,
proofVersion: 2
}));
now = 2000; // client contribution arrives strictly after publication
const consumed = offers.consume('socket-a', {
offerId: published.offerId,
clientSeed: CLIENT_SEED.toUpperCase()
});
expect(consumed.success).toBe(true);
expect(consumed.proofInput.offerIssuedAt).toBeLessThan(now);
expect(consumed.proofInput.clientSeed).toBe(CLIENT_SEED);
expect(consumed.proofInput.commitment).toBe(published.commitment);
expect(offers.consume('socket-a', {
offerId: published.offerId,
clientSeed: CLIENT_SEED
})).toEqual(expect.objectContaining({ success: false, code: 'OFFER_REPLAYED' }));
});
test('cannot steal, swap, or attach a client seed without echoing the published offer', () => {
const offers = new FairnessOfferManager({
seedFactory: () => SERVER_SEED,
idFactory: () => 'offer-owner'
});
const published = offers.ensureOffer('owner');
expect(offers.consume('attacker', {
offerId: published.offerId,
clientSeed: CLIENT_SEED
}).code).toBe('OFFER_OWNER_MISMATCH');
expect(offers.consume('owner', { clientSeed: CLIENT_SEED }).code).toBe('OFFER_REQUIRED');
const consumed = offers.consume('owner', {
offerId: published.offerId,
clientSeed: CLIENT_SEED
});
expect(consumed.success).toBe(true);
expect(() => createGameProof('game', CLIENT_SEED, {
...consumed.proofInput,
commitment: 'b'.repeat(64)
})).toThrow(/commitment does not match/i);
});
test('legacy clients may consume the already-published current offer with an empty seed', () => {
const offers = new FairnessOfferManager({
seedFactory: () => SERVER_SEED,
idFactory: () => 'legacy-offer'
});
offers.ensureOffer('legacy');
const consumed = offers.consume('legacy');
expect(consumed.success).toBe(true);
expect(consumed.proofInput.clientSeed).toBe('');
});
});
describe('v2 proof binds the accepted client seed to the played dungeon', () => {
function precommit(clientSeed, offerId) {
return {
proofVersion: 2,
offerId,
offerIssuedAt: 123,
serverSeed: SERVER_SEED,
commitment: hashSeed(SERVER_SEED),
clientSeed
};
}
test('Game derives its RNG before layout generation and persists reproducible context', () => {
const game = new Game('socket', { id: 1 }, { fairnessProof: precommit(CLIENT_SEED, 'offer-a') });
expect(game.gameProof.seed).toBe(deriveSeed(SERVER_SEED, CLIENT_SEED));
expect(game.gameProof.clientSeed).toBe(CLIENT_SEED);
expect(game.gameProof.offerId).toBe('offer-a');
const context = game.gameProof.context;
const regenerated = DungeonGenerator.regenerateFromSeed(
game.gameProof.seed,
context.cryptoType,
context.generationOptions
);
if (context.maxDepth > 1) regenerated.treasure = null;
expect(DungeonGenerator.layoutFingerprint(regenerated)).toBe(game.gameProof.layoutFingerprint);
});
test('changing the client seed changes both effective seed and dungeon', () => {
const one = new Game('one', { id: 1 }, { fairnessProof: precommit('01', 'offer-1') });
const two = new Game('two', { id: 2 }, { fairnessProof: precommit('02', 'offer-2') });
expect(one.gameProof.seed).not.toBe(two.gameProof.seed);
expect(one.gameProof.layoutFingerprint).not.toBe(two.gameProof.layoutFingerprint);
});
test('pre-game data hides server seed, reveal links by game id, and tampering fails', () => {
const proof = createGameProof('game-uuid', CLIENT_SEED, precommit(CLIENT_SEED, 'offer-z'));
proof.layoutFingerprint = 'f'.repeat(64);
const before = getPreGameCommitment(proof);
expect(before.clientSeed).toBe(CLIENT_SEED);
expect(before.offerId).toBe('offer-z');
expect(before.serverSeed).toBeUndefined();
expect(before.seed).toBeUndefined();
const reveal = getPostGameReveal(proof);
expect(reveal.verificationUrl).toBe('/verify/game-uuid');
expect(verifyGameProof(reveal).valid).toBe(true);
expect(verifyGameProof({ ...reveal, clientSeed: '03' }).valid).toBe(false);
expect(verifyGameProof({ ...reveal, effectiveSeed: 'b'.repeat(64), seed: 'b'.repeat(64) }).valid).toBe(false);
expect(verifyGameProof({ ...reveal, serverSeed: 'b'.repeat(64) }).valid).toBe(false);
});
test('post-game reveal preserves the terminal result after endGame changes runtime state', () => {
const game = new Game('result-socket', { id: 3, endGame() {} }, {
fairnessProof: precommit(CLIENT_SEED, 'result-offer')
});
game.endGame('won', { score: 10 });
expect(game.gameState).toBe('ended');
expect(game.getProofReveal().gameResult.won).toBe(true);
});
});
describe('all-depth versioned verification', () => {
let savedLevels;
let savedCrypto;
beforeEach(() => {
savedLevels = process.env.DUNGEON_LEVELS;
savedCrypto = process.env.CRYPTO_TYPE;
process.env.DUNGEON_LEVELS = '3';
process.env.CRYPTO_TYPE = 'WOW';
});
afterEach(() => {
if (savedLevels === undefined) delete process.env.DUNGEON_LEVELS;
else process.env.DUNGEON_LEVELS = savedLevels;
if (savedCrypto === undefined) delete process.env.CRYPTO_TYPE;
else process.env.CRYPTO_TYPE = savedCrypto;
});
test('persists and deterministically verifies every advertised depth', () => {
const fairnessProof = {
proofVersion: 2,
offerId: 'all-depth-offer',
offerIssuedAt: 123,
serverSeed: SERVER_SEED,
commitment: hashSeed(SERVER_SEED),
clientSeed: CLIENT_SEED
};
const game = new Game('depth-socket', { id: 7 }, { fairnessProof });
const reveal = game.getProofReveal();
expect(game.gameProof.generatorVersion).toBe(DungeonGenerator.GENERATOR_VERSION);
expect(game.gameProof.layoutFingerprints).toHaveLength(3);
expect(game.gameProof.layoutFingerprints.map(item => item.depth)).toEqual([1, 2, 3]);
const result = attachDungeonVerification(verifyGameProof(reveal), {
effectiveSeed: reveal.effectiveSeed,
proofContext: reveal.context,
expectedFingerprint: reveal.layoutFingerprint,
expectedFingerprints: reveal.layoutFingerprints,
generatorVersion: reveal.generatorVersion
});
expect(result.valid).toBe(true);
expect(result.verificationScope).toBe('all_depths');
expect(result.verifiedDepths).toEqual([1, 2, 3]);
expect(result.levels.every(level => level.matches)).toBe(true);
});
test('fails if any depth is tampered with or omitted', () => {
const game = new Game('tamper-socket', { id: 8 }, {
fairnessProof: {
proofVersion: 2,
offerId: 'tamper-offer',
offerIssuedAt: 123,
serverSeed: SERVER_SEED,
commitment: hashSeed(SERVER_SEED),
clientSeed: CLIENT_SEED
}
});
const reveal = game.getProofReveal();
const tampered = reveal.layoutFingerprints.map(item => ({ ...item }));
tampered[1].fingerprint = '0'.repeat(64);
const mismatch = attachDungeonVerification(verifyGameProof(reveal), {
effectiveSeed: reveal.effectiveSeed,
proofContext: reveal.context,
expectedFingerprints: tampered,
generatorVersion: reveal.generatorVersion
});
expect(mismatch.valid).toBe(false);
expect(mismatch.levels[1].matches).toBe(false);
const incomplete = attachDungeonVerification(verifyGameProof(reveal), {
effectiveSeed: reveal.effectiveSeed,
proofContext: reveal.context,
expectedFingerprints: reveal.layoutFingerprints.slice(0, 2),
generatorVersion: reveal.generatorVersion
});
expect(incomplete.valid).toBe(false);
expect(incomplete.regenerationError).toBe('incomplete_layout_manifest');
});
});
describe('browser contribution helper', () => {
test('generates a fresh 256-bit client seed after selecting the offer', () => {
const oldWindow = global.window;
const oldSocket = global.socket;
global.window = {
crypto: { getRandomValues: (bytes) => { for (let i = 0; i < bytes.length; i++) bytes[i] = i; return bytes; } }
};
global.socket = { emit: jest.fn() };
jest.resetModules();
const BrowserHandlers = require('../html/js/network/socketHandlers.js');
BrowserHandlers._fairnessOffer = { offerId: 'browser-offer', commitment: hashSeed(SERVER_SEED) };
const payload = BrowserHandlers.fairnessAttempt({ free: true });
expect(payload.free).toBe(true);
expect(payload.fairnessOfferId).toBe('browser-offer');
expect(payload.clientSeed).toMatch(/^[0-9a-f]{64}$/);
expect(BrowserHandlers._pendingFairnessAttempt.commitment).toBe(hashSeed(SERVER_SEED));
global.window = oldWindow;
global.socket = oldSocket;
});
});
+22
View File
@@ -0,0 +1,22 @@
'use strict';
const fs = require('fs');
const path = require('path');
const deploy = name => fs.readFileSync(path.join(__dirname, '..', 'scripts', 'deploy', name), 'utf8');
describe('deployment financial-audit contract', () => {
const audit = deploy('financial-audit.sql');
test('retained consumed match anchors are not treated as live escrow', () => {
expect(audit).toMatch(/match_queue_entries\s+WHERE status IS NULL OR status NOT IN \('consumed', 'cancelled'\)/);
expect(audit).not.toMatch(/status NOT IN \('cancelled'\)/);
});
test('nullable or unknown money/game states and a stale schema ledger fail closed', () => {
expect(audit).toContain("payouts WHERE status IS DISTINCT FROM 'completed'");
expect(audit).toContain("games WHERE status IS NULL OR status NOT IN ('won', 'lost', 'expired')");
expect(audit).toContain("MAX(filename) = '040_paid_match_entropy_precommit.sql'");
expect(audit).toContain('COUNT(*) = 40');
});
});
@@ -0,0 +1,80 @@
'use strict';
const fs = require('fs');
const path = require('path');
const MIGRATIONS = path.join(__dirname, '../src/migrations');
const AUDIT = path.join(__dirname, '../scripts/deploy/financial-constraint-audit.sql');
const VALIDATE = path.join(__dirname, '../scripts/deploy/financial-constraint-validate-restore.sql');
const CONSTRAINTS = Object.freeze([
['payments', 'payments_status_check'],
['payments', 'payments_payment_type_check'],
['payouts', 'payouts_status_check'],
['games', 'games_payout_commitment_complete'],
['games', 'games_layout_fingerprints_array_check'],
['race_entry_lots', 'race_entry_lots_remaining_within_original'],
['match_queue_entries', 'match_queue_entries_status_check'],
['match_queue_entries', 'match_queue_entries_escrow_nonnegative'],
['match_queue_entries', 'match_queue_committed_escrow_complete'],
['matches', 'matches_payout_liability_amount_nonnegative'],
['matches', 'matches_payout_liability_cap_positive'],
['matches', 'matches_payout_liability_complete'],
['matches', 'matches_payout_liability_economics_consistent'],
['race_entry_lots', 'race_entry_lots_refund_shape'],
['payment_refunds', 'payment_refunds_progress_nonnegative'],
['games', 'games_entry_consumption_shape'],
['payments', 'payments_fairness_binding_complete'],
['payouts', 'payouts_no_address_review_shape']
]);
function migrationNotValidConstraintNames() {
const names = new Set();
for (const fileName of fs.readdirSync(MIGRATIONS).filter(name => name.endsWith('.sql'))) {
const sql = fs.readFileSync(path.join(MIGRATIONS, fileName), 'utf8');
for (const marker of sql.matchAll(/NOT\s+VALID\s*;/gi)) {
const prefix = sql.slice(0, marker.index);
const declarations = [...prefix.matchAll(/ADD\s+CONSTRAINT\s+([a-z0-9_]+)/gi)];
if (declarations.length) names.add(declarations.at(-1)[1].toLowerCase());
}
}
return [...names].sort();
}
describe('historical financial constraint release gates', () => {
const expectedNames = CONSTRAINTS.map(([, name]) => name).sort();
test('inventory stays synchronized with every migration NOT VALID constraint', () => {
expect(migrationNotValidConstraintNames()).toEqual(expectedNames);
expect(CONSTRAINTS).toHaveLength(18);
});
test('read-only audit names every constraint and checks historical predicates', () => {
const sql = fs.readFileSync(AUDIT, 'utf8');
expect(sql).toMatch(/BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY;/i);
expect(sql).toContain("current_database() = :'expected_database'");
expect(sql).toContain('historical_constraints_clean');
expect(sql).toContain('IS FALSE');
expect(sql).not.toMatch(/\bALTER\s+TABLE\b/i);
expect(sql).not.toMatch(/\b(?:INSERT\s+INTO|UPDATE|DELETE\s+FROM|TRUNCATE)\b/i);
for (const [, name] of CONSTRAINTS) {
expect(sql).toContain(`'${name}'`);
}
});
test('disposable-restore gate explicitly validates every constraint then rolls back', () => {
const sql = fs.readFileSync(VALIDATE, 'utf8');
expect(sql).toContain("current_database() ~* '(restore|scratch)'");
expect(sql).toContain('I_CONFIRM_THIS_DATABASE_IS_A_DISPOSABLE_RESTORE');
expect(sql).toContain('\\ir financial-constraint-audit.sql');
expect(sql).toContain('SET LOCAL lock_timeout');
expect(sql).toMatch(/ROLLBACK;[\s\S]*PASS: PostgreSQL validated all 18 constraints/i);
for (const [table, name] of CONSTRAINTS) {
expect(sql).toContain(
`ALTER TABLE public.${table} VALIDATE CONSTRAINT ${name};`
);
}
expect((sql.match(/\bVALIDATE CONSTRAINT\s+[a-z0-9_]+;/gi) || []))
.toHaveLength(CONSTRAINTS.length);
});
});
+2 -2
View File
@@ -50,8 +50,8 @@ describe('Follow-up 1 — no-address match winner reconciliation', () => {
expect(ok).toBe(true);
const payoutUpdate = db.query.mock.calls.find(c => /UPDATE\s+payouts/i.test(c[0]));
expect(payoutUpdate).toBeTruthy();
// Narrow, safe filter — only the no-address match liability, never generic needs_review.
expect(payoutUpdate[0]).toMatch(/reason\s*=\s*'match_winner_no_address'/);
// Narrow, safe filter — only explicit no-address liabilities, never generic needs_review.
expect(payoutUpdate[0]).toMatch(/reason\s+IN\s*\(\s*'match_winner_no_address'\s*,\s*'solo_winner_no_address'\s*\)/);
expect(payoutUpdate[0]).toMatch(/status\s*=\s*'needs_review'/);
expect(payoutUpdate[0]).toMatch(/PENDING_NO_ADDRESS/);
expect(payoutUpdate[0]).toMatch(/SET payout_address = \$1, status = 'pending'/);
+59
View File
@@ -0,0 +1,59 @@
const GameManager = require('../src/game/gameManager');
function makeHarness(completion) {
const targetedEmit = jest.fn();
const io = {
to: jest.fn(() => ({ emit: targetedEmit })),
emit: jest.fn()
};
const game = {
id: 'game-seed',
player: { hasTreasure: false },
moveCount: 12,
startedAt: Date.now() - 1000,
endGame: jest.fn(),
getProofReveal: jest.fn(() => ({ commitment: 'proof' }))
};
const gameModeManager = {
completeGame: jest.fn().mockResolvedValue(completion),
db: { query: jest.fn().mockResolvedValue({ rows: [{ name: 'Alice' }] }) }
};
const manager = new GameManager({
activeGames: new Map([['socket-1', game]]),
io,
broadcastManager: {},
debugManager: { CONSOLE_LOGGING: false },
gameModeManager
});
return { manager, game, io };
}
async function finishWin(completion) {
const harness = makeHarness(completion);
await harness.manager.handleGameOver(
{ id: 'socket-1' },
harness.game,
'won',
'escaped',
'Escaped',
100
);
return harness.io.emit.mock.calls.find(([event]) => event === 'win_feed')?.[1];
}
describe('solo win feed payout status', () => {
test('marks a win paid when completion contains a nested payout amount', async () => {
const feed = await finishWin({
success: true,
payout: { status: 'queued', amount: '200000000000', multiplier: 2 }
});
expect(feed).toEqual(expect.objectContaining({ name: 'Alice', score: 100, paid: true }));
});
test('does not trust an obsolete top-level amount when no payout was created', async () => {
const feed = await finishWin({ success: true, amount: '200000000000', payout: null });
expect(feed).toEqual(expect.objectContaining({ paid: false }));
});
});
+349 -19
View File
@@ -110,7 +110,205 @@ describe('GameModeManager', () => {
});
});
describe('production payout and free-play policy', () => {
test('financial recovery gate blocks invoice intake and every paid start but preserves explicit free play', async () => {
gmm.paymentsEnabled = true;
gmm.directModeEnabled = true;
gmm.creditsModeEnabled = true;
gmm.financialAdmissionAllowed = () => false;
expect(gmm.isPaymentIntakeEnabled('single_game')).toBe(false);
expect(gmm.isPaymentIntakeEnabled('credits_package')).toBe(false);
expect(gmm.isPaymentIntakeEnabled('cosmetic_pack')).toBe(false);
const user = { id: 7, credits: 5, payout_address: 'wallet' };
jest.spyOn(gmm, 'getOrCreateUser').mockResolvedValue(user);
const paid = await gmm.processGameStart('socket-paid', 'game-paid');
expect(paid).toMatchObject({
success: false,
code: 'FINANCIAL_RECOVERY_PENDING'
});
expect(mockDb.query).not.toHaveBeenCalled();
expect(mockDb.withTransaction).not.toHaveBeenCalled();
expect(await gmm._processGameStartWithCredits(user, 'socket-paid', 'game-credits'))
.toMatchObject({ success: false, code: 'FINANCIAL_RECOVERY_PENDING' });
expect(await gmm._processGameStartWithPayment(user, { id: 9 }, 'game-direct'))
.toMatchObject({ success: false, code: 'FINANCIAL_RECOVERY_PENDING' });
expect(mockDb.withTransaction).not.toHaveBeenCalled();
jest.spyOn(gmm, '_processGameStartFree').mockResolvedValue({
success: true,
effectiveMode: 'FREE'
});
await expect(gmm.processGameStart('socket-free', 'game-free', { forceFree: true }))
.resolves.toEqual({ success: true, effectiveMode: 'FREE' });
});
test('master payout switch overrides enabled per-mode rules and stops dispatch', async () => {
gmm.payoutsEnabled = false;
gmm.directPayoutEnabled = true;
gmm.creditsPayoutEnabled = true;
gmm.directRequiresAddress = true;
expect(gmm.isPayoutEnabledForMode('PAID_SINGLE')).toBe(false);
expect(gmm.isPayoutEnabledForMode('PAID_CREDITS')).toBe(false);
expect(gmm.requiresPayoutAddressForMode('PAID_SINGLE')).toBe(false);
await gmm._processPendingPayouts();
expect(mockDb.withTransaction).not.toHaveBeenCalled();
expect(mockWalletService.processPayout).not.toHaveBeenCalled();
});
test('shutdown cancels a queued payout debounce and rejects later scheduling', async () => {
jest.useFakeTimers();
try {
const processPending = jest.spyOn(gmm, '_processPendingPayouts').mockResolvedValue();
gmm._scheduleBatchPayout();
expect(jest.getTimerCount()).toBe(1);
gmm.shutdown();
expect(gmm._batchPayoutTimer).toBeNull();
expect(jest.getTimerCount()).toBe(0);
gmm._scheduleBatchPayout();
jest.advanceTimersByTime(5000);
await Promise.resolve();
expect(processPending).not.toHaveBeenCalled();
expect(jest.getTimerCount()).toBe(0);
} finally {
jest.useRealTimers();
}
});
test('unified paid config does not accidentally inherit free mode when FREE_PLAY_ENABLED is false', () => {
const oldGameMode = process.env.GAME_MODE;
const oldFree = process.env.FREE_PLAY_ENABLED;
try {
delete process.env.GAME_MODE;
process.env.FREE_PLAY_ENABLED = 'false';
const manager = new GameModeManager(mockDb, mockWalletService, mockDebugManager, mockPaymentConfigManager);
expect(manager.paymentsEnabled).toBe(true);
expect(manager.freePlayEnabled).toBe(false);
} finally {
if (oldGameMode === undefined) delete process.env.GAME_MODE; else process.env.GAME_MODE = oldGameMode;
if (oldFree === undefined) delete process.env.FREE_PLAY_ENABLED; else process.env.FREE_PLAY_ENABLED = oldFree;
}
});
test('game mode info advertises the selected safe ruleset catalog', () => {
const old = process.env.MATCH_RULESET_ID;
try {
process.env.MATCH_RULESET_ID = 'last-alive';
const match = gmm.getGameModeInfo().modes.match;
expect(match.activeRuleset).toEqual(expect.objectContaining({
id: 'last-alive',
label: 'Last Alive',
winCondition: 'last-alive'
}));
expect(match.rulesets.map(r => r.id)).toEqual(expect.arrayContaining(['race', 'last-alive', 'score-attack', 'coop-escape']));
expect(match.rulesets.map(r => r.id)).not.toContain('solo-classic');
} finally {
if (old === undefined) delete process.env.MATCH_RULESET_ID; else process.env.MATCH_RULESET_ID = old;
}
});
test('crypto race economy is hidden when the payout master switch is off', () => {
const keys = [
'MATCH_ENABLED', 'MATCH_CRYPTO_RACE_ENABLED', 'MATCH_PAYOUTS_ENABLED',
'MATCH_PAYOUT_MAX', 'MATCH_ENTRY_FEE_ATOMIC', 'MATCH_HOUSE_FEE_PERCENT',
'MATCH_MAX_PLAYERS'
];
const old = Object.fromEntries(keys.map(key => [key, process.env[key]]));
try {
process.env.MATCH_ENABLED = 'true';
process.env.MATCH_CRYPTO_RACE_ENABLED = 'true';
process.env.MATCH_PAYOUTS_ENABLED = 'true';
process.env.MATCH_PAYOUT_MAX = '1000000000000';
process.env.MATCH_ENTRY_FEE_ATOMIC = '10000';
process.env.MATCH_HOUSE_FEE_PERCENT = '5';
process.env.MATCH_MAX_PLAYERS = '4';
gmm.payoutsEnabled = false;
expect(gmm._getMatchEconomies().crypto_race).toBeUndefined();
gmm.payoutsEnabled = true;
expect(gmm._getMatchEconomies().crypto_race).toBe(true);
} finally {
for (const key of keys) {
if (old[key] === undefined) delete process.env[key]; else process.env[key] = old[key];
}
}
});
});
describe('calculatePayout', () => {
test('publishes only the double/triple outcomes implemented by payout calculation', () => {
gmm.directPayoutMultipliers = {
escape: 2,
escapeWithTreasure: 3,
perfectRun: 5,
experimentalJackpot: 20
};
gmm.creditPayoutMultipliers = {
escape: 2,
escapeWithTreasure: 3,
perfectRun: 3
};
expect(gmm.getImplementedPayoutMultipliersForMode('PAID_SINGLE')).toStrictEqual({
escape: 2,
escapeWithTreasure: 3
});
expect(gmm.getImplementedPayoutMultipliersForMode('PAID_CREDITS')).toStrictEqual({
escape: 2,
escapeWithTreasure: 3
});
const publicInfo = gmm.getGameModeInfo();
expect(publicInfo.payoutMultipliers).toStrictEqual({
direct: { escape: 2, escapeWithTreasure: 3 },
credits: { escape: 2, escapeWithTreasure: 3 }
});
});
test('publishes exact effective outcomes after cap/min policy', () => {
gmm.payoutsEnabled = true;
gmm.directPayoutEnabled = true;
gmm.creditsPayoutEnabled = true;
gmm.singleGamePrice = 20000;
gmm.creditsPayoutBaseValue = 1000;
gmm.directPayoutMultipliers = { escape: 2, escapeWithTreasure: 3 };
gmm.creditPayoutMultipliers = { escape: 2, escapeWithTreasure: 3 };
gmm.configSnapshot.payouts.rules.direct.minPayout = 1n;
gmm.configSnapshot.payouts.rules.direct.maxPayout = 50000n;
gmm.configSnapshot.payouts.rules.credits.minPayout = 3000n;
gmm.configSnapshot.payouts.rules.credits.maxPayout = 50000n;
const outcomes = gmm.getGameModeInfo().payoutOutcomes;
expect(outcomes.direct.escape).toEqual(expect.objectContaining({
payable: true,
amountAtomic: '40000',
rawAmountAtomic: '40000',
capApplied: false
}));
expect(outcomes.direct.escapeWithTreasure).toEqual(expect.objectContaining({
payable: true,
amountAtomic: '50000',
rawAmountAtomic: '60000',
capApplied: true
}));
expect(outcomes.credits.escape).toEqual(expect.objectContaining({
payable: false,
amountAtomic: '0',
rawAmountAtomic: '2000',
suppressedReason: 'below_minimum'
}));
const snapshot = gmm._computePayoutSnapshot('PAID_SINGLE');
expect(snapshot.treasureAmount).toBe('50000');
expect(snapshot.terms.version).toBe(2);
});
test('calculates direct mode payout correctly', () => {
gmm.singleGamePrice = 100000000000; // 1 WOW
gmm.directPayoutMultipliers = { escape: 2, escapeWithTreasure: 3 };
@@ -189,10 +387,14 @@ describe('GameModeManager', () => {
});
// Step 2: withTransaction — client.query calls
mockClient.query
.mockResolvedValueOnce({ rows: [{ id: 123 }] }) // UPDATE payments SET status='confirmed'
.mockResolvedValueOnce({ rows: [{ credits: 15 }] }) // UPDATE users SET credits = credits + 15
.mockResolvedValueOnce({ rows: [] }); // INSERT credit_transactions
mockClient.query.mockImplementation(async (sql) => {
if (/UPDATE payments/i.test(sql)) return { rows: [{ id: 123 }], rowCount: 1 };
if (/SELECT id, premium_level/i.test(sql)) return { rows: [{ id: 1, premium_level: 'free' }], rowCount: 1 };
if (/UPDATE users/i.test(sql)) return { rows: [{ id: 1, credits: 15, total_credits_purchased: 15, premium_level: 'free' }], rowCount: 1 };
if (/INSERT INTO payment_entitlement_grants/i.test(sql)) return { rows: [{ payment_id: 123 }], rowCount: 1 };
if (/SELECT pack_id/i.test(sql)) return { rows: [] };
return { rows: [], rowCount: 1 };
});
const result = await gmm.processCreditsPackageConfirmation('socket1', 123, { credits: 10, bonus: 5 });
expect(result.success).toBe(true);
@@ -204,12 +406,15 @@ describe('GameModeManager', () => {
rows: [{ user_id: 1, description: '', status: 'pending', payment_type: 'credits_package' }]
});
mockClient.query
.mockResolvedValueOnce({ rows: [{ id: 123 }] }) // UPDATE payments
.mockResolvedValueOnce({ rows: [{ id: 1, credits: 15, total_credits_purchased: 15, premium_level: 'supporter' }] }) // UPDATE users
.mockResolvedValueOnce({ rows: [] }) // INSERT credit_transactions
.mockResolvedValueOnce({ rows: [] }) // INSERT pack entitlement
.mockResolvedValueOnce({ rows: [{ pack_id: 'kenney-3d-characters' }] }); // SELECT active grants
mockClient.query.mockImplementation(async (sql) => {
if (/UPDATE payments/i.test(sql)) return { rows: [{ id: 123 }], rowCount: 1 };
if (/SELECT id, premium_level/i.test(sql)) return { rows: [{ id: 1, premium_level: 'free' }], rowCount: 1 };
if (/UPDATE users/i.test(sql)) return { rows: [{ id: 1, credits: 15, total_credits_purchased: 15, premium_level: 'supporter' }], rowCount: 1 };
if (/INSERT INTO user_pack_entitlements/i.test(sql)) return { rows: [{ pack_id: 'kenney-3d-characters' }], rowCount: 1 };
if (/INSERT INTO payment_entitlement_grants/i.test(sql)) return { rows: [{ payment_id: 123 }], rowCount: 1 };
if (/SELECT pack_id/i.test(sql)) return { rows: [{ pack_id: 'kenney-3d-characters' }] };
return { rows: [], rowCount: 1 };
});
const result = await gmm.processCreditsPackageConfirmation('socket1', 123, {
id: 'bundle',
@@ -243,11 +448,15 @@ describe('GameModeManager', () => {
}]
});
mockClient.query
.mockResolvedValueOnce({ rows: [{ id: 456 }] }) // UPDATE payments
.mockResolvedValueOnce({ rows: [{ id: 1, credits: 0, total_credits_purchased: 0, premium_level: 'free' }] }) // UPDATE users
.mockResolvedValueOnce({ rows: [] }) // INSERT pack entitlement
.mockResolvedValueOnce({ rows: [{ pack_id: 'kenney-3d-characters' }] }); // SELECT active grants
mockClient.query.mockImplementation(async (sql) => {
if (/UPDATE payments/i.test(sql)) return { rows: [{ id: 456 }], rowCount: 1 };
if (/SELECT id, premium_level/i.test(sql)) return { rows: [{ id: 1, premium_level: 'free' }], rowCount: 1 };
if (/UPDATE users/i.test(sql)) return { rows: [{ id: 1, credits: 0, total_credits_purchased: 0, premium_level: 'free' }], rowCount: 1 };
if (/INSERT INTO user_pack_entitlements/i.test(sql)) return { rows: [{ pack_id: 'kenney-3d-characters' }], rowCount: 1 };
if (/INSERT INTO payment_entitlement_grants/i.test(sql)) return { rows: [{ payment_id: 456 }], rowCount: 1 };
if (/SELECT pack_id/i.test(sql)) return { rows: [{ pack_id: 'kenney-3d-characters' }] };
return { rows: [], rowCount: 1 };
});
const result = await gmm.processProductPaymentConfirmation('socket1', 456, null, 1000);
@@ -257,6 +466,105 @@ describe('GameModeManager', () => {
expect(mockClient.query.mock.calls.some(([sql]) => /INSERT INTO credit_transactions/i.test(sql))).toBe(false);
});
test('creates an exact funded lot from the durable confirmed-payment grant snapshot', async () => {
const keys = ['MATCH_CRYPTO_RACE_ENABLED', 'MATCH_PAYOUTS_ENABLED', 'MATCH_ENTRY_FEE_ATOMIC'];
const previous = Object.fromEntries(keys.map(key => [key, process.env[key]]));
try {
process.env.MATCH_CRYPTO_RACE_ENABLED = 'true';
process.env.MATCH_PAYOUTS_ENABLED = 'true';
process.env.MATCH_ENTRY_FEE_ATOMIC = '10000';
mockDb.query.mockResolvedValueOnce({
rows: [{
user_id: 1,
description: 'Two race entries',
status: 'pending',
payment_type: 'cosmetic_pack',
product_id: 'race_2',
product_grants: {
credits: 0,
raceEntries: 2,
raceEntryValueAtomic: '10000',
packs: [],
premiumLevel: null
},
received_amount: '20000',
expected_amount: '20000'
}]
});
mockClient.query.mockImplementation(async (sql) => {
if (/UPDATE payments/i.test(sql)) return { rows: [{ id: 700 }], rowCount: 1 };
if (/SELECT id, premium_level/i.test(sql)) {
return { rows: [{ id: 1, premium_level: 'free' }], rowCount: 1 };
}
if (/SET credits = credits/i.test(sql)) {
return {
rows: [{ id: 1, credits: 0, total_credits_purchased: 0, premium_level: 'free' }],
rowCount: 1
};
}
if (/SET race_entries = race_entries \+/i.test(sql)) {
return { rows: [{ race_entries: 2 }], rowCount: 1 };
}
if (/INSERT INTO race_entry_lots/i.test(sql)) {
return { rows: [{ id: 88 }], rowCount: 1 };
}
if (/SELECT pack_id/i.test(sql)) return { rows: [], rowCount: 0 };
return { rows: [], rowCount: 1 };
});
const result = await gmm.processProductPaymentConfirmation('new-socket', 700, {
id: 'changed-live-catalog',
grants: { race_entries: 99, race_entry_value_atomic: '1' }
}, '20000');
expect(result.success).toBe(true);
const lot = mockClient.query.mock.calls.find(([sql]) => /INSERT INTO race_entry_lots/i.test(sql));
expect(lot[1]).toEqual([1, 700, '10000', 2, 'race_2']);
const ticketLedger = mockClient.query.mock.calls.find(([sql]) => /INSERT INTO race_entry_transactions/i.test(sql));
expect(ticketLedger[1][3]).toBe(700);
} finally {
for (const key of keys) {
if (previous[key] === undefined) delete process.env[key];
else process.env[key] = previous[key];
}
}
});
test('does not confirm an underfunded race-ticket invoice', async () => {
const keys = ['MATCH_CRYPTO_RACE_ENABLED', 'MATCH_PAYOUTS_ENABLED', 'MATCH_ENTRY_FEE_ATOMIC'];
const previous = Object.fromEntries(keys.map(key => [key, process.env[key]]));
try {
process.env.MATCH_CRYPTO_RACE_ENABLED = 'true';
process.env.MATCH_PAYOUTS_ENABLED = 'true';
process.env.MATCH_ENTRY_FEE_ATOMIC = '10000';
mockDb.query.mockResolvedValueOnce({
rows: [{
user_id: 1,
status: 'pending',
payment_type: 'cosmetic_pack',
product_id: 'race_2',
product_grants: {
credits: 0,
raceEntries: 2,
raceEntryValueAtomic: '10000'
},
received_amount: '19999',
expected_amount: '20000'
}]
});
const result = await gmm.processProductPaymentConfirmation('new-socket', 701, null, '19999');
expect(result.success).toBe(false);
expect(mockDb.withTransaction).not.toHaveBeenCalled();
} finally {
for (const key of keys) {
if (previous[key] === undefined) delete process.env[key];
else process.env[key] = previous[key];
}
}
});
test('falls back to default credits when package info missing and no description', async () => {
// Step 1: db.query — SELECT payment lookup with no credits in description
mockDb.query.mockResolvedValueOnce({
@@ -264,10 +572,14 @@ describe('GameModeManager', () => {
});
// Step 2: withTransaction — client.query calls
mockClient.query
.mockResolvedValueOnce({ rows: [{ id: 123 }] }) // UPDATE payments SET status='confirmed'
.mockResolvedValueOnce({ rows: [{ credits: 10 }] }) // UPDATE users
.mockResolvedValueOnce({ rows: [] }); // INSERT credit_transactions
mockClient.query.mockImplementation(async (sql) => {
if (/UPDATE payments/i.test(sql)) return { rows: [{ id: 123 }], rowCount: 1 };
if (/SELECT id, premium_level/i.test(sql)) return { rows: [{ id: 1, premium_level: 'free' }], rowCount: 1 };
if (/UPDATE users/i.test(sql)) return { rows: [{ id: 1, credits: 10, total_credits_purchased: 10, premium_level: 'free' }], rowCount: 1 };
if (/INSERT INTO payment_entitlement_grants/i.test(sql)) return { rows: [{ payment_id: 123 }], rowCount: 1 };
if (/SELECT pack_id/i.test(sql)) return { rows: [] };
return { rows: [], rowCount: 1 };
});
const result = await gmm.processCreditsPackageConfirmation('socket1', 123, null);
expect(result.success).toBe(true);
@@ -309,6 +621,24 @@ describe('GameModeManager', () => {
expect(result.effectiveMode).toBe('PAID_SINGLE');
});
test('when direct is preferred, a confirmed direct entry is not replaced by an existing credit', async () => {
gmm.paymentsEnabled = true;
gmm.directModeEnabled = true;
gmm.creditsModeEnabled = true;
gmm.preferCreditsFirst = false;
mockDb.query
.mockResolvedValueOnce({ rows: [{ id: 1, credits: 5 }] })
.mockResolvedValueOnce({ rows: [] })
.mockResolvedValueOnce({ rows: [{ id: 999 }] });
const result = await gmm.canUserStartGame('socket1');
expect(result.allowed).toBe(true);
expect(result.paymentId).toBe(999);
expect(result.effectiveMode).toBe('PAID_SINGLE');
});
test('denies game when no credits and no payment in mixed mode', async () => {
gmm.paymentsEnabled = true;
gmm.directModeEnabled = true;
+51
View File
@@ -0,0 +1,51 @@
const Game = require('../src/game/game');
const GameManager = require('../src/game/gameManager');
describe('paid game persistence gate', () => {
afterEach(() => jest.restoreAllMocks());
test('a failed durable INSERT aborts before the game becomes active', async () => {
const game = { id: 'durable-seed', gameProof: {} };
jest.spyOn(Game, 'createStandardGame').mockReturnValue(game);
const user = { id: 'socket-1', joinGame: jest.fn() };
const activeGames = new Map();
const dbError = new Error('database unavailable');
const manager = new GameManager({
activeGames,
io: {},
broadcastManager: {},
debugManager: { CONSOLE_LOGGING: false, getCurrentBlockHeight: () => 100 },
gameModeManager: {
paymentsEnabled: true,
gameMode: 'PAID_SINGLE',
db: { query: jest.fn().mockRejectedValue(dbError) }
}
});
await expect(manager.createGameForUser(user)).rejects.toThrow('database unavailable');
expect(user.joinGame).not.toHaveBeenCalled();
expect(activeGames.has(user.id)).toBe(false);
expect(game.dbId).toBeNull();
});
test('a paid game also rejects an INSERT that returns no durable id', async () => {
const game = { id: 'missing-id', gameProof: {} };
jest.spyOn(Game, 'createStandardGame').mockReturnValue(game);
const user = { id: 'socket-2', joinGame: jest.fn() };
const manager = new GameManager({
activeGames: new Map(),
io: {},
broadcastManager: {},
debugManager: { CONSOLE_LOGGING: false, getCurrentBlockHeight: () => 100 },
gameModeManager: {
paymentsEnabled: true,
gameMode: 'PAID_SINGLE',
db: { query: jest.fn().mockResolvedValue({ rows: [] }) }
}
});
await expect(manager.createGameForUser(user)).rejects.toThrow('Durable game record is required');
expect(user.joinGame).not.toHaveBeenCalled();
});
});
+160
View File
@@ -0,0 +1,160 @@
const { buildPublicHealth } = require('../src/services/healthService');
describe('public health payload', () => {
test('is ready only when required production dependencies are up', () => {
const health = buildPublicHealth({
databaseReady: true,
blockHeight: 123,
paymentsEnabled: true,
walletHealthy: true,
payoutsEnabled: false,
now: 1,
uptime: 10
});
expect(health).toMatchObject({
status: 'ok',
ready: true,
checks: { database: 'up', chain: 'up', wallet: 'up' },
money: { paymentsEnabled: true, payoutsEnabled: false }
});
});
test('free-only instances do not require a wallet', () => {
const health = buildPublicHealth({
databaseReady: true,
blockHeight: 123,
paymentsEnabled: false,
walletHealthy: false
});
expect(health.ready).toBe(true);
expect(health.checks.wallet).toBe('not_required');
});
test('payout-only settlement requires a wallet without claiming intake is enabled', () => {
const health = buildPublicHealth({
databaseReady: true,
blockHeight: 123,
paymentsEnabled: false,
payoutsEnabled: true,
walletHealthy: false
});
expect(health.ready).toBe(false);
expect(health.checks.wallet).toBe('down');
expect(health.money).toEqual({ paymentsEnabled: false, payoutsEnabled: true });
expect(health.paymentsEnabled).toBe(false);
});
test('reports degraded readiness when the daemon or required wallet is down', () => {
const health = buildPublicHealth({
databaseReady: true,
blockHeight: 0,
paymentsEnabled: true,
walletHealthy: false
});
expect(health.status).toBe('degraded');
expect(health.ready).toBe(false);
expect(health.checks).toMatchObject({ chain: 'down', wallet: 'down' });
});
test('keeps readiness closed while startup financial recovery is pending', () => {
const health = buildPublicHealth({
databaseReady: true,
blockHeight: 123,
paymentsEnabled: true,
walletHealthy: true,
financialRecoveryReady: false
});
expect(health.ready).toBe(false);
expect(health.checks.financialRecovery).toBe('pending');
});
test('does not treat a stale cached block height as a healthy daemon', () => {
const health = buildPublicHealth({
databaseReady: true,
blockHeight: 123,
chainHealthy: false,
paymentsEnabled: false
});
expect(health.ready).toBe(false);
expect(health.checks.chain).toBe('down');
});
test('never includes sensitive operational detail', () => {
const health = buildPublicHealth({
databaseReady: true,
blockHeight: 1,
paymentsEnabled: true,
walletHealthy: true
});
expect(JSON.stringify(health)).not.toMatch(/balance|endpoint|password|memory|rateLimiter/i);
});
test('requires verified daemon and wallet identities for production money mode', () => {
const identity = {
verified: true,
expected: { cryptoType: 'XMR', network: 'stagenet', genesisHash: 'secret-internal' },
actual: { cryptoType: 'XMR', network: 'stagenet', genesisHash: 'secret-internal' },
reason: 'internal-reason',
endpoint: 'http://user:password@wallet.test'
};
const health = buildPublicHealth({
databaseReady: true,
blockHeight: 123,
chainHealthy: true,
paymentsEnabled: true,
walletHealthy: true,
identityRequired: true,
daemonIdentity: identity,
walletIdentity: identity
});
expect(health).toMatchObject({
ready: true,
checks: { daemonIdentity: 'verified', walletIdentity: 'verified' },
chain: { network: 'stagenet' },
identities: {
daemon: { required: true, verified: true },
wallet: { required: true, verified: true }
}
});
expect(JSON.stringify(health)).not.toMatch(/secret-internal|internal-reason|endpoint|password/);
});
test('fails readiness on a daemon identity mismatch', () => {
const health = buildPublicHealth({
databaseReady: true,
blockHeight: 123,
chainHealthy: true,
paymentsEnabled: false,
identityRequired: true,
daemonIdentity: { verified: false }
});
expect(health.ready).toBe(false);
expect(health.checks).toMatchObject({ chain: 'down', daemonIdentity: 'unverified' });
});
test('fails payout-only readiness on a wallet identity mismatch', () => {
const health = buildPublicHealth({
databaseReady: true,
blockHeight: 123,
chainHealthy: true,
paymentsEnabled: false,
payoutsEnabled: true,
walletHealthy: true,
identityRequired: true,
daemonIdentity: { verified: true },
walletIdentity: { verified: false }
});
expect(health.ready).toBe(false);
expect(health.checks).toMatchObject({ wallet: 'down', walletIdentity: 'unverified' });
});
});
+15
View File
@@ -53,3 +53,18 @@ describe('handleRegisterClient validation', () => {
expect(reg.data.success).toBe(false);
});
});
describe('session resume credential transport', () => {
let ch;
afterEach(() => ch && ch.shutdown && ch.shutdown());
test('accepts auth payload token and ignores URL query token', () => {
ch = buildHandler();
expect(ch._getResumeToken({
handshake: { auth: { resumeToken: 'auth-secret' }, query: { resumeToken: 'url-secret' } }
})).toBe('auth-secret');
expect(ch._getResumeToken({
handshake: { auth: {}, query: { resumeToken: 'url-secret' } }
})).toBeNull();
});
});
+94
View File
@@ -0,0 +1,94 @@
const GameModeManager = require('../src/game/gameModeManager');
const LatePaymentReconciler = require('../src/services/latePaymentReconciler');
describe('late incoming payment reconciliation', () => {
test('bounded scanner sends an expired native receipt only to manual review', async () => {
const row = {
id: 7,
user_id: 3,
status: 'expired',
subaddress: 'late-address',
address_index: 11,
expected_amount: '100',
provider_id: 'native-monero',
provider_invoice_id: 'late-address'
};
const db = { query: jest.fn().mockResolvedValue({ rows: [row] }) };
const receipt = {
evidenceType: 'chain_output', providerId: 'native-monero',
txHash: 'a'.repeat(64), outputId: 'b'.repeat(64),
evidenceId: `${'a'.repeat(64)}:${'b'.repeat(64)}`,
addressIndex: 11, amount: '100', confirmed: true
};
const walletService = {
accountIndex: 0,
addressToUser: new Map(),
addressToSocket: new Map(),
checkPaymentStatus: jest.fn(async address => {
expect(address).toBe('late-address');
expect(walletService.addressToUser.get(address)).toEqual(expect.objectContaining({
amount: 100,
addressIndex: 11
}));
return { confirmed: true, complete: true, amount: 100, receipts: [receipt] };
}),
stopPaymentMonitoring: jest.fn()
};
const gameModeManager = {
paymentProviders: { get: jest.fn(() => null) },
reconcileLatePaymentForReview: jest.fn().mockResolvedValue({ checked: true, needsReview: true })
};
const service = new LatePaymentReconciler({
db, gameModeManager, walletService, lookbackHours: 24, batchSize: 20,
logger: { warn: jest.fn(), error: jest.fn() }
});
const result = await service.runOnce();
expect(result).toEqual({ skipped: false, checked: 1, reviews: 1, errors: 0 });
expect(db.query.mock.calls[0][1]).toEqual([24, 20]);
expect(gameModeManager.reconcileLatePaymentForReview).toHaveBeenCalledWith(7, [receipt]);
expect(walletService.addressToUser.has('late-address')).toBe(false);
expect(walletService.stopPaymentMonitoring).toHaveBeenCalledWith('late-address');
});
test('durable late receipt stays expired and creates review without any entitlement write', async () => {
const calls = [];
const client = {
query: jest.fn(async (sql, params = []) => {
calls.push({ sql, params });
if (/SELECT id, status, expected_amount/i.test(sql)) {
return { rows: [{
id: 8, status: 'expired', expected_amount: '100',
provider_id: 'native-monero', address_index: 12
}] };
}
if (/COALESCE\(SUM\(amount\)/i.test(sql)) {
return { rows: [{ total: '100', primary_tx_hash: 'c'.repeat(64), receipt_count: '1' }] };
}
return { rows: [], rowCount: 1 };
})
};
const manager = Object.create(GameModeManager.prototype);
manager.db = { withTransaction: async callback => callback(client) };
const outputId = 'd'.repeat(64);
const receipt = {
evidenceType: 'chain_output', providerId: 'native-monero',
txHash: 'c'.repeat(64), outputId,
evidenceId: `${'c'.repeat(64)}:${outputId}`,
addressIndex: 12, amount: '100', confirmed: true
};
const result = await manager.reconcileLatePaymentForReview(8, [receipt]);
expect(result).toEqual(expect.objectContaining({
checked: true,
needsReview: true,
coverageComplete: true
}));
expect(calls.some(call => /INSERT INTO payment_receipts/i.test(call.sql))).toBe(true);
expect(calls.some(call => /INSERT INTO payment_late_reviews/i.test(call.sql))).toBe(true);
expect(calls.some(call => /SET status = CASE WHEN status = 'pending' THEN 'expired'/i.test(call.sql))).toBe(true);
expect(calls.some(call => /UPDATE users|payment_entitlement_grants|credit_transactions/i.test(call.sql))).toBe(false);
});
});
+42
View File
@@ -0,0 +1,42 @@
const { buildCommerceDisclosure } = require('../src/config/commerceDisclosurePolicy');
const { renderPrivacy, renderResponsiblePlay, renderTerms } = require('../src/views/legalPages');
function disclosure(overrides = {}) {
const manager = {
cryptoType: 'WOW', currencyLabel: 'WOW', network: 'mainnet', isTestNetwork: false,
gameName: '<script>bad</script>', paymentsEnabled: true, freePlayEnabled: true,
directModeEnabled: true, creditsModeEnabled: true, payoutsEnabled: false,
isPayoutEnabledForMode: () => false
};
return buildCommerceDisclosure(manager, {
NODE_ENV: 'production', LEGAL_POLICY_VERSION: 'v1', TERMS_EFFECTIVE_DATE: '2026-07-21',
MINIMUM_AGE: '18', PAID_ACKNOWLEDGEMENT_REQUIRED: 'true',
OPERATOR_NAME: '<img src=x onerror=alert(1)>',
OPERATOR_CONTACT_URL: 'javascript:alert(1)',
...overrides
});
}
describe('production disclosure pages', () => {
test.each([
['terms', renderTerms, 'Paid prestige mode'],
['privacy', renderPrivacy, 'random browser session token'],
['responsible play', renderResponsiblePlay, 'automated self-exclusion system']
])('%s page is mode-aware, complete, and HTML-escapes operator data', (_name, render, expected) => {
const html = render(disclosure());
expect(html).toContain(expected);
expect(html).toContain('/responsible-play');
expect(html).toContain('&lt;script&gt;bad&lt;/script&gt;');
expect(html).toContain('&lt;img src=x onerror=alert(1)&gt;');
expect(html).not.toContain('<script>bad</script>');
expect(html).not.toContain('href="javascript:');
});
test('terms state the exact solo and PvP leaderboard mappings', () => {
const html = renderTerms(disclosure());
expect(html).toContain('FREE runs use the Pleb board');
expect(html).toContain('PAID_SINGLE and PAID_CREDITS runs use the Hall of Champions');
expect(html).toContain('credits_prestige matches use the Prestige board');
expect(html).toContain('crypto_race matches use the Hall of Champions');
});
});
+19
View File
@@ -0,0 +1,19 @@
const fs = require('fs');
const path = require('path');
describe('match client durable finish and fairness messaging', () => {
const source = fs.readFileSync(path.join(__dirname, '../html/js/matchClient.js'), 'utf8');
test('does not treat a settlement-pending event as a final result', () => {
const pending = source.match(/socket\.on\('match_settlement_pending',[\s\S]*?\n \}\);/);
expect(pending).toBeTruthy();
expect(pending[0]).toContain('not final yet');
expect(pending[0]).not.toContain('inRace = false');
});
test('checks the revealed seed against the published SHA-256 commitment', () => {
expect(source).toContain("subtle.digest('SHA-256'");
expect(source).toContain("actual === String(fairness.seedHash).toLowerCase()");
expect(source).toContain('seed proof ✓');
});
});
+497
View File
@@ -0,0 +1,497 @@
const MatchQueue = require('../src/network/matchQueue');
const MatchRoom = require('../src/multiplayer/MatchRoom');
const MatchScheduler = require('../src/network/matchScheduler');
const MatchManager = require('../src/network/matchManager');
describe('match queue crash/restart invariants', () => {
const priorMatchEnabled = process.env.MATCH_ENABLED;
beforeEach(() => {
process.env.MATCH_ENABLED = 'true';
});
afterEach(() => {
if (priorMatchEnabled === undefined) delete process.env.MATCH_ENABLED;
else process.env.MATCH_ENABLED = priorMatchEnabled;
});
test('persisting a match links every exact queue row before inserting linked entrants', async () => {
const captured = [];
const entries = [
{ userId: 7, socketId: 's7', queueEntryId: 101 },
{ userId: 8, socketId: 's8', queueEntryId: 102 }
];
const db = {
async withTransaction(fn) {
return fn({
async query(sql, params) {
captured.push({ sql, params });
if (sql.includes('UPDATE match_queue_entries') && sql.includes('SET match_id')) {
return {
rows: entries.map(entry => ({
id: entry.queueEntryId,
user_id: entry.userId
})),
rowCount: entries.length
};
}
return { rows: [{ id: 1 }], rowCount: 1 };
}
});
}
};
const room = new MatchRoom({
economy: 'crypto_race',
entrants: {
s7: { userId: 7, socketId: 's7' },
s8: { userId: 8, socketId: 's8' }
},
seed: 'a'.repeat(64)
});
const scheduler = new MatchScheduler({ matchQueue: {}, matchManager: { db } });
await scheduler._persistMatch(room, entries, 'crypto_race');
const link = captured.find(call => call.sql.includes('SET match_id'));
expect(link.params).toEqual([room.id, [101, 102], 'crypto_race']);
expect(link.sql).toContain("status = 'matched'");
expect(link.sql).toContain('match_id IS NULL');
const entrantInserts = captured.filter(call => call.sql.includes('INSERT INTO match_entrants'));
expect(entrantInserts.map(call => call.params)).toEqual([
[room.id, 7, 's7', 101],
[room.id, 8, 's8', 102]
]);
scheduler.shutdown();
});
test('a finished match with linked consumed tickets is never treated as stale escrow', async () => {
let staleSql = '';
const db = {
query: jest.fn(async (sql) => {
if (sql.includes('FROM matches')) return { rows: [], rowCount: 0 };
if (sql.includes('FROM match_queue_entries') && sql.includes("status = 'matched'")) {
staleSql = sql;
return { rows: [], rowCount: 0 };
}
return { rows: [], rowCount: 0 };
}),
withTransaction: jest.fn()
};
const queue = new MatchQueue({ db });
await queue.initialize();
await queue.shutdown();
expect(staleSql).toContain("status = 'matched' AND match_id IS NULL");
expect(staleSql).not.toContain("status = 'consumed'");
expect(db.withTransaction).not.toHaveBeenCalled();
});
test('a crash after drain but before match persistence refunds the unlinked hold exactly once', async () => {
const state = {
queueStatus: 'matched',
matchId: null,
lotRemaining: 0,
userTickets: 0,
ledgerRows: 0
};
const db = {
async query(sql) {
if (sql.includes('FROM match_queue_entries')
&& state.queueStatus === 'matched'
&& state.matchId === null) {
return {
rows: [{
id: 41,
user_id: 9,
economy: 'crypto_race',
escrow_amount: '1',
race_entry_lot_id: 71
}],
rowCount: 1
};
}
return { rows: [], rowCount: 0 };
},
async withTransaction(fn) {
return fn({
async query(sql) {
if (sql.includes('UPDATE match_queue_entries')) {
expect(sql).toContain("status = 'matched' AND match_id IS NULL");
if (state.queueStatus !== 'matched' || state.matchId !== null) {
return { rows: [], rowCount: 0 };
}
state.queueStatus = 'cancelled';
return {
rows: [{ id: 41, escrow_amount: '1', race_entry_lot_id: 71 }],
rowCount: 1
};
}
if (sql.includes('UPDATE race_entry_lots')) {
expect(sql).toContain('refunded_at IS NULL');
expect(sql).toContain('remaining_entries + $1 <= original_entries');
state.lotRemaining += 1;
return { rows: [{ id: 71 }], rowCount: 1 };
}
if (sql.includes('UPDATE users')) {
state.userTickets += 1;
return { rows: [{ race_entries: state.userTickets }], rowCount: 1 };
}
if (sql.includes('INSERT INTO race_entry_transactions')) {
state.ledgerRows += 1;
return { rows: [], rowCount: 1 };
}
return { rows: [], rowCount: 0 };
}
});
}
};
const queue = new MatchQueue({ db });
await queue._recoverStaleQueueEntries();
await queue._recoverStaleQueueEntries();
expect(state).toEqual(expect.objectContaining({
queueStatus: 'cancelled',
lotRemaining: 1,
userTickets: 1,
ledgerRows: 1
}));
});
test('an abandoned starting match restores a consumed funded ticket only once', async () => {
const state = {
matchStatus: 'starting',
queueStatus: 'consumed',
lotRemaining: 0,
userTickets: 0,
entrantRefunded: false,
ledgerRows: 0
};
const db = {
async query(sql) {
if (sql.includes('FROM matches') && state.matchStatus === 'starting') {
return { rows: [{ id: 'match-9', economy: 'crypto_race' }], rowCount: 1 };
}
return { rows: [], rowCount: 0 };
},
async withTransaction(fn) {
return fn({
async query(sql) {
if (sql.includes('SELECT id FROM payouts')) return { rows: [], rowCount: 0 };
if (sql.includes('UPDATE matches SET status')) {
if (state.matchStatus !== 'starting') return { rows: [], rowCount: 0 };
state.matchStatus = 'cancelled';
return { rows: [{ id: 'match-9' }], rowCount: 1 };
}
if (sql.includes('FROM match_entrants') && sql.includes('queue_entry_id')) {
return {
rows: [{ id: 91, user_id: 9, queue_entry_id: 41 }],
rowCount: 1
};
}
if (sql.includes('UPDATE match_queue_entries') && sql.includes('match_id = $2')) {
if (state.queueStatus !== 'consumed') return { rows: [], rowCount: 0 };
state.queueStatus = 'cancelled';
return {
rows: [{ id: 41, escrow_amount: '1', race_entry_lot_id: 71 }],
rowCount: 1
};
}
if (sql.includes('UPDATE race_entry_lots')) {
state.lotRemaining += 1;
return { rows: [{ id: 71 }], rowCount: 1 };
}
if (sql.includes('UPDATE users')) {
state.userTickets += 1;
return { rows: [{ race_entries: state.userTickets }], rowCount: 1 };
}
if (sql.includes('INSERT INTO race_entry_transactions')) {
state.ledgerRows += 1;
return { rows: [], rowCount: 1 };
}
if (sql.includes('UPDATE match_entrants')) {
state.entrantRefunded = true;
return { rows: [], rowCount: 1 };
}
return { rows: [], rowCount: 0 };
}
});
}
};
const queue = new MatchQueue({ db });
await queue._recoverAbandonedMatches();
await queue._recoverAbandonedMatches();
expect(state).toEqual({
matchStatus: 'cancelled',
queueStatus: 'cancelled',
lotRemaining: 1,
userTickets: 1,
entrantRefunded: true,
ledgerRows: 1
});
});
test('queue initialization stays closed when the abandoned-match scan fails', async () => {
const db = {
query: jest.fn(async sql => {
if (sql.includes('FROM matches')) throw new Error('scan unavailable');
return { rows: [], rowCount: 0 };
}),
withTransaction: jest.fn()
};
const queue = new MatchQueue({ db });
await expect(queue.initialize()).rejects.toMatchObject({
code: 'FINANCIAL_RECOVERY_INCOMPLETE',
recovery: {
scope: 'abandoned_matches',
scanFailed: true,
scanned: 0,
resolved: 0,
unresolved: []
}
});
expect(queue.initialized).toBe(false);
expect(db.withTransaction).not.toHaveBeenCalled();
});
test('queue initialization rejects after processing peers when one match remains unresolved', async () => {
let transactionCount = 0;
const db = {
query: jest.fn(async sql => {
if (sql.includes('FROM matches')) {
return {
rows: [
{ id: 'unresolved-match', economy: 'crypto_race' },
{ id: 'resolved-match', economy: 'crypto_race' }
],
rowCount: 2
};
}
return { rows: [], rowCount: 0 };
}),
withTransaction: jest.fn(async fn => {
transactionCount += 1;
if (transactionCount === 1) throw new Error('refund transaction failed');
return fn({
query: jest.fn(async sql => {
if (sql.includes('SELECT id FROM payouts')) {
return { rows: [{ id: 91 }], rowCount: 1 };
}
return { rows: [], rowCount: 1 };
})
});
})
};
const queue = new MatchQueue({ db });
await expect(queue.initialize()).rejects.toMatchObject({
code: 'FINANCIAL_RECOVERY_INCOMPLETE',
recovery: {
scope: 'abandoned_matches',
scanFailed: false,
scanned: 2,
resolved: 1,
unresolved: [{ type: 'match', id: 'unresolved-match' }]
}
});
expect(queue.initialized).toBe(false);
expect(db.withTransaction).toHaveBeenCalledTimes(2);
});
test('paid queue admission is closed before durable recovery completes', async () => {
const queue = new MatchQueue({ db: {} });
expect(await queue.join({ userId: 7, economy: 'credits_prestige' })).toEqual({
success: false,
reason: 'financial_recovery_pending'
});
});
test('a later liability-recovery failure closes paid joins, drains, and freezes dynamically', async () => {
const queue = new MatchQueue({
db: {},
isFinancialRecoveryReady: () => false
});
queue.initialized = true;
queue._queues.credits_prestige.push(
{ queueEntryId: 1, userId: 1, socketId: 's1' },
{ queueEntryId: 2, userId: 2, socketId: 's2' }
);
expect(await queue.join({ userId: 7, economy: 'credits_prestige' })).toEqual({
success: false,
reason: 'financial_recovery_pending'
});
await expect(queue.drain('credits_prestige', 2, 2)).resolves.toBeNull();
await expect(queue.freezePaidMatch({
economy: 'credits_prestige',
maxPlayers: 2,
minPlayers: 2,
freezeBlockHeight: 10,
targetBlockHeight: 11,
rulesetId: 'race'
})).resolves.toBeNull();
});
test('MATCH_ENABLED=false still refunds durable queued escrow during boot', async () => {
process.env.MATCH_ENABLED = 'false';
let userTickets = 0;
let lotTickets = 0;
let ledgerRows = 0;
const db = {
async query(sql) {
if (sql.includes('FROM matches')) return { rows: [], rowCount: 0 };
if (sql.includes("status = 'matched'") && sql.includes('INTERVAL')) {
return { rows: [], rowCount: 0 };
}
if (sql.includes("WHERE status = 'queued'")) {
return {
rows: [{
id: 55,
user_id: 12,
economy: 'crypto_race',
socket_id: 'old-socket',
session_token: 'session',
created_at: new Date().toISOString()
}],
rowCount: 1
};
}
return { rows: [], rowCount: 0 };
},
async withTransaction(fn) {
return fn({
async query(sql) {
if (sql.includes('DELETE FROM match_queue_entries')) {
return {
rows: [{ id: 55, escrow_amount: '1', race_entry_lot_id: 77 }],
rowCount: 1
};
}
if (sql.includes('UPDATE users')) {
userTickets += 1;
return { rows: [{ race_entries: userTickets }], rowCount: 1 };
}
if (sql.includes('UPDATE race_entry_lots')) {
lotTickets += 1;
return { rows: [{ id: 77 }], rowCount: 1 };
}
if (sql.includes('INSERT INTO race_entry_transactions')) {
ledgerRows += 1;
return { rows: [], rowCount: 1 };
}
return { rows: [], rowCount: 0 };
}
});
}
};
const queue = new MatchQueue({ db });
await queue.initialize();
await queue.shutdown();
expect(queue.isEnabled()).toBe(false);
expect(queue.length('crypto_race')).toBe(0);
expect({ userTickets, lotTickets, ledgerRows }).toEqual({
userTickets: 1,
lotTickets: 1,
ledgerRows: 1
});
});
test('MATCH_ENABLED=false still reconciles an already-finished accepted liability', async () => {
process.env.MATCH_ENABLED = 'false';
const manager = new MatchManager({ db: {}, io: null });
manager.matchPayoutService.reconcileFinishedLiabilities = jest.fn().mockResolvedValue({
scanned: 1,
created: 1,
failed: 0
});
await manager.initialize();
expect(manager.enabled).toBe(false);
expect(manager.matchPayoutService.reconcileFinishedLiabilities).toHaveBeenCalledTimes(1);
manager.shutdown();
});
test('finished accepted-liability failures keep match recovery and startup closed', async () => {
const manager = new MatchManager({ db: {}, io: null });
manager.matchPayoutService.reconcileFinishedLiabilities = jest.fn().mockResolvedValue({
ok: false,
scanned: 1,
created: 0,
failed: 1,
unresolved: [{ type: 'match_liability', id: 'match-owed' }]
});
await expect(manager.initialize()).rejects.toMatchObject({
code: 'FINANCIAL_RECOVERY_INCOMPLETE',
recovery: {
scope: 'finished_match_liabilities',
scanFailed: false,
scanned: 1,
resolved: 0,
unresolved: [{ type: 'match_liability', id: 'match-owed' }]
}
});
expect(manager.financialRecoveryReady).toBe(false);
expect(manager._liabilityReconcileTimer).toBeNull();
manager.shutdown();
});
test('finished-liability scan errors have explicit fail-closed startup semantics', async () => {
const manager = new MatchManager({ db: {}, io: null });
manager.matchPayoutService.reconcileFinishedLiabilities = jest.fn()
.mockRejectedValue(new Error('liability scan unavailable'));
await expect(manager.initialize()).rejects.toMatchObject({
code: 'FINANCIAL_RECOVERY_INCOMPLETE',
recovery: {
scope: 'finished_match_liabilities',
scanFailed: true,
scanned: 0,
resolved: 0,
unresolved: []
}
});
expect(manager.financialRecoveryReady).toBe(false);
manager.shutdown();
});
test('bounded reconciliation retries a later transient failure and reopens the dynamic gate', async () => {
const manager = new MatchManager({ db: {}, io: null });
manager.matchPayoutService.reconcileFinishedLiabilities = jest.fn().mockResolvedValue({
ok: true, scanned: 0, created: 0, failed: 0, unresolved: []
});
await manager.initialize();
manager.matchPayoutService.reconcileFinishedLiabilities
.mockResolvedValueOnce({
ok: false,
scanned: 1,
created: 0,
failed: 1,
unresolved: [{ type: 'match_liability', id: 'match-retry' }]
})
.mockResolvedValueOnce({
ok: true,
scanned: 1,
created: 1,
failed: 0,
unresolved: []
});
const failed = await manager._reconcileFinishedLiabilities({ failClosed: false });
expect(failed.ok).toBe(false);
expect(manager.financialRecoveryReady).toBe(false);
const retried = await manager._reconcileFinishedLiabilities({ failClosed: false });
expect(retried).toMatchObject({ ok: true, scanned: 1, created: 1, failed: 0 });
expect(manager.financialRecoveryReady).toBe(true);
manager.shutdown();
});
});
+84
View File
@@ -1,5 +1,6 @@
const MatchLeaderboard = require('../src/network/matchLeaderboard');
const MatchRoom = require('../src/multiplayer/MatchRoom');
const { WIN } = require('../src/game/rulesets');
describe('MatchLeaderboard', () => {
function makeDb() {
@@ -64,4 +65,87 @@ describe('MatchLeaderboard', () => {
expect(gamesInserts.length).toBe(2);
expect(gamesInserts[0].params[2]).toBe('PAID_CREDITS');
});
test('last-alive win/loss follows winnerId + placement, never the escaped flag', async () => {
const db = makeDb();
const lb = new MatchLeaderboard({ db });
const room = new MatchRoom({
entrants: { a: { userId: 1 }, b: { userId: 2 } },
economy: 'free',
ruleset: {
id: 'test-last-alive', mode: 'pvp',
entities: { monster: false, pvpCombat: true },
players: { min: 2, max: 2 },
winCondition: { type: WIN.LAST_ALIVE }
},
seed: '0'.repeat(64)
});
room.start();
room._finishPlayer('a', true);
room.winCondition.onExit(room, 'a');
room.finalize();
expect(room.playerStates.get('a').escaped).toBe(true);
expect(room.winnerId).toBe('b');
expect(room.playerStates.get('b').placement).toBe(1);
await lb.postMatch(room);
const inserts = db.captured.filter(q => q.sql.includes('INSERT INTO games'));
const bySocket = new Map(inserts.map(q => [q.params[1], q.params]));
expect(bySocket.get('a').slice(3, 5)).toEqual(['lost', 'match_loss']);
expect(bySocket.get('b').slice(3, 5)).toEqual(['won', 'match_winner']);
});
test('score-attack awards the authoritative score winner even when another entrant escaped', async () => {
const db = makeDb();
const lb = new MatchLeaderboard({ db });
const room = new MatchRoom({
entrants: { a: { userId: 1 }, b: { userId: 2 } },
economy: 'free',
ruleset: {
id: 'test-score', mode: 'race', entities: { monster: false },
players: { min: 2, max: 2 }, winCondition: { type: WIN.HIGH_SCORE }
},
seed: '0'.repeat(64)
});
room.start();
room.playerStates.get('a').escaped = true;
room.playerStates.get('a').finished = true;
room.playerStates.get('a').moves = 200;
room.playerStates.get('b').hasTreasure = true;
room.playerStates.get('b').moves = 1;
room.expire('hard_ceiling');
room.finalize();
expect(room.winnerId).toBe('b');
await lb.postMatch(room);
const inserts = db.captured.filter(q => q.sql.includes('INSERT INTO games'));
const bySocket = new Map(inserts.map(q => [q.params[1], q.params]));
expect(bySocket.get('a')[3]).toBe('lost');
expect(bySocket.get('b')[3]).toBe('won');
});
test('co-op results stay in match history and never enter individual boards', async () => {
const emitted = [];
const db = makeDb();
const lb = new MatchLeaderboard({ db, io: { emit: (e, p) => emitted.push({ e, p }) } });
const room = new MatchRoom({
entrants: { a: { userId: 1 }, b: { userId: 2 } },
economy: 'free',
ruleset: {
id: 'coop-escape', mode: 'coop',
entities: { monster: false }, players: { min: 2, max: 2 },
winCondition: { type: WIN.ALL_ESCAPE }
},
seed: '0'.repeat(64)
});
room.start();
room.expire('hard_ceiling');
room.finalize();
expect(room.winnerId).toBeNull();
await lb.postMatch(room);
expect(db.captured).toHaveLength(0);
expect(emitted).toHaveLength(0);
});
});
+297
View File
@@ -0,0 +1,297 @@
const MatchManager = require('../src/network/matchManager');
const MatchPayoutService = require('../src/network/matchPayoutService');
const MatchQueue = require('../src/network/matchQueue');
const MatchRoom = require('../src/multiplayer/MatchRoom');
const MatchScheduler = require('../src/network/matchScheduler');
const {
buildPaidEntrantFreeze,
deriveFutureBlockMatchSeed
} = require('../src/network/matchFairness');
const MATCH_ENV_KEYS = [
'MATCH_ENABLED', 'MATCH_CRYPTO_RACE_ENABLED', 'MATCH_PAYOUTS_ENABLED',
'MATCH_PAYOUT_MAX', 'MATCH_ENTRY_FEE_ATOMIC', 'MATCH_HOUSE_FEE_PERCENT',
'MATCH_RULESET_ID', 'MATCH_MAX_PLAYERS', 'PAYOUTS_ENABLED', 'PAYMENTS_ENABLED'
];
describe('match payout liability production invariants', () => {
let priorEnv;
beforeEach(() => {
priorEnv = Object.fromEntries(MATCH_ENV_KEYS.map(key => [key, process.env[key]]));
Object.assign(process.env, {
MATCH_ENABLED: 'true',
MATCH_CRYPTO_RACE_ENABLED: 'true',
MATCH_PAYOUTS_ENABLED: 'true',
MATCH_PAYOUT_MAX: '1000000',
MATCH_ENTRY_FEE_ATOMIC: '10000',
MATCH_HOUSE_FEE_PERCENT: '5',
MATCH_RULESET_ID: 'race',
MATCH_MAX_PLAYERS: '4',
PAYOUTS_ENABLED: 'true',
PAYMENTS_ENABLED: 'true'
});
});
afterEach(() => {
for (const [key, value] of Object.entries(priorEnv)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
const gameModeManager = (overrides = {}) => ({
payoutsEnabled: true,
directModeEnabled: true,
creditsModeEnabled: false,
_scheduleBatchPayout: jest.fn(),
...overrides
});
function cryptoRoom(rulesetId = 'race') {
return new MatchRoom({
economy: 'crypto_race',
rulesetId,
entrants: { a: { userId: 1 }, b: { userId: 2 } },
seed: '0'.repeat(64)
});
}
test('MATCH_PAYOUT_MAX is checked before any ticket commitment transaction', async () => {
process.env.MATCH_PAYOUT_MAX = '18000'; // two entries owe 19,000 after the 5% fee
const db = { withTransaction: jest.fn() };
const service = new MatchPayoutService({ db, gameModeManager: gameModeManager() });
await expect(service.collectEntryTickets(cryptoRoom(), [
{ userId: 1, queueEntryId: 11 }, { userId: 2, queueEntryId: 12 }
]))
.rejects.toMatchObject({ code: 'PAYOUT_CAP_EXCEEDED' });
expect(db.withTransaction).not.toHaveBeenCalled();
});
test('crypto co-op is rejected before commitment because split semantics do not exist', async () => {
process.env.MATCH_RULESET_ID = 'coop-escape';
const db = { withTransaction: jest.fn() };
const service = new MatchPayoutService({ db, gameModeManager: gameModeManager() });
await expect(service.collectEntryTickets(cryptoRoom('coop-escape'), [
{ userId: 1, queueEntryId: 11 }, { userId: 2, queueEntryId: 12 }
]))
.rejects.toMatchObject({ code: 'UNSUPPORTED_CRYPTO_RULESET' });
expect(db.withTransaction).not.toHaveBeenCalled();
});
test('scheduler clamps entrants to the selected ruleset player contract', () => {
const scheduler = new MatchScheduler({
matchQueue: {},
matchManager: {},
rulesetId: 'race',
maxPlayers: 32
});
expect(scheduler.minPlayers).toBe(2);
expect(scheduler.maxPlayers).toBe(8);
scheduler.shutdown();
});
test('hot-disable refunds queued crypto escrow and removes memory only after commit', async () => {
let available = true;
const captured = [];
const db = {
async withTransaction(fn) {
return fn({
async query(sql, params) {
captured.push({ sql, params });
if (sql.includes('UPDATE match_queue_entries')) return { rows: [{ id: 1 }], rowCount: 1 };
if (sql.includes('UPDATE users')) return { rows: [{ race_entries: 2 }], rowCount: 1 };
return { rows: [{ id: 1 }], rowCount: 1 };
}
});
}
};
const manager = gameModeManager({
_getMatchEconomies: () => available ? { free: true, crypto_race: true } : { free: true }
});
const queue = new MatchQueue({ db, gameModeManager: manager });
queue._queues.crypto_race.push({ userId: 1, socketId: 'a', createdAt: 1 });
available = false;
await queue.refundUnavailableQueues();
expect(queue.length('crypto_race')).toBe(0);
expect(captured.some(q => q.sql.includes('race_entries = race_entries + $1'))).toBe(true);
expect(captured.some(q => q.sql.includes('INSERT INTO race_entry_transactions'))).toBe(true);
});
test('failed hot-disable refund remains queued for the next reconciliation attempt', async () => {
const db = { async withTransaction() { throw new Error('db down'); } };
const manager = gameModeManager({ _getMatchEconomies: () => ({ free: true }) });
const queue = new MatchQueue({ db, gameModeManager: manager });
queue._queues.crypto_race.push({ userId: 1, socketId: 'a', createdAt: 1 });
await queue.refundUnavailableQueues();
expect(queue.length('crypto_race')).toBe(1);
});
test('finished liability is reconciled after a transient payout insertion failure', async () => {
let insertFails = true;
let payoutInserted = false;
let successfulInserts = 0;
const captured = [];
const db = {
async query(sql) {
if (sql.includes('SELECT m.id')) {
return payoutInserted
? { rows: [], rowCount: 0 }
: { rows: [{ id: 'match-1' }], rowCount: 1 };
}
return { rows: [], rowCount: 0 };
},
async withTransaction(fn) {
return fn({
async query(sql, params) {
captured.push({ sql, params });
if (sql.includes('FROM matches') && sql.includes('FOR UPDATE')) {
return { rows: [{
id: 'match-1', economy: 'crypto_race', status: 'finished', winner_user_id: 7,
payout_liability_amount_atomic: '19000',
payout_liability_cap_atomic: '1000000',
payout_liability_accepted_at: new Date().toISOString()
}], rowCount: 1 };
}
if (sql.includes('FROM payouts')) {
return payoutInserted
? { rows: [{ id: 88 }], rowCount: 1 }
: { rows: [], rowCount: 0 };
}
if (sql.includes('FROM users')) return { rows: [{ id: 7, payout_address: 'wallet' }], rowCount: 1 };
if (sql.includes('INSERT INTO payouts') && insertFails) throw new Error('insert unavailable');
if (sql.includes('INSERT INTO payouts')) {
payoutInserted = true;
successfulInserts += 1;
}
return { rows: [{ id: 88 }], rowCount: 1 };
}
});
}
};
const manager = gameModeManager();
const service = new MatchPayoutService({ db, gameModeManager: manager });
expect(await service.reconcileFinishedLiabilities()).toEqual({
ok: false,
scanned: 1,
created: 0,
failed: 1,
unresolved: [{ type: 'match_liability', id: 'match-1' }]
});
insertFails = false;
expect(await service.reconcileFinishedLiabilities()).toEqual({
ok: true,
scanned: 1,
created: 1,
failed: 0,
unresolved: []
});
expect(await service.reconcileFinishedLiabilities()).toEqual({
ok: true,
scanned: 0,
created: 0,
failed: 0,
unresolved: []
});
expect(successfulInserts).toBe(1);
expect(manager._scheduleBatchPayout).toHaveBeenCalledTimes(1);
expect(captured.filter(q => q.sql.includes('INSERT INTO payouts'))).toHaveLength(2);
});
test('manager durably persists completion before attempting liability insertion', async () => {
const manager = new MatchManager({ io: null, db: {}, debugManager: null });
manager.enabled = true;
const order = [];
manager._persistFinish = async () => { order.push('persist'); };
manager.matchPayoutService.payoutWinner = async () => { order.push('payout'); };
manager.matchLeaderboard.postMatch = async () => { order.push('leaderboard'); };
const room = cryptoRoom();
room.winnerId = 'a';
room.status = 'finished';
await manager._finalize(room);
expect(order.slice(0, 2)).toEqual(['persist', 'payout']);
manager.shutdown();
});
test('a future-block-frozen match with an ambiguous collect acknowledgement continues only when the accepted snapshot exists', async () => {
const entries = [
{ userId: 1, socketId: 'a', sessionToken: 'ta', queueEntryId: 11 },
{ userId: 2, socketId: 'b', sessionToken: 'tb', queueEntryId: 12 }
];
const matchQueue = {
refundEntries: jest.fn()
};
const payoutService = {
collectEntryTickets: jest.fn().mockRejectedValue(new Error('COMMIT acknowledgement lost')),
getAcceptedLiability: jest.fn().mockResolvedValue({
entry_fee_atomic: '10000',
pot_atomic: '20000',
house_fee_atomic: '1000',
house_fee_percent: '5',
payout_liability_amount_atomic: '19000',
payout_liability_cap_atomic: '1000000',
payout_liability_terms: { version: 2 }
})
};
const matchManager = {
db: null,
gameModeManager: gameModeManager(),
matchPayoutService: payoutService,
setEngine: jest.fn(),
attach: jest.fn(),
onTick: jest.fn(),
onFinish: jest.fn()
};
const scheduler = new MatchScheduler({
matchQueue,
matchManager,
blockCountProvider: async () => 902,
blockEntropyProvider: async height => ({
block_header: { height, hash: 'ab'.repeat(32) }
})
});
const freeze = buildPaidEntrantFreeze({
freezeBlockHeight: 899,
targetBlockHeight: 900,
economy: 'crypto_race',
rulesetId: 'race',
queueEntryIds: entries.map(entry => entry.queueEntryId)
});
const seedProof = deriveFutureBlockMatchSeed({
blockHash: 'ab'.repeat(32),
blockHeight: 900,
freeze
});
await scheduler._startEntries({
entries,
economy: 'crypto_race',
blockHeight: 900,
seedProof,
frozen: {
id: '11111111-1111-4111-8111-111111111111',
freeze,
freezeCommitment: freeze.freezeCommitment,
entries
}
});
expect(payoutService.collectEntryTickets).toHaveBeenCalledTimes(1);
expect(payoutService.getAcceptedLiability).toHaveBeenCalledTimes(1);
expect(matchQueue.refundEntries).not.toHaveBeenCalled();
expect(matchManager.attach).toHaveBeenCalledTimes(1);
const room = matchManager.attach.mock.calls[0][0];
expect(room.payoutLiabilityAmountAtomic).toBe('19000');
scheduler.shutdown();
});
});
+120 -22
View File
@@ -2,33 +2,84 @@ const MatchPayoutService = require('../src/network/matchPayoutService');
const MatchRoom = require('../src/multiplayer/MatchRoom');
describe('MatchPayoutService', () => {
function makeDb() {
const enabledEnv = (overrides = {}) => ({
MATCH_ENABLED: 'true',
MATCH_CRYPTO_RACE_ENABLED: 'true',
MATCH_PAYOUTS_ENABLED: 'true',
MATCH_PAYOUT_MAX: '100000000000000000',
MATCH_ENTRY_FEE_ATOMIC: '10000',
MATCH_HOUSE_FEE_PERCENT: '5',
PAYOUTS_ENABLED: 'true',
PAYMENTS_ENABLED: 'true',
MATCH_RULESET_ID: 'race',
MATCH_MAX_PLAYERS: '4',
CRYPTO_TYPE: 'XMR',
MONERO_NETWORK: 'stagenet',
...overrides
});
function makeDb({ address = 'WALLET_ADDRESS', accepted = true, amount = '19000', cap = '1000000', existing = null } = {}) {
const captured = [];
return {
captured,
async query(sql, params) {
if (sql.includes('SELECT pot_atomic')) return { rows: [{ pot_atomic: '20000', house_fee_atomic: '1000' }] };
if (sql.includes('SELECT id, payout_address')) return { rows: [{ id: 1, payout_address: 'WALLET_ADDRESS' }] };
async query(sql) {
if (sql.includes('SELECT m.id')) return { rows: [] };
return { rows: [], rowCount: 0 };
},
async withTransaction(fn) {
const client = {
async query(sql, params) {
captured.push({ sql, params });
if (sql.includes('SELECT id FROM payouts')) return { rows: [] };
if (sql.includes('FROM match_queue_entries') && sql.includes('FOR UPDATE')) {
const ids = params[0];
return {
rows: ids.map((id, index) => ({
id,
user_id: index + 1,
race_entry_lot_id: 100 + index,
escrow_amount: '1',
escrow_value_atomic: '10000'
})),
rowCount: ids.length
};
}
if (sql.includes('FROM matches') && sql.includes('FOR UPDATE')) {
return { rows: [{
id: params[0], economy: 'crypto_race', status: 'finished', winner_user_id: 1,
payout_liability_amount_atomic: amount,
payout_liability_cap_atomic: cap,
payout_liability_accepted_at: accepted ? new Date().toISOString() : null
}], rowCount: 1 };
}
if (sql.includes('FROM payouts') && sql.includes('LIMIT 1')) {
return { rows: existing ? [existing] : [], rowCount: existing ? 1 : 0 };
}
if (sql.includes('FROM users') && sql.includes('payout_address')) {
return { rows: [{ id: 1, payout_address: address }], rowCount: 1 };
}
return { rows: [{ id: 99 }], rowCount: 1 };
}
};
return await fn(client);
return fn(client);
}
};
}
function manager(overrides = {}) {
return {
payoutsEnabled: true,
directModeEnabled: true,
creditsModeEnabled: false,
_scheduleBatchPayout: jest.fn(),
...overrides
};
}
function makeRoom() {
const room = new MatchRoom({
economy: 'crypto_race',
entrants: { a: { userId: 1 }, b: { userId: 2 } },
seed: '0000000000000000000000000000000000000000000000000000000000000000'
seed: '0'.repeat(64)
});
room.start();
room.playerStates.get('a').escaped = true;
@@ -38,30 +89,77 @@ describe('MatchPayoutService', () => {
return room;
}
test('collectEntryTickets locks pot on match row', async () => {
test('collectEntryTickets atomically locks exact pot and accepted payout terms', async () => {
const db = makeDb();
const service = new MatchPayoutService({ db });
const service = new MatchPayoutService({ db, env: enabledEnv(), gameModeManager: manager() });
const room = makeRoom();
await service.collectEntryTickets(room, [
{ userId: 1, socketId: 'a' },
{ userId: 2, socketId: 'b' }
{ userId: 1, socketId: 'a', queueEntryId: 11 },
{ userId: 2, socketId: 'b', queueEntryId: 12 }
]);
const updateMatch = db.captured.find(q => q.sql.includes('UPDATE matches'));
const updateMatch = db.captured.find(q => q.sql.includes('payout_liability_accepted_at = NOW()'));
expect(updateMatch).toBeTruthy();
expect(room.potAtomic).toBeGreaterThan(0);
expect(updateMatch.params.slice(0, 6)).toEqual([
'10000', '20000', '1000', 5, '19000', '100000000000000000'
]);
expect(JSON.parse(updateMatch.params[6])).toEqual(expect.objectContaining({
playerCount: 2,
payoutAmountAtomic: '19000',
rulesetId: 'race'
}));
expect(room.potAtomic).toBe('20000');
});
test('payoutWinner inserts one pending payout for winner', async () => {
test('accepted ON -> OFF liability still creates a pending payout', async () => {
const db = makeDb();
const service = new MatchPayoutService({ db });
const room = makeRoom();
room.entryFeeAtomic = 10000;
room.potAtomic = 20000;
room.houseFeeAtomic = 1000;
await service.payoutWinner(room);
const gmm = manager({ payoutsEnabled: false });
const service = new MatchPayoutService({
db,
env: enabledEnv({ MATCH_PAYOUTS_ENABLED: 'false', PAYOUTS_ENABLED: 'false' }),
gameModeManager: gmm
});
const result = await service.payoutWinner(makeRoom());
const insert = db.captured.find(q => q.sql.includes('INSERT INTO payouts'));
expect(insert).toBeTruthy();
expect(insert.params[3]).toBe('19000'); // winner amount
expect(result).toEqual(expect.objectContaining({ created: true, amount: '19000' }));
expect(insert.params[3]).toBe('19000');
expect(insert.params[5]).toBe('match_winner');
// Global off pauses dispatch, but does not suppress recording the obligation.
expect(gmm._scheduleBatchPayout).toHaveBeenCalledTimes(1);
});
test('OFF -> ON never creates a retroactive match liability', async () => {
const db = makeDb({ accepted: false });
const service = new MatchPayoutService({ db, env: enabledEnv(), gameModeManager: manager() });
const result = await service.payoutWinner(makeRoom());
expect(result).toEqual({ created: false, reason: 'liability_not_accepted' });
expect(db.captured.some(q => q.sql.includes('INSERT INTO payouts'))).toBe(false);
});
test('needs_review payout identity blocks a replacement row', async () => {
const db = makeDb({ existing: { id: 77, status: 'needs_review' } });
const service = new MatchPayoutService({ db, env: enabledEnv(), gameModeManager: manager() });
const result = await service.payoutWinner(makeRoom());
expect(result).toEqual({ created: false, reason: 'payout_exists', payoutId: 77 });
expect(db.captured.some(q => q.sql.includes('INSERT INTO payouts'))).toBe(false);
});
test('winner with no address records a claimable needs_review liability', async () => {
const db = makeDb({ address: null });
const service = new MatchPayoutService({ db, env: enabledEnv(), gameModeManager: manager() });
await service.payoutWinner(makeRoom());
const insert = db.captured.find(q => q.sql.includes('INSERT INTO payouts'));
expect(insert.params[2]).toBe('PENDING_NO_ADDRESS');
expect(insert.params[5]).toBe('match_winner_no_address');
expect(insert.params[6]).toBe('needs_review');
});
});
+136 -2
View File
@@ -1,7 +1,10 @@
const MatchQueue = require('../src/network/matchQueue');
const SocketHandlers = require('../src/network/socketHandlers');
describe('MatchQueue', () => {
function makeDb() {
let nextQueueId = 1;
const queueUsers = new Map();
return {
queries: [],
lastParams: null,
@@ -18,9 +21,13 @@ describe('MatchQueue', () => {
this.queries.push({ sql, params });
let rows = [{ id: 1, created_at: new Date().toISOString(), credits: 100, race_entries: 1 }];
let rowCount = 1;
if (sql.includes('UPDATE match_queue_entries') && sql.includes('user_id = ANY')) {
if (sql.includes('INSERT INTO match_queue_entries')) {
const id = nextQueueId++;
queueUsers.set(String(id), params[0]);
rows = [{ id, created_at: new Date().toISOString() }];
} else if (sql.includes('UPDATE match_queue_entries') && sql.includes('id = ANY')) {
const ids = params[0] || [];
rows = ids.map((uid, i) => ({ id: i + 10, user_id: uid }));
rows = ids.map(id => ({ id, user_id: queueUsers.get(String(id)) }));
rowCount = ids.length;
}
return { rows, rowCount };
@@ -45,6 +52,21 @@ describe('MatchQueue', () => {
delete process.env.MATCH_ENABLED;
});
test('rejects a syntactically valid economy that the server has disabled', async () => {
process.env.MATCH_ENABLED = 'true';
const db = makeDb();
const gameModeManager = { _getMatchEconomies: () => ({ free: true }) };
const q = new MatchQueue({ db, gameModeManager });
q.initialized = true;
const result = await q.join({ userId: 1, socketId: 's', sessionToken: 't', economy: 'crypto_race' });
expect(result).toEqual({ success: false, reason: 'economy_disabled' });
expect(q.length('crypto_race')).toBe(0);
expect(db.queries).toHaveLength(0);
delete process.env.MATCH_ENABLED;
});
test('free join adds to memory queue', async () => {
process.env.MATCH_ENABLED = 'true';
const db = makeDb();
@@ -60,6 +82,7 @@ describe('MatchQueue', () => {
process.env.MATCH_CREDITS_COST = '5';
const db = makeDb();
const q = new MatchQueue({ db });
q.initialized = true;
const result = await q.join({ userId: 1, socketId: 's1', sessionToken: 't1', economy: 'credits_prestige' });
expect(result.success).toBe(true);
expect(q.length('credits_prestige')).toBe(1);
@@ -68,6 +91,83 @@ describe('MatchQueue', () => {
delete process.env.MATCH_CREDITS_COST;
});
test.each([
['credits_prestige', 'credits'],
['crypto_race', 'race_entries']
])('concurrent duplicate %s joins hold escrow exactly once', async (economy, debitColumn) => {
process.env.MATCH_ENABLED = 'true';
if (economy === 'crypto_race') {
process.env.MATCH_CRYPTO_RACE_ENABLED = 'true';
process.env.MATCH_PAYOUTS_ENABLED = 'true';
process.env.MATCH_PAYOUT_MAX = '1000000';
process.env.MATCH_ENTRY_FEE_ATOMIC = '10000';
process.env.MATCH_HOUSE_FEE_PERCENT = '5';
process.env.MATCH_MAX_PLAYERS = '4';
}
let queued = false;
let debits = 0;
let joinLedgerRows = 0;
const createdAt = new Date().toISOString();
const db = {
async withTransaction(fn) {
return fn({
async query(sql) {
if (sql.includes('INSERT INTO match_queue_entries')) {
if (queued) return { rows: [], rowCount: 0 };
queued = true;
return { rows: [{ id: 77, created_at: createdAt }], rowCount: 1 };
}
if (sql.includes('UPDATE match_queue_entries') && sql.includes("status = 'queued'")) {
return { rows: [{ id: 77, created_at: createdAt }], rowCount: 1 };
}
if (sql.includes('UPDATE users') && sql.includes(`${debitColumn} = ${debitColumn} -`)) {
debits += 1;
return {
rows: [{ credits: 9, race_entries: 0 }],
rowCount: 1
};
}
if (sql.includes('SELECT id, unit_value_atomic') && sql.includes('FROM race_entry_lots')) {
return { rows: [{ id: 501, unit_value_atomic: '10000' }], rowCount: 1 };
}
if (sql.includes('UPDATE race_entry_lots')) {
return { rows: [{ id: 501 }], rowCount: 1 };
}
if (sql.includes('INSERT INTO credit_transactions') || sql.includes('INSERT INTO race_entry_transactions')) {
joinLedgerRows += 1;
return { rows: [], rowCount: 1 };
}
return { rows: [], rowCount: 0 };
}
});
}
};
const gameModeManager = economy === 'crypto_race' ? {
payoutsEnabled: true,
directModeEnabled: true,
creditsModeEnabled: false,
_getMatchEconomies: () => ({ free: true, crypto_race: true })
} : null;
const q = new MatchQueue({ db, gameModeManager });
q.initialized = true;
const request = () => q.join({ userId: 9, socketId: 's9', sessionToken: 't9', economy });
const results = await Promise.all([request(), request()]);
expect(results.every(result => result.success)).toBe(true);
expect(results.filter(result => result.alreadyQueued).length).toBe(1);
expect(debits).toBe(1);
expect(joinLedgerRows).toBe(1);
expect(q.length(economy)).toBe(1);
delete process.env.MATCH_ENABLED;
delete process.env.MATCH_CRYPTO_RACE_ENABLED;
delete process.env.MATCH_PAYOUTS_ENABLED;
delete process.env.MATCH_PAYOUT_MAX;
delete process.env.MATCH_ENTRY_FEE_ATOMIC;
delete process.env.MATCH_HOUSE_FEE_PERCENT;
delete process.env.MATCH_MAX_PLAYERS;
});
test('drain requires at least two queued players', async () => {
process.env.MATCH_ENABLED = 'true';
const db = makeDb();
@@ -97,6 +197,7 @@ describe('MatchQueue', () => {
process.env.MATCH_CREDITS_COST = '3';
const db = makeDb();
const q = new MatchQueue({ db });
q.initialized = true;
await q.join({ userId: 1, socketId: 's1', sessionToken: 't1', economy: 'credits_prestige' });
const leave = await q.leave(1, 'credits_prestige');
expect(leave.success).toBe(true);
@@ -105,3 +206,36 @@ describe('MatchQueue', () => {
delete process.env.MATCH_CREDITS_COST;
});
});
describe('SocketHandlers match queue acknowledgements', () => {
test('emits the documented joined and left results to the client', async () => {
const emitted = [];
const socket = { id: 'sock', emit: (event, payload) => emitted.push({ event, payload }) };
const ctx = {
activeGames: new Map(),
matchQueue: {
isEnabled: () => true,
enqueue: jest.fn().mockResolvedValue({ success: true, position: 2 }),
leave: jest.fn().mockResolvedValue({ success: true })
},
rateLimiter: {
checkLimit: jest.fn().mockResolvedValue({ allowed: true, retryAfter: 0 }),
recordAttempt: jest.fn().mockResolvedValue(undefined)
},
_resolveMatchSession: jest.fn().mockResolvedValue({ userId: 1, socketId: 'sock', sessionToken: 't' }),
debugManager: { CONSOLE_LOGGING: false }
};
await SocketHandlers.prototype._handleMatchQueue.call(ctx, socket, { action: 'join', economy: 'free' });
await SocketHandlers.prototype._handleMatchQueue.call(ctx, socket, { action: 'leave', economy: 'free' });
expect(emitted).toContainEqual({
event: 'match_queue_joined',
payload: { success: true, position: 2, economy: 'free' }
});
expect(emitted).toContainEqual({
event: 'match_queue_left',
payload: { success: true, economy: 'free' }
});
});
});
+163 -18
View File
@@ -116,6 +116,48 @@ describe('match rework — crash + countdown (MatchManager)', () => {
// Second call must be a benign no-op (no double finalize, no throw).
expect(() => m.expire(room.id, 'admin')).not.toThrow();
});
test('block deadline expires on the first later header after the active-play floor', () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-07-21T12:00:00.000Z'));
const m = mgr();
const room = new MatchRoom({
economy: 'free', startBlockHeight: 100,
entrants: { s1: { userId: 1 }, s2: { userId: 2 } }, seed: SEED
});
m.attach(room, [{ userId: 1, socketId: 's1' }, { userId: 2, socketId: 's2' }],
{ countdownMs: NEVER, minDurationMs: 20000, hardCeilingMs: NEVER });
room.start();
expect(m.expireBlockDeadlines(100)).toBe(0); // same header can never end its room
jest.advanceTimersByTime(19999);
expect(m.expireBlockDeadlines(101)).toBe(0); // later header, but floor not met
jest.advanceTimersByTime(1);
expect(m.expireBlockDeadlines(101)).toBe(1);
expect(room.status).toBe('finished');
expect(room.endReason).toBe('block_deadline');
expect(room.endBlockHeight).toBe(101);
expect(m.expireBlockDeadlines(102)).toBe(0); // terminal transition is idempotent
});
test('a ruleset can explicitly disable the block deadline', () => {
const m = mgr();
const room = new MatchRoom({
economy: 'free', startBlockHeight: 100,
entrants: { s1: { userId: 1 }, s2: { userId: 2 } }, seed: SEED,
ruleset: {
id: 'no-block-clock', mode: 'race', players: { min: 2, max: 2 },
entities: { monster: false }, winCondition: { type: 'high-score' },
timing: { blockDeadline: false, minDurationMs: 0 }
}
});
m.attach(room, [{ userId: 1, socketId: 's1' }, { userId: 2, socketId: 's2' }],
{ countdownMs: NEVER, minDurationMs: 0, hardCeilingMs: NEVER });
room.start();
expect(m.expireBlockDeadlines(101)).toBe(0);
expect(room.status).toBe('active');
});
});
describe('match rework — reconnect re-keying + ownership (MP-H1/H2)', () => {
@@ -166,6 +208,42 @@ describe('match rework — reconnect re-keying + ownership (MP-H1/H2)', () => {
expect(room.occupants.has('evil')).toBe(false);
expect(m.socketToMatch.has('evil')).toBe(false);
});
test('a finished-room reconnect receives the terminal result immediately and once', () => {
const { m, room } = activeMatch();
room.expire('hard_ceiling');
room.finalize();
room._finalized = true; // durable finish retained during the normal cleanup grace
const sock = makeSocket('s1new');
expect(m.handleReconnect(sock, { userId: 1 })).toBe(true);
expect(m.handleReconnect(sock, { userId: 1 })).toBe(true);
const terminal = sock.emitted.filter(e => e.ev === 'match_end');
expect(terminal).toHaveLength(1);
expect(terminal[0].p).toEqual(expect.objectContaining({
matchId: room.id,
reason: 'hard_ceiling',
winnerId: room.winnerId
}));
expect(terminal[0].p.players.find(p => p.id === 's1new')).toEqual(expect.objectContaining({
placement: expect.any(Number),
score: expect.any(Number)
}));
expect(terminal[0].p.fairness.seed).toBe(SEED);
});
test('a reconnect during nondurable finish gets pending, never a premature winner', () => {
const { m, room } = activeMatch();
room.expire('hard_ceiling');
room.finalize();
room._finalized = false;
const sock = makeSocket('s1new');
expect(m.handleReconnect(sock, { userId: 1 })).toBe(true);
expect(sock.emitted.some(e => e.ev === 'match_settlement_pending')).toBe(true);
expect(sock.emitted.some(e => e.ev === 'match_end')).toBe(false);
});
});
describe('match rework — winner consistency across end paths', () => {
@@ -194,18 +272,24 @@ describe('match rework — winner consistency across end paths', () => {
describe('match rework — payout economics (MP-C4/H5)', () => {
function capturingDb({ address = 'WALLET', pot = '20000', fee = '1000' } = {}) {
const captured = [];
const amount = (BigInt(pot) - BigInt(fee)).toString();
return {
captured,
async query(sql) {
if (sql.includes('SELECT pot_atomic')) return { rows: [{ pot_atomic: pot, house_fee_atomic: fee }] };
if (sql.includes('SELECT id, payout_address')) return { rows: [{ id: 1, payout_address: address }] };
return { rows: [], rowCount: 0 };
},
async query() { return { rows: [], rowCount: 0 }; },
async withTransaction(fn) {
const client = {
async query(sql, params) {
captured.push({ sql, params });
if (sql.includes('SELECT id FROM payouts')) return { rows: [] };
if (sql.includes('FROM matches') && sql.includes('FOR UPDATE')) {
return { rows: [{
id: params[0], economy: 'crypto_race', status: 'finished', winner_user_id: 1,
payout_liability_amount_atomic: amount,
payout_liability_cap_atomic: amount,
payout_liability_accepted_at: new Date().toISOString()
}], rowCount: 1 };
}
if (sql.includes('FROM payouts')) return { rows: [], rowCount: 0 };
if (sql.includes('FROM users')) return { rows: [{ id: 1, payout_address: address }], rowCount: 1 };
return { rows: [{ id: 1 }], rowCount: 1 };
}
};
@@ -237,28 +321,60 @@ describe('match rework — payout economics (MP-C4/H5)', () => {
});
test('BigInt exactness: a pot beyond 2^53 loses no precision', async () => {
const db = capturingDb({ address: 'WALLET', pot: '90071992547409920001', fee: '1' });
const db = capturingDb({ address: 'WALLET', pot: '900719925474099201', fee: '1' });
const svc = new MatchPayoutService({ db });
await svc.payoutWinner(makeCryptoFinishedRoom());
const ins = db.captured.find(q => q.sql.includes('INSERT INTO payouts'));
expect(ins.params[3]).toBe('90071992547409920000'); // exact; a float would round this
expect(ins.params[3]).toBe('900719925474099200'); // exact; a float would round this
});
test('collectEntryTickets uses integer basis-point math for a fractional house fee', async () => {
process.env.MATCH_ENABLED = 'true';
process.env.MATCH_CRYPTO_RACE_ENABLED = 'true';
process.env.MATCH_PAYOUTS_ENABLED = 'true';
process.env.MATCH_PAYOUT_MAX = '1000000000000';
process.env.MATCH_MAX_PLAYERS = '4';
process.env.PAYOUTS_ENABLED = 'true';
process.env.PAYMENTS_ENABLED = 'true';
process.env.MATCH_ENTRY_FEE_ATOMIC = '10000000000';
process.env.MATCH_HOUSE_FEE_PERCENT = '5.5';
const captured = [];
const db = { async withTransaction(fn) { return fn({ async query(sql, params) { captured.push({ sql, params }); return { rows: [{ race_entries: 1 }], rowCount: 1 }; } }); } };
const db = { async withTransaction(fn) { return fn({ async query(sql, params) {
captured.push({ sql, params });
if (sql.includes('FROM match_queue_entries') && sql.includes('FOR UPDATE')) {
return {
rows: params[0].map((id, index) => ({
id,
user_id: index + 1,
race_entry_lot_id: 100 + index,
escrow_amount: '1',
escrow_value_atomic: '10000000000'
})),
rowCount: params[0].length
};
}
return { rows: [{ race_entries: 1, id: 1 }], rowCount: 1 };
} }); } };
const svc = new MatchPayoutService({ db });
const room = new MatchRoom({ economy: 'crypto_race', entrants: { a: { userId: 1 }, b: { userId: 2 } }, seed: SEED });
await svc.collectEntryTickets(room, [{ userId: 1 }, { userId: 2 }]);
await svc.collectEntryTickets(room, [
{ userId: 1, queueEntryId: 11 },
{ userId: 2, queueEntryId: 12 }
]);
const upd = captured.find(q => q.sql.includes('UPDATE matches'));
expect(upd.params[0]).toBe('10000000000'); // entry fee
expect(upd.params[1]).toBe('20000000000'); // pot = fee * 2
expect(upd.params[2]).toBe('1100000000'); // house fee = pot * 550bp / 10000 (floor)
expect(room.potAtomic).toBeGreaterThan(0);
expect(room.potAtomic).toBe('20000000000');
delete process.env.MATCH_ENTRY_FEE_ATOMIC;
delete process.env.MATCH_HOUSE_FEE_PERCENT;
delete process.env.MATCH_CRYPTO_RACE_ENABLED;
delete process.env.MATCH_PAYOUTS_ENABLED;
delete process.env.MATCH_PAYOUT_MAX;
delete process.env.MATCH_MAX_PLAYERS;
delete process.env.PAYOUTS_ENABLED;
delete process.env.PAYMENTS_ENABLED;
delete process.env.MATCH_ENABLED;
});
});
@@ -283,7 +399,7 @@ describe('match rework — refunds on every cancellation path (MP-H3/H4)', () =>
const q = new MatchQueue({ db });
await q.refundEntries([{ userId: 1 }], 'crypto_race', 'match_cancel');
expect(captured.some(c => c.sql.includes("status = 'cancelled'"))).toBe(true);
expect(captured.some(c => c.sql.includes('race_entries = race_entries + 1'))).toBe(true);
expect(captured.some(c => c.sql.includes('race_entries = race_entries + $1'))).toBe(true);
expect(captured.some(c => c.sql.includes('INSERT INTO race_entry_transactions'))).toBe(true);
});
@@ -331,10 +447,10 @@ describe('match rework — refunds on every cancellation path (MP-H3/H4)', () =>
});
});
describe('match rework — payout-first isolated finalize (MP-C5)', () => {
describe('match rework — durable-finish isolated finalize (MP-C5)', () => {
afterEach(() => { delete process.env.MATCH_ENABLED; });
test('the winner payout runs FIRST and the match_end broadcast still fires when later steps throw', async () => {
test('a nondurable winner creates no payout, leaderboard row, or final announcement', async () => {
const io = makeIo();
const throwingDb = { async withTransaction() { throw new Error('persist boom'); } };
const m = new MatchManager({ io, db: throwingDb, debugManager: null });
@@ -342,15 +458,44 @@ describe('match rework — payout-first isolated finalize (MP-C5)', () => {
const order = [];
m.matchPayoutService.payoutWinner = async () => { order.push('payout'); };
m.matchLeaderboard.postMatch = async () => { order.push('leaderboard'); throw new Error('lb boom'); };
m.matchLeaderboard.postMatch = async () => { order.push('leaderboard'); };
const room = makeCryptoFinishedRoom();
m.rooms.set(room.id, room);
await m._finalize(room);
expect(order[0]).toBe('payout'); // payout is attempted before persist/leaderboard
// Persist threw and leaderboard threw, yet the result was still broadcast to clients.
expect(io.broadcasts.some(b => b.event === 'match_end')).toBe(true);
expect(order).not.toContain('payout'); // no DB winner means no reconstructable payout target
expect(order).not.toContain('leaderboard'); // no synthetic result without a durable match
expect(room._finalized).not.toBe(true); // remains retryable in memory
expect(io.broadcasts.some(b => b.event === 'match_end')).toBe(false);
expect(io.broadcasts.some(b => b.event === 'match_settlement_pending')).toBe(true);
m.shutdown();
});
test('a transient finish failure retries and announces only after durability succeeds', async () => {
jest.useFakeTimers();
const io = makeIo();
const m = new MatchManager({ io, db: {}, debugManager: null });
m.enabled = true;
m._persistFinish = jest.fn()
.mockRejectedValueOnce(new Error('temporary database outage'))
.mockResolvedValueOnce();
m.matchPayoutService.payoutWinner = jest.fn().mockResolvedValue();
m.matchLeaderboard.postMatch = jest.fn().mockResolvedValue();
const room = makeCryptoFinishedRoom();
m.rooms.set(room.id, room);
await m._finalize(room);
expect(room._finalized).not.toBe(true);
expect(io.broadcasts.some(b => b.event === 'match_end')).toBe(false);
await jest.advanceTimersByTimeAsync(5000);
expect(room._finalized).toBe(true);
expect(m._persistFinish).toHaveBeenCalledTimes(2);
expect(m.matchPayoutService.payoutWinner).toHaveBeenCalledTimes(1);
expect(m.matchLeaderboard.postMatch).toHaveBeenCalledTimes(1);
expect(io.broadcasts.filter(b => b.event === 'match_end')).toHaveLength(1);
m.shutdown();
jest.useRealTimers();
});
});
+17
View File
@@ -194,6 +194,23 @@ describe('MatchRoom', () => {
expect(room.playerStates.get('a').alive).toBe(false);
expect(result.events.some(e => e.type === 'player_death')).toBe(true);
});
test('fractional monster speed accumulates across ticks and zero stays stationary', () => {
const room = makeRoom();
room.start();
room.monster.hasLineOfSight = jest.fn(() => true);
room.monster.moveTowardPlayer = jest.fn(() => true);
room.difficultyConfig.monster.movesPerPlayerMove = 0.5;
expect(room._moveMonster().filter(e => e.type === 'monster_move')).toHaveLength(0);
expect(room._moveMonster().filter(e => e.type === 'monster_move')).toHaveLength(1);
expect(room.monster.moveTowardPlayer).toHaveBeenCalledTimes(1);
room.difficultyConfig.monster.movesPerPlayerMove = 0;
room._moveMonster();
room._moveMonster();
expect(room.monster.moveTowardPlayer).toHaveBeenCalledTimes(1);
});
});
describe('expiration / rankings', () => {
+73 -4
View File
@@ -1,4 +1,5 @@
const MatchScheduler = require('../src/network/matchScheduler');
const { deriveBlockMatchSeed } = MatchScheduler;
const MatchQueue = require('../src/network/matchQueue');
describe('MatchScheduler', () => {
@@ -6,10 +7,7 @@ describe('MatchScheduler', () => {
let manager = null;
afterEach(() => {
// The scheduler starts long-lived timers when it drains a queue: a hard-ceiling
// setTimeout (owned by the scheduler) and, per match, an engine tick interval (owned
// by the manager via setEngine). Stop both so Jest can exit instead of leaking handles
// (an unstopped ceiling timer later fires manager.expire() and crashes the process).
// Stop the scheduler tail and every handed-off engine so Jest cannot leak handles.
if (scheduler) scheduler.shutdown();
if (manager) for (const engine of manager.engines.values()) engine.stop();
scheduler = null;
@@ -50,6 +48,7 @@ describe('MatchScheduler', () => {
this.attached.push({ room, entrants, opts });
},
setEngine(id, engine) { engines.set(id, engine); },
expireBlockDeadlines: jest.fn(),
onTick() {},
onFinish() {},
expire() {}
@@ -106,4 +105,74 @@ describe('MatchScheduler', () => {
expect(manager.engines.size).toBe(1);
expect(room.status).toBe('starting');
});
test('operator-selected last-alive ruleset creates a real PvP match', async () => {
process.env.MATCH_ENABLED = 'true';
const queue = makeQueue();
manager = makeManager();
scheduler = new MatchScheduler({ matchQueue: queue, matchManager: manager, rulesetId: 'last-alive' });
await queue.join({ userId: 1, socketId: 's1', sessionToken: 't1', economy: 'free' });
await queue.join({ userId: 2, socketId: 's2', sessionToken: 't2', economy: 'free' });
await scheduler.onBlock(100);
const { room } = manager.attached[0];
expect(room.ruleset.id).toBe('last-alive');
expect(room.pvpCombat).toBe(true);
expect(room.variant).toBe('pvp');
expect(room.snapshot().ruleset).toEqual(expect.objectContaining({
id: 'last-alive',
winCondition: 'last-alive'
}));
});
test('block-derived match seeds are reproducible and lock the durable entrant set', () => {
const input = {
blockHash: 'ab'.repeat(32),
blockHeight: 123,
economy: 'crypto_race',
rulesetId: 'race',
queueEntryIds: [12, 3]
};
const first = deriveBlockMatchSeed(input);
const reordered = deriveBlockMatchSeed({ ...input, queueEntryIds: [3, 12] });
const changedEntrant = deriveBlockMatchSeed({ ...input, queueEntryIds: [3, 13] });
expect(first).toEqual(reordered);
expect(first.seed).toMatch(/^[0-9a-f]{64}$/);
expect(first.derivation.queueEntryIds).toEqual(['3', '12']);
expect(changedEntrant.seed).not.toBe(first.seed);
});
test('production paid queues do not drain without a canonical block hash', async () => {
const oldNodeEnv = process.env.NODE_ENV;
process.env.NODE_ENV = 'production';
process.env.MATCH_ENABLED = 'true';
const queue = makeQueue();
const drain = jest.spyOn(queue, 'drain');
manager = makeManager();
scheduler = new MatchScheduler({ matchQueue: queue, matchManager: manager });
try {
await scheduler._drainEconomy('credits_prestige', 100, null);
expect(drain).not.toHaveBeenCalled();
expect(manager.attached).toHaveLength(0);
} finally {
if (oldNodeEnv === undefined) delete process.env.NODE_ENV;
else process.env.NODE_ENV = oldNodeEnv;
}
});
test('each advancing block is offered once to active-room deadline processing', async () => {
process.env.MATCH_ENABLED = 'true';
const queue = makeQueue();
manager = makeManager();
scheduler = new MatchScheduler({ matchQueue: queue, matchManager: manager });
await scheduler.onBlock(100);
await scheduler.onBlock(100);
await scheduler.onBlock(101);
// getblockcount is normalized to its zero-based canonical header exactly once.
expect(manager.expireBlockDeadlines.mock.calls).toEqual([[99], [100]]);
});
});
+20
View File
@@ -0,0 +1,20 @@
const noStore = require('../src/middleware/noStore');
describe('authenticated API cache policy', () => {
test('sets explicit no-store headers before continuing', () => {
const headers = {};
const response = {
setHeader: jest.fn((name, value) => { headers[name] = value; })
};
const next = jest.fn();
noStore({}, response, next);
expect(headers).toEqual({
'Cache-Control': 'no-store, max-age=0',
Pragma: 'no-cache',
Expires: '0'
});
expect(next).toHaveBeenCalledTimes(1);
});
});
+195
View File
@@ -0,0 +1,195 @@
const SessionManager = require('../src/network/sessionManager');
function makeDb(initialGames, initialCredits = 0) {
const games = new Map(initialGames.map(game => [game.id, { ...game }]));
const users = new Map([[7, initialCredits]]);
const refundReasons = new Set();
const query = jest.fn(async (sql, params = []) => {
if (/SELECT id FROM games WHERE status = 'active'/i.test(sql)) {
return {
rows: Array.from(games.values())
.filter(game => game.status === 'active')
.map(game => ({ id: game.id }))
};
}
if (/FROM games WHERE id = \$1 FOR UPDATE/i.test(sql)) {
const game = games.get(params[0]);
return { rows: game ? [{ ...game }] : [] };
}
if (/SELECT 1 FROM credit_transactions/i.test(sql)) {
return { rows: refundReasons.has(params[1]) ? [{}] : [] };
}
if (/UPDATE users SET credits = credits \+/i.test(sql)) {
const next = (users.get(params[1]) || 0) + Number(params[0]);
users.set(params[1], next);
return { rows: [{ credits: next }], rowCount: 1 };
}
if (/INSERT INTO credit_transactions/i.test(sql)) {
refundReasons.add(params[2]);
return { rows: [], rowCount: 1 };
}
if (/UPDATE games SET status = \$1/i.test(sql)) {
const game = games.get(params[1]);
if (game) game.status = params[0];
return { rows: [], rowCount: game ? 1 : 0 };
}
return { rows: [], rowCount: 0 };
});
return {
query,
withTransaction: jest.fn(async callback => callback({ query })),
credits: userId => users.get(userId),
game: id => games.get(id),
refundReasons
};
}
function manager(db, currentConfiguredCost = 99) {
return new SessionManager({
db,
debugManager: { CONSOLE_LOGGING: false },
gameModeManager: { creditsPerGameCost: currentConfiguredCost }
});
}
describe('orphan game recovery debit evidence', () => {
test('does not mint a refund for a pre-created active row when entry charging never committed', async () => {
const db = makeDb([{
id: 1,
user_id: 7,
game_mode: 'PAID_CREDITS',
payment_id: null,
status: 'active',
entry_consumed_at: null,
entry_credits_spent: 5
}], 10);
const result = await manager(db).recoverOrphanedGames();
expect(result).toEqual({
ok: true,
scanned: 1,
finalized: 1,
refunded: 0,
creditsRefunded: 0,
unresolved: []
});
expect(db.credits(7)).toBe(10);
expect(db.game(1).status).toBe('expired');
expect(db.refundReasons.size).toBe(0);
});
test('restores the immutable debit snapshot, not the current configured entry cost', async () => {
const db = makeDb([{
id: 2,
user_id: 7,
game_mode: 'PAID_CREDITS',
payment_id: null,
status: 'active',
entry_consumed_at: new Date('2026-01-01T00:00:00Z'),
entry_credits_spent: 3
}], 4);
const recovery = manager(db, 99);
const first = await recovery.recoverOrphanedGames();
const second = await recovery.recoverOrphanedGames();
expect(first).toEqual({
ok: true,
scanned: 1,
finalized: 1,
refunded: 1,
creditsRefunded: 3,
unresolved: []
});
expect(second).toEqual({
ok: true,
scanned: 0,
finalized: 0,
refunded: 0,
creditsRefunded: 0,
unresolved: []
});
expect(db.credits(7)).toBe(7);
expect(db.game(2).status).toBe('refunded');
expect(db.refundReasons).toEqual(new Set(['orphan_game_refunded:2']));
});
test('does not silently convert a linked direct payment into credits', async () => {
const db = makeDb([{
id: 3,
user_id: 7,
game_mode: 'PAID_SINGLE',
payment_id: 77,
status: 'active',
entry_consumed_at: new Date('2026-01-01T00:00:00Z'),
entry_credits_spent: null
}], 1);
const result = await manager(db).recoverOrphanedGames();
expect(result.refunded).toBe(0);
expect(db.credits(7)).toBe(1);
expect(db.game(3).status).toBe('expired');
});
test('fails closed with explicit scan semantics when the orphan query fails', async () => {
const db = {
query: jest.fn().mockRejectedValue(new Error('database unavailable')),
withTransaction: jest.fn()
};
await expect(manager(db).recoverOrphanedGames()).rejects.toMatchObject({
code: 'FINANCIAL_RECOVERY_INCOMPLETE',
recovery: {
ok: false,
scope: 'orphaned_solo_games',
scanFailed: true,
scanned: 0,
resolved: 0,
unresolved: []
}
});
expect(db.withTransaction).not.toHaveBeenCalled();
});
test('finishes other rows but rejects startup while any orphan remains unresolved', async () => {
const db = makeDb([{
id: 4,
user_id: 7,
game_mode: 'PAID_CREDITS',
status: 'active',
entry_consumed_at: new Date('2026-01-01T00:00:00Z'),
entry_credits_spent: 2
}, {
id: 5,
user_id: 7,
game_mode: 'FREE',
status: 'active',
entry_consumed_at: null,
entry_credits_spent: null
}], 0);
const baseTransaction = db.withTransaction;
let attempts = 0;
db.withTransaction = jest.fn(async callback => {
attempts += 1;
if (attempts === 1) throw new Error('refund transaction failed');
return baseTransaction(callback);
});
await expect(manager(db).recoverOrphanedGames()).rejects.toMatchObject({
code: 'FINANCIAL_RECOVERY_INCOMPLETE',
recovery: {
scope: 'orphaned_solo_games',
scanFailed: false,
scanned: 2,
resolved: 1,
unresolved: [{ type: 'game', id: '4' }]
}
});
expect(db.game(4).status).toBe('active');
expect(db.game(5).status).toBe('expired');
});
});
+332
View File
@@ -0,0 +1,332 @@
const SocketHandlers = require('../src/network/socketHandlers');
const PaymentHandlers = require('../src/network/paymentHandlers');
const { buildCommerceDisclosure } = require('../src/config/commerceDisclosurePolicy');
const ENV_KEYS = [
'NODE_ENV',
'LEGAL_POLICY_VERSION',
'PAID_ACKNOWLEDGEMENT_REQUIRED',
'MATCH_ENABLED',
'MATCH_CRYPTO_RACE_ENABLED',
'MATCH_PAYOUTS_ENABLED'
];
function manager(overrides = {}) {
return {
cryptoType: 'WOW',
currencyLabel: 'WOW',
network: 'mainnet',
isTestNetwork: false,
paymentsEnabled: false,
directModeEnabled: false,
creditsModeEnabled: false,
freePlayEnabled: true,
payoutsEnabled: false,
gameMode: 'PAID_CREDITS',
isPayoutEnabledForMode: () => false,
...overrides
};
}
function acknowledgement(gameModeManager, overrides = {}) {
const disclosure = buildCommerceDisclosure(gameModeManager, process.env);
return {
policyVersion: disclosure.policyVersion,
ageEligible: true,
termsRead: true,
riskAccepted: true,
testnetUnderstood: false,
...overrides
};
}
function socket() {
return {
id: 'socket-paid-flow',
handshake: { address: '127.0.0.1', headers: {} },
emit: jest.fn()
};
}
function baseContext(gameModeManager = manager()) {
return {
gameModeManager,
sessionManager: null,
broadcastManager: { sendStatusUpdate: jest.fn() },
rateLimiter: {
checkLimit: jest.fn().mockResolvedValue({ allowed: true, retryAfter: 0 }),
recordAttempt: jest.fn().mockResolvedValue(undefined)
},
_consumeFairnessAttempt: jest.fn().mockReturnValue({ proofVersion: 2 }),
activeGames: new Map(),
connectionHandler: { getUserBySocket: jest.fn().mockReturnValue({ serverId: 'socket-paid-flow' }) },
debugManager: { getCurrentBlockHeight: jest.fn().mockReturnValue(100), CONSOLE_LOGGING: false },
io: { to: jest.fn(() => ({ emit: jest.fn() })) }
};
}
describe('current paid acknowledgement gates every value-consuming socket flow', () => {
let previous;
beforeEach(() => {
previous = Object.fromEntries(ENV_KEYS.map(key => [key, process.env[key]]));
process.env.NODE_ENV = 'test';
process.env.LEGAL_POLICY_VERSION = 'paid-flow-current-v1';
process.env.PAID_ACKNOWLEDGEMENT_REQUIRED = 'false';
process.env.MATCH_ENABLED = 'true';
delete process.env.MATCH_CRYPTO_RACE_ENABLED;
delete process.env.MATCH_PAYOUTS_ENABLED;
});
afterEach(() => {
for (const key of ENV_KEYS) {
if (previous[key] === undefined) delete process.env[key];
else process.env[key] = previous[key];
}
});
test('invoice request rejects a stale policy before fairness, wallet, or database work', async () => {
const client = socket();
const ctx = baseContext();
ctx.paymentHandlers = { handlePaymentRequest: jest.fn() };
await SocketHandlers.prototype.handlePaymentRequest.call(ctx, client, {
type: 'credits_package',
legalAcknowledgement: acknowledgement(ctx.gameModeManager, { policyVersion: 'stale-v0' })
});
expect(ctx.paymentHandlers.handlePaymentRequest).not.toHaveBeenCalled();
expect(ctx._consumeFairnessAttempt).not.toHaveBeenCalled();
expect(client.emit).toHaveBeenCalledWith('commerce_ack_required', expect.objectContaining({
code: 'PAID_ACK_VERSION', policyVersion: 'paid-flow-current-v1'
}));
});
test('invoice request accepts the current policy even with intake state reported off', async () => {
const client = socket();
const ctx = baseContext();
ctx.paymentHandlers = { handlePaymentRequest: jest.fn().mockResolvedValue(undefined) };
const ack = acknowledgement(ctx.gameModeManager);
await SocketHandlers.prototype.handlePaymentRequest.call(ctx, client, {
type: 'credits_package', legalAcknowledgement: ack
});
expect(ctx.paymentHandlers.handlePaymentRequest).toHaveBeenCalledWith(client, {
type: 'credits_package', legalAcknowledgement: ack
});
});
test('auto-start rejects stale policy before consuming a fairness offer or credit', async () => {
const client = socket();
const ctx = baseContext();
ctx.gameManager = { createGameForUser: jest.fn() };
await SocketHandlers.prototype.handleAutoStart.call(ctx, client, {
legalAcknowledgement: acknowledgement(ctx.gameModeManager, { policyVersion: 'stale-v0' })
});
expect(ctx._consumeFairnessAttempt).not.toHaveBeenCalled();
expect(ctx.gameManager.createGameForUser).not.toHaveBeenCalled();
expect(client.emit).toHaveBeenCalledWith('commerce_ack_required', expect.objectContaining({ code: 'PAID_ACK_VERSION' }));
});
test('auto-start with current policy reaches existing-credit consumption while intake is off', async () => {
const client = socket();
const gameModeManager = manager({
canUserStartGame: jest.fn().mockResolvedValue({
allowed: true,
effectiveMode: 'PAID_CREDITS',
useCredits: true
}),
requiresPayoutAddressForMode: jest.fn().mockReturnValue(false),
processGameStart: jest.fn().mockResolvedValue({ success: true, creditsRemaining: 4 })
});
const ctx = baseContext(gameModeManager);
const game = {
id: 'game-1',
dbId: null,
getState: jest.fn().mockReturnValue({}),
getProofCommitment: jest.fn().mockReturnValue('commitment')
};
ctx.gameManager = { createGameForUser: jest.fn().mockResolvedValue(game) };
await SocketHandlers.prototype.handleAutoStart.call(ctx, client, {
legalAcknowledgement: acknowledgement(gameModeManager)
});
expect(gameModeManager.canUserStartGame).toHaveBeenCalledWith(client.id);
expect(gameModeManager.processGameStart).toHaveBeenCalledWith(client.id, game.id, { forceFree: false });
});
test('queued-credit entry rejects stale policy before queue eligibility or debit', async () => {
const client = socket();
const ctx = baseContext();
ctx.queueHandler = { handleGameQueue: jest.fn() };
await SocketHandlers.prototype.handleJoinQueue.call(ctx, client, {
legalAcknowledgement: acknowledgement(ctx.gameModeManager, { policyVersion: 'stale-v0' })
});
expect(ctx._consumeFairnessAttempt).not.toHaveBeenCalled();
expect(ctx.queueHandler.handleGameQueue).not.toHaveBeenCalled();
});
test('queued-credit entry accepts the current policy while invoice intake is off', async () => {
const client = socket();
const ctx = baseContext();
ctx.queueHandler = { handleGameQueue: jest.fn().mockResolvedValue(undefined) };
ctx.queueManager = { getPlayerIndex: jest.fn().mockReturnValue(-1) };
await SocketHandlers.prototype.handleJoinQueue.call(ctx, client, {
legalAcknowledgement: acknowledgement(ctx.gameModeManager)
});
expect(ctx.queueHandler.handleGameQueue).toHaveBeenCalledWith(
client,
expect.any(Function),
expect.objectContaining({ fairnessProof: { proofVersion: 2 } })
);
});
test('early entry rejects stale policy before queue eligibility or credit debit', async () => {
const client = socket();
const ctx = baseContext();
ctx.queueHandler = { handleEarlyEntry: jest.fn() };
await SocketHandlers.prototype.handleEarlyEntry.call(ctx, client, {
legalAcknowledgement: acknowledgement(ctx.gameModeManager, { policyVersion: 'stale-v0' })
});
expect(ctx._consumeFairnessAttempt).not.toHaveBeenCalled();
expect(ctx.queueHandler.handleEarlyEntry).not.toHaveBeenCalled();
expect(client.emit).toHaveBeenCalledWith('early_entry_error', expect.objectContaining({ code: 'PAID_ACK_VERSION' }));
});
test('early entry accepts current policy while invoice intake is off', async () => {
const client = socket();
const ctx = baseContext();
ctx.queueHandler = { handleEarlyEntry: jest.fn().mockResolvedValue({ success: true }) };
await SocketHandlers.prototype.handleEarlyEntry.call(ctx, client, {
legalAcknowledgement: acknowledgement(ctx.gameModeManager)
});
expect(ctx.queueHandler.handleEarlyEntry).toHaveBeenCalledWith(
client,
expect.any(Function),
{ fairnessProof: { proofVersion: 2 } }
);
});
test('paid PvP rejects stale policy before a credit or ticket is escrowed', async () => {
const client = socket();
const ctx = baseContext();
ctx.matchQueue = {
isEnabled: jest.fn().mockReturnValue(true),
enqueue: jest.fn(),
leave: jest.fn()
};
ctx._resolveMatchSession = jest.fn().mockResolvedValue({
userId: 1, socketId: client.id, sessionToken: 'session'
});
await SocketHandlers.prototype._handleMatchQueue.call(ctx, client, {
action: 'join', economy: 'crypto_race',
legalAcknowledgement: acknowledgement(ctx.gameModeManager, { policyVersion: 'stale-v0' })
});
expect(ctx.matchQueue.enqueue).not.toHaveBeenCalled();
expect(client.emit).toHaveBeenCalledWith('match_error', expect.objectContaining({ code: 'PAID_ACK_VERSION' }));
});
test.each(['credits_prestige', 'crypto_race'])(
'paid PvP %s accepts the current policy while new invoice intake is off',
async economy => {
const client = socket();
const ctx = baseContext();
ctx.matchQueue = {
isEnabled: jest.fn().mockReturnValue(true),
enqueue: jest.fn().mockResolvedValue({ success: true, position: 1 }),
leave: jest.fn()
};
ctx._resolveMatchSession = jest.fn().mockResolvedValue({
userId: 1, socketId: client.id, sessionToken: 'session'
});
await SocketHandlers.prototype._handleMatchQueue.call(ctx, client, {
action: 'join', economy,
legalAcknowledgement: acknowledgement(ctx.gameModeManager)
});
expect(ctx.matchQueue.enqueue).toHaveBeenCalledWith(
expect.objectContaining({ userId: 1, economy }),
{ economy }
);
}
);
test('continuation cache is canonical, capped, expires, and clears on failed selection', async () => {
const gameModeManager = manager({
gameMode: 'FREE',
getCosmeticProduct: jest.fn().mockReturnValue(null)
});
const handler = new PaymentHandlers({
io: { to: () => ({ emit: jest.fn() }) },
gameModeManager,
walletService: {},
debugManager: { CONSOLE_LOGGING: false },
queueManager: {},
broadcastManager: { sendStatusUpdate: jest.fn() },
sessionManager: null
});
const ack = acknowledgement(gameModeManager);
try {
handler._rememberPendingCommerceAcknowledgement('canonical', {
...ack,
ignored: 'never retained'
});
expect(handler._getPendingCommerceAcknowledgement('canonical')).toEqual(ack);
expect(Object.keys(handler._getPendingCommerceAcknowledgement('canonical'))).toEqual([
'policyVersion', 'ageEligible', 'termsRead', 'riskAccepted', 'testnetUnderstood'
]);
for (let index = 0; index < 300; index += 1) {
handler._rememberPendingCommerceAcknowledgement(`socket-${index}`, ack);
}
expect(handler.pendingCommerceAcknowledgement.size).toBe(256);
const expiresAt = handler.pendingCommerceAcknowledgement.get('socket-299').expiresAt;
const now = jest.spyOn(Date, 'now').mockReturnValue(expiresAt);
expect(handler._getPendingCommerceAcknowledgement('socket-299')).toBeNull();
now.mockRestore();
handler._rememberPendingCommerceAcknowledgement('failed', ack);
handler.pendingEntryFairness.set('failed', { proofVersion: 2 });
await handler.createAndShowPaymentRequest({ id: 'failed' }, {
paymentType: 'bogus',
legalAcknowledgement: ack
});
expect(handler._getPendingCommerceAcknowledgement('failed')).toBeNull();
expect(handler.pendingEntryFairness.has('failed')).toBe(false);
} finally {
handler.dispose();
}
});
test('disconnect clears server-side acknowledgement and fairness continuation state', () => {
const client = socket();
const clearPendingCommerceAcknowledgement = jest.fn();
const pendingEntryFairness = new Map([[client.id, { proofVersion: 2 }]]);
const ctx = {
fairnessOffers: { discardSocket: jest.fn() },
paymentHandlers: { clearPendingCommerceAcknowledgement, pendingEntryFairness },
connectionHandler: { handleDisconnect: jest.fn() }
};
SocketHandlers.prototype.handleDisconnect.call(ctx, client);
expect(clearPendingCommerceAcknowledgement).toHaveBeenCalledWith(client.id);
expect(pendingEntryFairness.has(client.id)).toBe(false);
});
});
+233
View File
@@ -0,0 +1,233 @@
const GameModeManager = require('../src/game/gameModeManager');
const { hashSeed } = require('../src/game/provablyFair');
const ENV_KEYS = [
'NODE_ENV', 'REQUIRE_PAID_FAIRNESS_V2', 'GAME_MODE', 'SINGLE_GAME_PRICE',
'CREDITS_PACKAGE_PRICE', 'CREDITS_PER_GAME', 'CREDITS_PAYOUT_ENABLED',
'CREDITS_PAYOUTS_ENABLED'
];
function configManager() {
return {
getConfig: () => ({
paymentsEnabled: true,
currency: { symbol: 'XMR', decimals: 12 },
modes: {
direct: { enabled: true, price: 1000n },
credits: { enabled: false, creditsPerGame: 1, packages: [] }
},
payouts: {
enabled: false,
rules: {
direct: { enabled: false, multipliers: { escape: 2, escapeWithTreasure: 3 } },
credits: { enabled: false, multipliers: { escape: 2, escapeWithTreasure: 3 } }
}
},
preferences: { preferCreditsFirst: false }
}),
getLegacyGameMode: () => 'PAID_SINGLE',
eventBus: { on: jest.fn() }
};
}
function makeManager(db, provider = null) {
const providers = provider ? { getProvider: jest.fn(() => provider) } : { getProvider: jest.fn(() => null) };
return new GameModeManager(
db,
{ createPaymentRequest: jest.fn() },
{ CONSOLE_LOGGING: false },
configManager(),
providers
);
}
describe('paid fairness invoice durability', () => {
let previousEnv;
let logSpy;
beforeEach(() => {
previousEnv = Object.fromEntries(ENV_KEYS.map(key => [key, process.env[key]]));
process.env.NODE_ENV = 'production';
process.env.REQUIRE_PAID_FAIRNESS_V2 = 'false';
logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(() => {
logSpy.mockRestore();
for (const key of ENV_KEYS) {
if (previousEnv[key] === undefined) delete process.env[key];
else process.env[key] = previousEnv[key];
}
});
test('production cannot disable v2 and a new manager restores the invoice-bound proof', async () => {
const serverSeed = '12'.repeat(32);
const proof = {
proofVersion: 2,
offerId: 'offer-before-payment',
offerIssuedAt: 1700000000123,
serverSeed,
commitment: hashSeed(serverSeed),
clientSeed: '34'.repeat(32)
};
let insertParams;
let insertSql;
const expiresAt = new Date('2030-01-01T00:00:00.000Z');
const db = {
query: jest.fn(async (sql, params = []) => {
if (/SELECT \* FROM users WHERE id/i.test(sql)) return { rows: [{ id: 7 }] };
if (/UPDATE payments SET status = 'expired'/i.test(sql)) return { rows: [] };
if (/INSERT INTO payments/i.test(sql)) {
insertSql = sql;
insertParams = params;
return { rows: [{ id: 55, expires_at: expiresAt }] };
}
return { rows: [] };
})
};
const provider = {
createInvoice: jest.fn(async () => ({
address: 'stagenet-address',
addressIndex: 9,
invoiceId: 'invoice-55',
expiresAt
}))
};
const firstProcess = makeManager(db, provider);
expect(firstProcess._requiresPaidFairnessV2()).toBe(true);
const request = await firstProcess.createPaymentRequest('old-socket', 'single_game', {
userId: 7,
reuseExisting: false,
fairnessProof: proof
});
expect(request.fairnessProof).toEqual(proof);
// PostgreSQL's extended protocol rejects one placeholder when it is inferred as both
// VARCHAR (the destination column) and TEXT (a CASE expression). Keep both uses explicit
// and identical; this is exercised end-to-end by the stagenet invoice canary.
expect(insertSql).toMatch(/\$12::varchar\(64\)/i);
expect(insertSql).toMatch(/CASE WHEN \$12::varchar\(64\) IS NULL/i);
expect(insertParams.slice(10, 16)).toEqual([
2,
proof.offerId,
new Date(proof.offerIssuedAt),
proof.commitment,
proof.serverSeed,
proof.clientSeed
]);
// Simulate a process restart: only fields reconstructed from the payment row remain.
const durableRow = {
id: 55,
subaddress: 'stagenet-address',
expected_amount: '1000',
payment_type: 'single_game',
status: 'pending',
expires_at: expiresAt,
fairness_proof_version: insertParams[10],
fairness_offer_id: insertParams[11],
fairness_offer_issued_at: insertParams[12],
fairness_commitment: insertParams[13],
fairness_server_seed: insertParams[14],
fairness_client_seed: insertParams[15],
fairness_bound_at: new Date(),
fairness_consumed_at: null
};
const restarted = makeManager({ query: jest.fn() });
expect(restarted._mapPaymentRowToRequest(durableRow, 'single_game', null).fairnessProof)
.toEqual(proof);
});
test('entry claim atomically verifies and consumes the exact invoice proof', async () => {
const serverSeed = 'ab'.repeat(32);
const offerIssuedAt = 1700000000999;
const payment = {
id: 90,
user_id: 7,
fairness_proof_version: 2,
fairness_offer_id: 'restart-proof',
fairness_offer_issued_at: new Date(offerIssuedAt),
fairness_commitment: hashSeed(serverSeed),
fairness_server_seed: serverSeed,
fairness_client_seed: 'cd'.repeat(32),
fairness_bound_at: new Date(),
fairness_consumed_at: null
};
const calls = [];
const client = {
query: jest.fn(async (sql, params = []) => {
calls.push({ sql, params });
if (/SELECT \* FROM payments/i.test(sql)) return { rows: [payment] };
if (/SELECT proof_version/i.test(sql)) {
return { rows: [{
proof_version: 2,
fairness_offer_id: payment.fairness_offer_id,
fairness_offer_issued_at: payment.fairness_offer_issued_at,
proof_commitment: payment.fairness_commitment,
server_seed: payment.fairness_server_seed,
client_seed: payment.fairness_client_seed
}] };
}
if (/UPDATE games/i.test(sql)) return { rows: [{ id: 1 }], rowCount: 1 };
if (/UPDATE payments[\s\S]*fairness_consumed_at/i.test(sql)) {
return { rows: [{ id: payment.id }], rowCount: 1 };
}
if (/UPDATE users/i.test(sql)) return { rows: [], rowCount: 1 };
return { rows: [], rowCount: 0 };
})
};
const db = { withTransaction: jest.fn(async callback => callback(client)) };
const manager = makeManager(db);
await expect(manager._processGameStartWithPayment(
{ id: 7, payout_address: null },
{ id: payment.id },
'game-proof-seed'
)).resolves.toEqual(expect.objectContaining({ success: true, paymentId: payment.id }));
const gameUpdate = calls.find(call => /UPDATE games/i.test(call.sql));
const paymentConsume = calls.find(call => /UPDATE payments[\s\S]*fairness_consumed_at/i.test(call.sql));
expect(gameUpdate.sql).toContain('entry_consumed_at = NOW()');
expect(paymentConsume.sql).toContain('fairness_consumed_at IS NULL');
});
test('tampering with the persisted game proof aborts before entry consumption', async () => {
const serverSeed = 'ef'.repeat(32);
const payment = {
id: 91,
user_id: 7,
fairness_proof_version: 2,
fairness_offer_id: 'tamper-proof',
fairness_offer_issued_at: new Date(1700000001234),
fairness_commitment: hashSeed(serverSeed),
fairness_server_seed: serverSeed,
fairness_client_seed: '01'.repeat(32),
fairness_bound_at: new Date(),
fairness_consumed_at: null
};
const client = {
query: jest.fn(async sql => {
if (/SELECT \* FROM payments/i.test(sql)) return { rows: [payment] };
if (/SELECT proof_version/i.test(sql)) {
return { rows: [{
proof_version: 2,
fairness_offer_id: payment.fairness_offer_id,
fairness_offer_issued_at: payment.fairness_offer_issued_at,
proof_commitment: payment.fairness_commitment,
server_seed: payment.fairness_server_seed,
client_seed: 'ff'.repeat(32)
}] };
}
return { rows: [], rowCount: 0 };
})
};
const manager = makeManager({ withTransaction: jest.fn(async callback => callback(client)) });
await expect(manager._processGameStartWithPayment(
{ id: 7, payout_address: null },
{ id: payment.id },
'tampered-game'
)).rejects.toMatchObject({ code: 'PAYMENT_FAIRNESS_MISMATCH' });
expect(client.query.mock.calls.some(([sql]) => /UPDATE games/i.test(sql))).toBe(false);
});
});
+642
View File
@@ -0,0 +1,642 @@
'use strict';
const crypto = require('crypto');
const MatchScheduler = require('../src/network/matchScheduler');
const MatchQueue = require('../src/network/matchQueue');
const SocketHandlers = require('../src/network/socketHandlers');
const {
PAID_SEED_VERSION,
buildPaidEntrantFreeze,
deriveFutureBlockMatchSeed
} = require('../src/network/matchFairness');
const FUTURE_HASH = 'ab'.repeat(32);
function entrant(queueEntryId, userId = queueEntryId) {
return {
queueEntryId,
userId,
socketId: `socket-${userId}`,
sessionToken: `session-${userId}`,
createdAt: queueEntryId
};
}
class DurablePaidQueueDouble {
constructor(entries = []) {
this.initialized = true;
this.queues = {
free: [],
credits_prestige: entries.slice(),
crypto_race: []
};
this.frozen = [];
this.freezeCalls = [];
this.cancelled = [];
}
async refundUnavailableQueues() {}
isEconomyAvailable() { return true; }
isFinancialRecoveryReady() { return true; }
async freezePaidMatch(spec) {
const queue = this.queues[spec.economy];
if (!queue || queue.length < spec.minPlayers) return null;
const entries = queue.slice(0, spec.maxPlayers);
const freeze = buildPaidEntrantFreeze({
freezeBlockHeight: spec.freezeBlockHeight,
targetBlockHeight: spec.targetBlockHeight,
economy: spec.economy,
rulesetId: spec.rulesetId,
queueEntryIds: entries.map(entry => entry.queueEntryId)
});
const group = {
id: crypto.randomUUID(),
economy: spec.economy,
variant: spec.variant,
rulesetId: spec.rulesetId,
difficultyPreset: spec.difficultyPreset,
maxPlayers: spec.maxPlayers,
targetBlockHeight: spec.targetBlockHeight,
freezeCommitment: freeze.freezeCommitment,
precommitTipHeight: null,
precommitVerifiedAt: null,
freeze,
entries,
status: 'pending'
};
this.freezeCalls.push(spec);
this.frozen.push(group);
this.queues[spec.economy] = queue.slice(entries.length);
return group;
}
async verifyFrozenPrecommit(id, commitment, tip) {
const group = this.frozen.find(candidate => candidate.id === id
&& candidate.status === 'pending'
&& candidate.freezeCommitment === commitment
&& candidate.targetBlockHeight > tip);
if (!group) return { verified: false };
group.precommitTipHeight = tip;
group.precommitVerifiedAt = new Date().toISOString();
return {
verified: true,
observedTipHeight: tip,
verifiedAt: group.precommitVerifiedAt
};
}
async listFrozenPaidMatches(throughBlockHeight) {
return this.frozen.filter(group => group.status === 'pending'
&& (throughBlockHeight == null || group.targetBlockHeight <= throughBlockHeight));
}
noteFrozenStarted(id) {
const group = this.frozen.find(candidate => candidate.id === id);
if (group) group.status = 'starting';
}
async cancelFrozenMatch(id) {
const group = this.frozen.find(candidate => candidate.id === id && candidate.status === 'pending');
if (!group) return { claimed: false, resolved: true, refunded: 0 };
group.status = 'cancelled';
this.cancelled.push(...group.entries.map(entry => entry.queueEntryId));
return { claimed: true, resolved: true, refunded: group.entries.length };
}
async refundEntries(entries) {
const ids = new Set((entries || []).map(entry => String(entry.queueEntryId)));
const group = this.frozen.find(candidate => candidate.entries.some(
entry => ids.has(String(entry.queueEntryId))
));
if (group && group.status !== 'cancelled') {
group.status = 'cancelled';
this.cancelled.push(...group.entries.map(entry => entry.queueEntryId));
}
return { attempted: ids.size, resolved: ids.size, failed: 0 };
}
async drain(economy, maxPlayers, minPlayers) {
const queue = this.queues[economy] || [];
if (queue.length < minPlayers) return null;
const entries = queue.slice(0, maxPlayers);
this.queues[economy] = queue.slice(entries.length);
return { entries };
}
}
function managerDouble() {
return {
attached: [],
engines: new Map(),
db: null,
attach(room, entries) { this.attached.push({ room, entries }); },
setEngine(id, engine) { this.engines.set(id, engine); },
onTick() {},
onFinish() {}
};
}
describe('paid PvP future-block entrant freeze', () => {
const schedulers = [];
beforeEach(() => {
process.env.MATCH_ENABLED = 'true';
});
afterEach(async () => {
await Promise.all(schedulers.splice(0).map(scheduler => scheduler.shutdown()));
delete process.env.MATCH_ENABLED;
});
function schedulerFor(queue, manager, provider, countProvider) {
const scheduler = new MatchScheduler({
matchQueue: queue,
matchManager: manager,
blockEntropyProvider: provider,
blockCountProvider: countProvider,
paidEntropyDelayBlocks: 2,
paidEntropyConfirmations: 2,
rulesetId: 'last-alive',
maxPlayers: 2,
countdownMs: 10 ** 9
});
schedulers.push(scheduler);
return scheduler;
}
test('delay=2/conf=2 freezes FIFO at H, reads no hash through target, then starts from the stable exact header', async () => {
const queue = new DurablePaidQueueDouble([entrant(12), entrant(3), entrant(99)]);
const manager = managerDouble();
let daemonCount = 101;
const entropyCalls = [];
const scheduler = schedulerFor(queue, manager, async height => {
entropyCalls.push(height);
if (height === 100) throw new Error('the freeze-block hash must never be requested');
return height === 102
? { block_header: { height, hash: FUTURE_HASH } }
: null;
}, async () => daemonCount);
// DebugManager emits block count 101, whose actual canonical tip header is height 100.
await scheduler.onBlock(101);
expect(entropyCalls).toEqual([]);
expect(queue.frozen).toHaveLength(1);
expect(queue.frozen[0].entries.map(entry => entry.queueEntryId)).toEqual([12, 3]);
expect(queue.frozen[0].freeze.queueEntryIds).toEqual(['3', '12']);
expect(manager.attached).toHaveLength(0);
daemonCount = 102;
await scheduler.onBlock(102); // H+1
daemonCount = 103;
await scheduler.onBlock(103); // target exists, but only confirmation one
expect(entropyCalls).toEqual([]);
expect(manager.attached).toHaveLength(0);
daemonCount = 104;
await scheduler.onBlock(104); // target+1: confirmation two
expect(entropyCalls).toEqual([102, 102, 102]);
expect(manager.attached).toHaveLength(1);
const { room, entries } = manager.attached[0];
const expected = deriveFutureBlockMatchSeed({
blockHash: FUTURE_HASH,
blockHeight: 102,
freeze: queue.frozen[0].freeze
});
expect(entries.map(entry => entry.queueEntryId)).toEqual([12, 3]);
expect(room.seed).toBe(expected.seed);
expect(room.seedDerivation).toEqual(expect.objectContaining(expected.derivation));
expect(room.seedDerivation.version).toBe(PAID_SEED_VERSION);
expect(room.seedDerivation.blockHash).toBe(FUTURE_HASH);
expect(room.seedDerivation).toEqual(expect.objectContaining({
entropyDelayBlocks: 2,
entropyConfirmations: 2,
minimumConfirmedTipHeight: 103,
precommitTipHeight: 100
}));
expect(room.fairnessProof(false)).toEqual(expect.objectContaining({
blockHeight: 102,
blockHash: FUTURE_HASH,
freezeBlockHeight: 100,
targetBlockHeight: 102,
entropyDelayBlocks: 2,
entropyConfirmations: 2,
minimumConfirmedTipHeight: 103,
precommitTipHeight: 100
}));
});
test('a delayed freeze commit that crosses its target performs zero hash/attach work and refunds once', async () => {
const queue = new DurablePaidQueueDouble([entrant(1), entrant(2)]);
const manager = managerDouble();
manager.matchPayoutService = { collectEntryTickets: jest.fn() };
const entropy = jest.fn();
const scheduler = schedulerFor(queue, manager, entropy, async () => 203);
await scheduler.onBlock(101); // freeze H=100, target=102, post-commit tip already 202
expect(manager.attached).toHaveLength(0);
expect(manager.matchPayoutService.collectEntryTickets).not.toHaveBeenCalled();
expect(entropy).not.toHaveBeenCalled();
expect(queue.freezeCalls).toHaveLength(1);
expect(queue.cancelled).toEqual([1, 2]);
await scheduler.onBlock(101);
expect(queue.cancelled).toEqual([1, 2]);
});
test('strict count failure cannot activate and the due unverified freeze is refunded without a hash read', async () => {
const queue = new DurablePaidQueueDouble([entrant(21), entrant(22)]);
const manager = managerDouble();
const entropy = jest.fn();
const scheduler = schedulerFor(queue, manager, entropy, async () => {
throw new Error('daemon unavailable');
});
await scheduler.onBlock(301);
await scheduler.onBlock(302);
await scheduler.onBlock(303);
expect(manager.attached).toHaveLength(0);
expect(entropy).not.toHaveBeenCalled();
expect(queue.cancelled).toEqual([21, 22]);
});
test('a header reorg between the two pre-activation reads refunds without gameplay', async () => {
const queue = new DurablePaidQueueDouble([entrant(41), entrant(42)]);
const manager = managerDouble();
let daemonCount = 201;
const hashes = [FUTURE_HASH, 'cd'.repeat(32)];
const entropy = jest.fn(async height => ({
block_header: { height, hash: hashes.shift() || hashes.at(-1) }
}));
const scheduler = schedulerFor(queue, manager, entropy, async () => daemonCount);
await scheduler.onBlock(201); // H=200, target=202
daemonCount = 204;
await scheduler.onBlock(204);
expect(manager.attached).toHaveLength(0);
expect(manager.engines.size).toBe(0);
expect(queue.cancelled).toEqual([41, 42]);
expect(entropy).toHaveBeenCalledTimes(2);
});
test('a daemon response for the wrong height cannot activate the paid freeze', async () => {
const queue = new DurablePaidQueueDouble([entrant(45), entrant(46)]);
const manager = managerDouble();
let daemonCount = 211;
const entropy = jest.fn(async () => ({
block_header: { height: 999, hash: FUTURE_HASH }
}));
const scheduler = schedulerFor(queue, manager, entropy, async () => daemonCount);
await scheduler.onBlock(211); // H=210, target=212
daemonCount = 214;
await scheduler.onBlock(214);
expect(manager.attached).toHaveLength(0);
expect(queue.frozen[0].status).toBe('pending');
expect(entropy).toHaveBeenCalledTimes(1);
});
test('a post-activation reorg aborts/refunds before engine attach', async () => {
const queue = new DurablePaidQueueDouble([entrant(51), entrant(52)]);
const manager = managerDouble();
manager.matchPayoutService = { collectEntryTickets: jest.fn() };
let daemonCount = 301;
const hashes = [FUTURE_HASH, FUTURE_HASH, 'ef'.repeat(32)];
const entropy = jest.fn(async height => ({
block_header: { height, hash: hashes.shift() }
}));
const scheduler = schedulerFor(queue, manager, entropy, async () => daemonCount);
await scheduler.onBlock(301);
daemonCount = 304;
await scheduler.onBlock(304);
expect(manager.attached).toHaveLength(0);
expect(manager.matchPayoutService.collectEntryTickets).not.toHaveBeenCalled();
expect(manager.engines.size).toBe(0);
expect(queue.cancelled).toEqual([51, 52]);
expect(entropy).toHaveBeenCalledTimes(3);
});
test('legacy v1 pending freezes are cancelled before any target lookup', async () => {
const queue = new DurablePaidQueueDouble();
const entries = [entrant(61), entrant(62)];
queue.frozen.push({
id: crypto.randomUUID(),
economy: 'credits_prestige',
rulesetId: 'last-alive',
targetBlockHeight: 401,
freezeCommitment: '0'.repeat(64),
freeze: {
version: 'future-block-freeze-v1', freezeBlockHeight: 400,
targetBlockHeight: 401, economy: 'credits_prestige', rulesetId: 'last-alive',
queueEntryIds: ['61', '62'], freezeCommitment: '0'.repeat(64)
},
entries,
status: 'pending'
});
const entropy = jest.fn();
const scheduler = schedulerFor(queue, managerDouble(), entropy, async () => 403);
await scheduler.onBlock(402);
expect(entropy).not.toHaveBeenCalled();
expect(queue.cancelled).toEqual([61, 62]);
});
test('the default provider fetches the exact canonical header height disclosed in proofs', async () => {
const rpc = { getBlockByHeight: jest.fn().mockResolvedValue({ hash: FUTURE_HASH }) };
const scheduler = new MatchScheduler({
matchQueue: new DurablePaidQueueDouble(),
matchManager: managerDouble(),
debugManager: { rpcService: rpc },
rulesetId: 'last-alive'
});
schedulers.push(scheduler);
await scheduler.blockEntropyProvider(808);
expect(rpc.getBlockByHeight).toHaveBeenCalledWith(808);
});
});
describe('MatchQueue durable freeze/refund transactions', () => {
beforeEach(() => { process.env.MATCH_ENABLED = 'true'; });
afterEach(() => { delete process.env.MATCH_ENABLED; });
test('writes pending match, exact queue links, and entrants in one freeze transaction', async () => {
const calls = [];
const entries = [entrant(61, 6), entrant(62, 7)];
const db = {
async withTransaction(fn) {
return fn({
async query(sql, params) {
calls.push({ sql, params });
if (sql.includes('INSERT INTO matches')) return { rows: [{ id: params[0] }], rowCount: 1 };
if (sql.includes('UPDATE match_queue_entries')) {
return {
rows: entries.map(entry => ({ id: entry.queueEntryId, user_id: entry.userId })),
rowCount: entries.length
};
}
return { rows: [{ id: 1 }], rowCount: 1 };
}
});
}
};
const queue = new MatchQueue({ db });
queue._queues.credits_prestige = entries.slice();
const frozen = await queue.freezePaidMatch({
economy: 'credits_prestige',
minPlayers: 2,
maxPlayers: 2,
freezeBlockHeight: 600,
targetBlockHeight: 601,
rulesetId: 'last-alive',
variant: 'pvp',
difficultyPreset: 'race'
});
expect(frozen.entries.map(entry => entry.queueEntryId)).toEqual([61, 62]);
expect(queue.length('credits_prestige')).toBe(0);
const pending = calls.find(call => call.sql.includes('INSERT INTO matches'));
const links = calls.find(call => call.sql.includes('UPDATE match_queue_entries'));
expect(pending.sql).toContain("'pending'");
expect(pending.params[6]).toBe(frozen.freeze.freezeCommitment);
expect(pending.params[8]).toBe(601);
expect(links.params[1]).toEqual(['61', '62']);
expect(calls.filter(call => call.sql.includes('INSERT INTO match_entrants'))).toHaveLength(2);
});
test('records the fresh post-commit daemon tip only while the target is still future', async () => {
const calls = [];
const verifiedAt = new Date('2026-07-21T12:00:00Z');
const db = {
async withTransaction(fn) {
return fn({
async query(sql, params) {
calls.push({ sql, params });
return {
rows: [{
id: params[0], start_block_height: 502,
entropy_precommit_tip_height: params[2],
entropy_precommit_verified_at: verifiedAt
}],
rowCount: 1
};
}
});
},
query: jest.fn()
};
const queue = new MatchQueue({ db });
const proof = await queue.verifyFrozenPrecommit(
crypto.randomUUID(), 'ab'.repeat(32), 500
);
expect(proof).toEqual(expect.objectContaining({
verified: true, targetBlockHeight: 502, observedTipHeight: 500
}));
expect(calls[0].sql).toContain('start_block_height > $3');
expect(calls[0].sql).toContain('entropy_precommit_tip_height IS NULL');
expect(calls[0].sql).toContain("status = 'pending'");
expect(db.query).not.toHaveBeenCalled();
});
test('startup cancels v1 and crash-before-verification freezes; a failed cancellation retries', async () => {
const canonical = buildPaidEntrantFreeze({
freezeBlockHeight: 800,
targetBlockHeight: 802,
economy: 'credits_prestige',
rulesetId: 'last-alive',
queueEntryIds: [81, 82]
});
const queue = new MatchQueue({ db: {} });
queue.listFrozenPaidMatches = jest.fn().mockResolvedValue([
{
id: 'v2-unverified', freeze: canonical,
freezeCommitment: canonical.freezeCommitment,
precommitTipHeight: null, precommitVerifiedAt: null
},
{
id: 'v1-legacy', freeze: { ...canonical, version: 'future-block-freeze-v1' },
freezeCommitment: canonical.freezeCommitment,
precommitTipHeight: 800, precommitVerifiedAt: new Date()
}
]);
const attempts = new Map();
queue.cancelFrozenMatch = jest.fn(async id => {
attempts.set(id, (attempts.get(id) || 0) + 1);
if (id === 'v2-unverified' && attempts.get(id) === 1) {
return { resolved: false };
}
return { resolved: true };
});
const first = await queue.cancelUnverifiedPaidFreezes(
'match_cancel', { failOnError: false }
);
const second = await queue.cancelUnverifiedPaidFreezes(
'match_cancel', { failOnError: false }
);
expect(first).toEqual(expect.objectContaining({ ok: false, failed: 1 }));
expect(second).toEqual(expect.objectContaining({ ok: true, failed: 0 }));
expect(attempts.get('v2-unverified')).toBe(2);
expect(attempts.get('v1-legacy')).toBe(2);
});
test('a lost freeze COMMIT acknowledgement adopts the exact durable envelope once', async () => {
const entries = [entrant(63, 9), entrant(64, 10)];
let pendingParams = null;
const db = {
async withTransaction(fn) {
await fn({
async query(sql, params) {
if (sql.includes('INSERT INTO matches')) {
pendingParams = params;
return { rows: [{ id: params[0] }], rowCount: 1 };
}
if (sql.includes('UPDATE match_queue_entries')) {
return {
rows: entries.map(entry => ({ id: entry.queueEntryId, user_id: entry.userId })),
rowCount: entries.length
};
}
return { rows: [{ id: 1 }], rowCount: 1 };
}
});
throw new Error('commit acknowledgement lost');
},
async query(sql) {
expect(sql).toContain("m.status = 'pending'");
const dungeon = JSON.parse(pendingParams[7]);
return {
rows: entries.map(entry => ({
id: pendingParams[0],
economy: 'credits_prestige',
variant: 'pvp',
ruleset_id: 'last-alive',
difficulty_preset: 'race',
max_players: 2,
seed_hash: pendingParams[6],
start_block_height: 611,
dungeon,
queue_entry_id: entry.queueEntryId,
user_id: entry.userId,
socket_id: entry.socketId,
session_token: entry.sessionToken,
created_at: new Date(entry.createdAt).toISOString()
})),
rowCount: entries.length
};
}
};
const queue = new MatchQueue({ db });
queue._queues.credits_prestige = entries.slice();
const frozen = await queue.freezePaidMatch({
economy: 'credits_prestige',
minPlayers: 2,
maxPlayers: 2,
freezeBlockHeight: 610,
targetBlockHeight: 611,
rulesetId: 'last-alive',
variant: 'pvp',
difficultyPreset: 'race'
});
expect(frozen).toEqual(expect.objectContaining({
id: pendingParams[0],
freezeCommitment: pendingParams[6]
}));
expect(queue.length('credits_prestige')).toBe(0);
});
test('shutdown cancellation claims a pending freeze and refunds each anchor exactly once', async () => {
const freeze = buildPaidEntrantFreeze({
freezeBlockHeight: 700,
targetBlockHeight: 701,
economy: 'credits_prestige',
rulesetId: 'last-alive',
queueEntryIds: [71, 72]
});
let pending = true;
let creditsRefunded = 0;
let ledgerRows = 0;
const db = {
async withTransaction(fn) {
return fn({
async query(sql) {
if (sql.includes('UPDATE matches')) {
if (!pending) return { rows: [], rowCount: 0 };
pending = false;
return {
rows: [{ economy: 'credits_prestige', dungeon: { match_fairness_freeze: freeze } }],
rowCount: 1
};
}
if (sql.includes('UPDATE match_queue_entries')) {
return {
rows: [
{ id: 71, user_id: 7, escrow_amount: '1', race_entry_lot_id: null },
{ id: 72, user_id: 8, escrow_amount: '1', race_entry_lot_id: null }
],
rowCount: 2
};
}
if (sql.includes('UPDATE users')) {
creditsRefunded += 1;
return { rows: [{ credits: 10 }], rowCount: 1 };
}
if (sql.includes('INSERT INTO credit_transactions')) {
ledgerRows += 1;
return { rows: [], rowCount: 1 };
}
return { rows: [], rowCount: 1 };
}
});
}
};
const queue = new MatchQueue({ db });
const freezeId = crypto.randomUUID();
queue.listFrozenPaidMatches = jest.fn().mockResolvedValue([{
id: freezeId, economy: 'credits_prestige', entries: [entrant(71), entrant(72)]
}]);
await queue.shutdown();
await queue.shutdown();
expect(queue.listFrozenPaidMatches).toHaveBeenCalledTimes(2);
expect({ creditsRefunded, ledgerRows }).toEqual({ creditsRefunded: 2, ledgerRows: 2 });
});
test('orderly server shutdown waits for scheduler, then refunds freezes, then stops matches', async () => {
const order = [];
const context = {
beginShutdown: jest.fn(),
debugManager: { CONSOLE_LOGGING: false },
tavernManager: null,
matchScheduler: { shutdown: jest.fn(async () => { order.push('scheduler'); }) },
matchQueue: { shutdown: jest.fn(async () => { order.push('queue'); }) },
matchManager: { shutdown: jest.fn(() => { order.push('manager'); }) },
spectatorManager: null,
chatHandler: null,
connectionHandler: null,
rateLimiter: null,
memoryManager: null,
sessionManager: null,
suspendedGameManager: null,
paymentHandlers: null,
activeGames: new Map()
};
await SocketHandlers.prototype.shutdown.call(context);
expect(order).toEqual(['scheduler', 'queue', 'manager']);
});
});
+38
View File
@@ -0,0 +1,38 @@
const PaymentConfigManager = require('../src/config/paymentConfig');
const GameModeManager = require('../src/game/gameModeManager');
describe('PaymentConfigManager atomic-unit precision', () => {
const originalPrice = process.env.DIRECT_GAME_PRICE;
afterEach(() => {
if (originalPrice === undefined) delete process.env.DIRECT_GAME_PRICE;
else process.env.DIRECT_GAME_PRICE = originalPrice;
});
test('parses integer environment values above Number.MAX_SAFE_INTEGER exactly', () => {
process.env.DIRECT_GAME_PRICE = '100000000000000001';
const config = new PaymentConfigManager({
logger: { warn: jest.fn(), info: jest.fn(), error: jest.fn() }
}).getConfig();
expect(config.modes.direct.price).toBe(100000000000000001n);
});
test('game mode snapshot and public transport preserve large atomic prices exactly', () => {
process.env.DIRECT_GAME_PRICE = '100000000000000001';
const manager = new PaymentConfigManager({
logger: { warn: jest.fn(), info: jest.fn(), error: jest.fn() }
});
const gmm = new GameModeManager(
{ query: jest.fn() },
{},
{ CONSOLE_LOGGING: false },
manager
);
expect(gmm.singleGamePrice).toBe('100000000000000001');
expect(gmm.getGameModeInfo().singleGamePrice).toBe('100000000000000001');
expect(gmm.calculatePayout('PAID_SINGLE').amount).toBe('200000000000000002');
});
});
+197 -2
View File
@@ -1,4 +1,16 @@
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' };
@@ -52,7 +64,9 @@ describe('PaymentHandlers basic', () => {
const emitted = {};
mocks.io.to = () => ({ emit: (event, data) => { emitted[event] = data; } });
ph = new PaymentHandlers(mocks);
await ph.createAndShowPaymentRequest({ id: 'sock1' });
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.
@@ -65,9 +79,190 @@ describe('PaymentHandlers basic', () => {
mocks.gameModeManager.paymentProviders = { getProvider: jest.fn(() => gateway) };
mocks.io.to = () => ({ emit: () => {} });
ph = new PaymentHandlers(mocks);
await ph.createAndShowPaymentRequest({ id: 'sock2' });
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 }));
});
});
+117
View File
@@ -0,0 +1,117 @@
const PaymentConfigManager = require('../src/config/paymentConfig');
const GameModeManager = require('../src/game/gameModeManager');
const ENV_KEYS = [
'GAME_MODE',
'PAYMENTS_ENABLED',
'PAYMENT_MODES',
'DIRECT_PAYMENT_ENABLED',
'CREDITS_ENABLED',
'FREE_PLAY_ENABLED',
'PAYOUTS_ENABLED',
'DIRECT_PAYOUTS_ENABLED',
'CREDITS_PAYOUTS_ENABLED',
'PAYOUT_MAX_PER_GAME',
'CREDITS_PAYOUT_MAX'
];
describe('production payment policy wiring', () => {
let saved;
beforeEach(() => {
saved = Object.fromEntries(ENV_KEYS.map(key => [key, process.env[key]]));
for (const key of ENV_KEYS) delete process.env[key];
});
afterEach(() => {
for (const key of ENV_KEYS) {
if (saved[key] === undefined) delete process.env[key];
else process.env[key] = saved[key];
}
});
test('PAYOUTS_ENABLED=false remains authoritative with both per-mode flags true', () => {
process.env.PAYMENTS_ENABLED = 'true';
process.env.PAYMENT_MODES = 'direct,credits';
process.env.PAYOUTS_ENABLED = 'false';
process.env.DIRECT_PAYOUTS_ENABLED = 'true';
process.env.CREDITS_PAYOUTS_ENABLED = 'true';
process.env.FREE_PLAY_ENABLED = 'false';
const configManager = new PaymentConfigManager({ logger: { warn() {}, info() {} } });
const manager = new GameModeManager(
{ query: jest.fn(), withTransaction: jest.fn() },
{ validateAddress: jest.fn() },
{ CONSOLE_LOGGING: false },
configManager
);
expect(configManager.getConfig().payouts.enabled).toBe(false);
expect(manager.payoutsEnabled).toBe(false);
expect(manager.isPayoutEnabledForMode('PAID_SINGLE')).toBe(false);
expect(manager.isPayoutEnabledForMode('PAID_CREDITS')).toBe(false);
expect(manager.getGameModeInfo()).toEqual(expect.objectContaining({
payoutsEnabled: false,
directPayoutsEnabled: false,
creditsPayoutsEnabled: false,
freePlayEnabled: false
}));
});
test('PAYMENTS_ENABLED=false remains authoritative over every per-mode enable', () => {
process.env.PAYMENTS_ENABLED = 'false';
process.env.PAYMENT_MODES = 'direct,credits';
process.env.DIRECT_PAYMENT_ENABLED = 'true';
process.env.CREDITS_ENABLED = 'true';
const config = new PaymentConfigManager({ logger: { warn() {}, info() {} } }).getConfig();
expect(config.paymentsEnabled).toBe(false);
expect(config.modes.direct.enabled).toBe(false);
expect(config.modes.credits.enabled).toBe(false);
});
test('invoice creation refuses before database or wallet work when the master switch is off', async () => {
process.env.PAYMENTS_ENABLED = 'false';
process.env.PAYMENT_MODES = 'direct,credits';
const configManager = new PaymentConfigManager({ logger: { warn() {}, info() {} } });
const db = { query: jest.fn(), withTransaction: jest.fn() };
const wallet = { createPaymentRequest: jest.fn(), validateAddress: jest.fn() };
const manager = new GameModeManager(db, wallet, { CONSOLE_LOGGING: false }, configManager);
await expect(manager.createPaymentRequest('socket-disabled', 'single_game'))
.rejects.toMatchObject({ code: 'PAYMENT_INTAKE_DISABLED' });
await expect(manager.createPaymentRequest('socket-disabled', 'credits_package'))
.rejects.toMatchObject({ code: 'PAYMENT_INTAKE_DISABLED' });
expect(db.query).not.toHaveBeenCalled();
expect(wallet.createPaymentRequest).not.toHaveBeenCalled();
});
test('per-product switches cannot be bypassed through the generic invoice method', async () => {
process.env.PAYMENTS_ENABLED = 'true';
process.env.PAYMENT_MODES = 'direct';
process.env.DIRECT_PAYMENT_ENABLED = 'true';
process.env.CREDITS_ENABLED = 'false';
const configManager = new PaymentConfigManager({ logger: { warn() {}, info() {} } });
const db = { query: jest.fn(), withTransaction: jest.fn() };
const wallet = { createPaymentRequest: jest.fn(), validateAddress: jest.fn() };
const manager = new GameModeManager(db, wallet, { CONSOLE_LOGGING: false }, configManager);
expect(manager.isPaymentIntakeEnabled('single_game')).toBe(true);
expect(manager.isPaymentIntakeEnabled('credits_package')).toBe(false);
await expect(manager.createPaymentRequest('socket-disabled', 'credits_package'))
.rejects.toMatchObject({ code: 'PAYMENT_INTAKE_DISABLED' });
expect(db.query).not.toHaveBeenCalled();
expect(wallet.createPaymentRequest).not.toHaveBeenCalled();
});
test('global per-game payout cap is an outer bound for direct and credit wins', () => {
process.env.PAYMENT_MODES = 'direct,credits';
process.env.PAYOUT_MAX_PER_GAME = '123456';
const config = new PaymentConfigManager({ logger: { warn() {}, info() {} } }).getConfig();
expect(config.payouts.rules.direct.maxPayout).toBe(123456n);
expect(config.payouts.rules.credits.maxPayout).toBe(123456n);
});
});
+10
View File
@@ -96,6 +96,16 @@ describe('BTCPayProvider (Greenfield)', () => {
expect(st.amount).toBe(150000000000); // 1.5 * 1e11
});
test('gateway atomic amounts above Number.MAX_SAFE_INTEGER remain exact strings', async () => {
const st = await mk('Settled', [{
cryptoCode: 'XMR',
totalPaid: '10000.000000000001',
amount: '10000.000000000001'
}], ['XMR']).getWalletStatus('inv1');
expect(st.amount).toBe('10000000000000001');
expect(st.required).toBe('10000000000000001');
});
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);
+128
View File
@@ -0,0 +1,128 @@
const fs = require('fs');
const path = require('path');
const GameModeManager = require('../src/game/gameModeManager');
function chainReceipt(txHash, outputId, addressIndex, amount) {
return {
evidenceType: 'chain_output',
evidenceId: `${txHash}:${outputId}`,
providerId: 'native-monero',
txHash,
outputId,
addressIndex,
amount: String(amount),
confirmed: true
};
}
function receiptDb(rows) {
const payments = new Map(rows.map(row => [row.id, { provider_id: 'native-monero', status: 'pending', ...row }]));
const receipts = [];
const client = {
query: jest.fn(async (sql, params = []) => {
if (/SELECT id, status, expected_amount/i.test(sql)) {
const payment = payments.get(params[0]);
return { rows: payment ? [{ ...payment }] : [] };
}
if (/INSERT INTO payment_receipts/i.test(sql)) {
const candidate = {
payment_id: params[0], provider_id: params[1], evidence_type: params[2],
evidence_id: params[3], tx_hash: params[4], output_id: params[5],
address_index: params[6], amount: params[7]
};
const occupied = receipts.some(receipt => receipt.provider_id === candidate.provider_id
&& receipt.evidence_id === candidate.evidence_id);
if (!occupied) receipts.push(candidate);
return { rows: [], rowCount: occupied ? 0 : 1 };
}
if (/COALESCE\(SUM\(amount\)/i.test(sql)) {
const owned = receipts.filter(receipt => receipt.payment_id === params[0]
&& receipt.provider_id === params[1]);
const total = owned.reduce((sum, receipt) => sum + BigInt(receipt.amount), 0n);
const hashes = owned.map(receipt => receipt.tx_hash).filter(Boolean).sort();
return { rows: [{ total: total.toString(), primary_tx_hash: hashes[0] || null }] };
}
if (/UPDATE payments/i.test(sql)) {
const payment = payments.get(params[0]);
if (!payment || payment.status !== 'pending') return { rows: [] };
payment.status = 'confirmed';
payment.received_amount = params[1];
payment.tx_hash = params[2];
return { rows: [{ id: payment.id }] };
}
return { rows: [], rowCount: 0 };
})
};
return {
withTransaction: jest.fn(async callback => callback(client)),
payments,
receipts
};
}
describe('durable incoming payment receipts', () => {
test('one Monero transaction can confirm distinct invoice subaddresses, not the same output twice', async () => {
const hash = 'a'.repeat(64);
const outputOne = '1'.repeat(64);
const outputTwo = '2'.repeat(64);
const db = receiptDb([
{ id: 1, expected_amount: '100', address_index: 5 },
{ id: 2, expected_amount: '100', address_index: 6 },
{ id: 3, expected_amount: '100', address_index: 5 }
]);
const manager = Object.create(GameModeManager.prototype);
manager.db = db;
await expect(manager.confirmSingleGamePayment(1, '100', [chainReceipt(hash, outputOne, 5, 100)]))
.resolves.toEqual({ updated: true });
await expect(manager.confirmSingleGamePayment(2, '100', [chainReceipt(hash, outputTwo, 6, 100)]))
.resolves.toEqual({ updated: true });
await expect(manager.confirmSingleGamePayment(3, '100', [chainReceipt(hash, outputOne, 5, 100)]))
.rejects.toThrow(/do not cover/i);
expect(db.payments.get(1).status).toBe('confirmed');
expect(db.payments.get(2).status).toBe('confirmed');
expect(db.payments.get(3).status).toBe('pending');
expect(db.receipts).toHaveLength(2);
});
test('several confirmed top-ups can exactly cover one invoice', async () => {
const db = receiptDb([{ id: 4, expected_amount: '100', address_index: 9 }]);
const manager = Object.create(GameModeManager.prototype);
manager.db = db;
const result = await manager.confirmSingleGamePayment(4, '100', [
chainReceipt('b'.repeat(64), '3'.repeat(64), 9, 40),
chainReceipt('c'.repeat(64), '4'.repeat(64), 9, 60)
]);
expect(result.updated).toBe(true);
expect(db.receipts).toHaveLength(2);
expect(db.payments.get(4).received_amount).toBe('100');
});
test('distinct outputs in one transaction to one subaddress sum once despite replay', async () => {
const hash = 'd'.repeat(64);
const first = chainReceipt(hash, '5'.repeat(64), 12, 40);
const second = chainReceipt(hash, '6'.repeat(64), 12, 60);
const db = receiptDb([{ id: 5, expected_amount: '100', address_index: 12 }]);
const manager = Object.create(GameModeManager.prototype);
manager.db = db;
const result = await manager.confirmSingleGamePayment(5, '140', [first, second, { ...first }]);
expect(result).toEqual({ updated: true });
expect(db.receipts).toHaveLength(2);
expect(db.payments.get(5).received_amount).toBe('100');
});
test('migration removes txid-only uniqueness and installs the output-scoped forward guard', () => {
const sql = fs.readFileSync(path.join(__dirname, '../src/migrations/037_payment_receipt_evidence.sql'), 'utf8');
expect(sql).toMatch(/DROP INDEX IF EXISTS idx_payments_tx_hash_unique/i);
expect(sql).not.toMatch(/CREATE\s+UNIQUE\s+INDEX[\s\S]*?ON\s+payments\s*\(\s*tx_hash\s*,\s*address_index\s*\)/i);
expect(sql).toMatch(/ON\s+payment_receipts\s*\(\s*provider_id\s*,\s*evidence_id\s*\)/i);
expect(sql).not.toMatch(/ON\s+payment_receipts\s*\(\s*tx_hash\s*,\s*address_index\s*\)/i);
expect(sql).not.toMatch(/UNIQUE\s*\(subaddress\)/i);
expect(sql).toMatch(/evidence_id\s*=\s*tx_hash\s*\|\|\s*':'\s*\|\|\s*output_id/i);
expect(sql).toMatch(/OLD\.status IS DISTINCT FROM 'confirmed'/i);
expect(sql).toMatch(/receipt_total < NEW\.expected_amount/i);
});
});
+585
View File
@@ -0,0 +1,585 @@
const PaymentRefundService = require('../src/services/paymentRefundService');
function clone(value) {
return value == null ? value : { ...value };
}
class Mutex {
constructor() {
this.locked = false;
this.waiters = [];
}
async acquire() {
if (!this.locked) {
this.locked = true;
return () => this.release();
}
return new Promise(resolve => this.waiters.push(resolve));
}
release() {
const next = this.waiters.shift();
if (next) {
next(() => this.release());
} else {
this.locked = false;
}
}
}
/** Minimal transactional DB double implementing only the refund service's SQL. */
class FakeRefundDb {
constructor({ payment, user, grant = null, raceLot = null, packEntitlements = [], refund = null }) {
this.payment = clone(payment);
this.user = clone(user);
this.grant = clone(grant);
this.raceLot = clone(raceLot);
this.packEntitlements = packEntitlements.map(clone);
this.raceTransactions = [];
this.refund = clone(refund);
this.creditTransactions = [];
this.paymentUpdateCount = 0;
this.nextRefundId = refund?.id ? Number(refund.id) + 1 : 1;
this.paymentLock = new Mutex();
}
async getClient() {
const db = this;
let unlock = null;
return {
async query(text, params = []) {
const sql = text.replace(/\s+/g, ' ').trim().toLowerCase();
if (sql === 'begin') return { rows: [] };
if (sql === 'commit' || sql === 'rollback') {
unlock?.();
unlock = null;
return { rows: [] };
}
if (sql.includes('select p.*') && sql.includes('from payments p')) {
unlock = await db.paymentLock.acquire();
return { rows: db.payment && Number(db.payment.id) === Number(params[0]) ? [clone(db.payment)] : [] };
}
if (sql.includes('from payment_refunds') && sql.includes('for update')) {
return { rows: db.refund ? [clone(db.refund)] : [] };
}
if (sql.includes('select id, credits, race_entries') && sql.includes('from users')) {
return { rows: db.user && Number(db.user.id) === Number(params[0]) ? [clone(db.user)] : [] };
}
if (sql.includes('from payment_entitlement_grants') && sql.includes('for update')) {
return { rows: db.grant ? [clone(db.grant)] : [] };
}
if (sql.startsWith('select id from games')) return { rows: [] };
if (sql.startsWith('update users') && sql.includes('credits = credits -')) {
db.user.credits = (BigInt(db.user.credits) - BigInt(params[0])).toString();
db.user.total_credits_purchased = (BigInt(db.user.total_credits_purchased) - BigInt(params[1])).toString();
db.user.race_entries -= Number(params[2]);
db.user.premium_level = params[3];
return { rows: [clone(db.user)] };
}
if (sql.startsWith('insert into credit_transactions')) {
db.creditTransactions.push({
user_id: params[0],
amount: params[1],
reason: params[2],
balance_after: params[3],
transaction_type: 'refund'
});
return { rows: [] };
}
if (sql.startsWith('insert into race_entry_transactions')) {
db.raceTransactions.push({ delta: params[1], payment_id: params[3] });
return { rows: [] };
}
if (sql.startsWith('update payment_entitlement_grants')) {
if (sql.includes("status = 'needs_review'")) {
db.grant.status = 'needs_review';
db.grant.needs_review_at = new Date();
} else {
db.grant.status = 'reversed';
db.grant.credits_reversed = params[1];
db.grant.purchase_progress_reversed = params[2];
db.grant.race_entries_reversed = params[3];
db.grant.reversed_at = new Date();
}
return { rows: [clone(db.grant)], rowCount: 1 };
}
if (sql.includes('from race_entry_lots')) return { rows: db.raceLot ? [clone(db.raceLot)] : [] };
if (sql.startsWith('update race_entry_lots')) {
if (!db.raceLot || db.raceLot.refunded_at != null
|| db.raceLot.remaining_entries !== db.raceLot.original_entries) return { rows: [], rowCount: 0 };
db.raceLot.remaining_entries = 0;
db.raceLot.refunded_at = new Date();
return { rows: [{ id: db.raceLot.id }], rowCount: 1 };
}
if (sql.includes('from user_pack_entitlements') && sql.includes('for update')) {
return { rows: db.packEntitlements.map(clone) };
}
if (sql.startsWith('delete from user_pack_entitlements')) {
const wanted = new Set(params[1]);
const removed = db.packEntitlements.filter(row => wanted.has(row.pack_id)
&& String(row.metadata?.paymentId) === String(params[2]));
db.packEntitlements = db.packEntitlements.filter(row => !removed.includes(row));
return { rows: removed.map(row => ({ pack_id: row.pack_id })), rowCount: removed.length };
}
if (sql.startsWith('update payments')) {
db.payment.status = 'refunded';
db.paymentUpdateCount += 1;
return { rows: [] };
}
if (sql.startsWith('insert into payment_refunds')) {
const needsReview = sql.includes("'needs_review'");
db.refund = {
id: db.nextRefundId++,
payment_id: params[0],
user_id: params[1],
status: needsReview ? 'needs_review' : params[2],
amount: needsReview ? params[2] : params[3],
payout_address: needsReview ? params[3] : params[4],
credits_deducted: needsReview ? '0' : params[5],
purchase_progress_deducted: needsReview ? '0' : params[6],
race_entries_deducted: needsReview ? 0 : params[7],
packs_revoked: needsReview ? [] : JSON.parse(params[8]),
premium_level_restored: needsReview ? null : params[9],
reason: needsReview ? params[4] : params[10],
tx_hash: null,
error_message: needsReview ? params[5] : null,
requested_at: needsReview || params[2] === 'requested' ? new Date() : null,
processing_started_at: null,
completed_at: null,
needs_review_at: needsReview ? new Date() : null
};
return { rows: [clone(db.refund)] };
}
throw new Error(`Unexpected transaction SQL: ${sql}`);
},
release() {
unlock?.();
unlock = null;
}
};
}
async query(text, params = []) {
const sql = text.replace(/\s+/g, ' ').trim().toLowerCase();
if (sql.startsWith('update payment_refunds') && sql.includes("set status = 'processing'")) {
if (!this.refund || Number(this.refund.payment_id) !== Number(params[0]) || this.refund.status !== 'requested') {
return { rows: [] };
}
this.refund.status = 'processing';
this.refund.processing_started_at = new Date();
this.refund.error_message = null;
return { rows: [clone(this.refund)] };
}
if (sql.startsWith('update payment_refunds') && sql.includes("set status = 'completed'")) {
if (!this.refund || Number(this.refund.id) !== Number(params[0]) || this.refund.status !== 'processing') {
return { rows: [] };
}
this.refund.status = 'completed';
this.refund.tx_hash = params[1];
this.refund.completed_at = new Date();
this.refund.error_message = null;
return { rows: [clone(this.refund)] };
}
if (sql.startsWith('update payment_refunds') && sql.includes("set status = 'needs_review'")) {
if (!this.refund || Number(this.refund.id) !== Number(params[0]) || this.refund.status !== 'processing') {
return { rows: [] };
}
this.refund.status = 'needs_review';
this.refund.tx_hash = params[1] || this.refund.tx_hash;
this.refund.error_message = params[2];
this.refund.needs_review_at = new Date();
return { rows: [clone(this.refund)] };
}
if (sql.startsWith('select *') && sql.includes('from payment_refunds')) {
return { rows: this.refund && Number(this.refund.payment_id) === Number(params[0]) ? [clone(this.refund)] : [] };
}
throw new Error(`Unexpected pool SQL: ${sql}`);
}
}
function fixture(overrides = {}) {
return new FakeRefundDb({
payment: {
id: 42,
user_id: 7,
expected_amount: '1000',
received_amount: '900',
credits_purchased: '6',
status: 'confirmed',
description: 'Credits purchase',
...overrides.payment
},
user: {
id: 7,
credits: '6',
race_entries: 0,
total_credits_purchased: '6',
premium_level: 'free',
payout_address: '5'.repeat(95),
...overrides.user
},
grant: overrides.grant === undefined ? {
payment_id: 42,
user_id: 7,
source: 'product_confirmation',
credits_granted: '6',
purchase_progress_granted: '6',
race_entries_granted: 0,
packs_granted: [],
premium_level_granted: null,
premium_level_previous: null,
status: 'active'
} : overrides.grant,
raceLot: overrides.raceLot || null,
packEntitlements: overrides.packEntitlements || [],
refund: overrides.refund || null
});
}
describe('PaymentRefundService', () => {
test('the global payout gate refuses an automatic refund before any durable or wallet side effect', async () => {
const db = fixture();
const walletService = { processPayout: jest.fn() };
const service = new PaymentRefundService({
db,
walletService,
logger,
isSendEnabled: () => false
});
await expect(service.refundPayment({ paymentId: 42, sendFunds: true }))
.rejects.toMatchObject({ code: 'PAYOUT_DISPATCH_DISABLED', statusCode: 503 });
expect(walletService.processPayout).not.toHaveBeenCalled();
expect(db.refund).toBeNull();
expect(db.paymentUpdateCount).toBe(0);
});
test('a kill switch raised after the refund claim prevents the wallet call and strands safely for review', async () => {
const db = fixture();
const walletService = { processPayout: jest.fn() };
const isSendEnabled = jest.fn()
.mockReturnValueOnce(true)
.mockReturnValueOnce(false);
const service = new PaymentRefundService({
db,
walletService,
logger,
isSendEnabled
});
const result = await service.refundPayment({ paymentId: 42, sendFunds: true });
expect(result.refund.status).toBe('needs_review');
expect(walletService.processPayout).not.toHaveBeenCalled();
expect(isSendEnabled).toHaveBeenCalledTimes(2);
});
const txHash = 'a'.repeat(64);
const logger = { error: jest.fn() };
beforeEach(() => logger.error.mockClear());
test('concurrent transfer requests deduct and broadcast exactly once', async () => {
const db = fixture();
const walletService = {
processPayout: jest.fn().mockResolvedValue({ success: true, txHash })
};
const service = new PaymentRefundService({ db, walletService, logger });
const results = await Promise.all([
service.refundPayment({ paymentId: 42, reason: 'duplicate request', sendFunds: true }),
service.refundPayment({ paymentId: 42, reason: 'duplicate request', sendFunds: true })
]);
expect(walletService.processPayout).toHaveBeenCalledTimes(1);
expect(db.paymentUpdateCount).toBe(1);
expect(db.payment.description).toBe('Credits purchase');
expect(db.user.credits).toBe('0');
expect(db.user.total_credits_purchased).toBe('0');
expect(db.creditTransactions).toHaveLength(1);
expect(db.creditTransactions[0].amount).toBe('-6');
expect(db.refund.status).toBe('completed');
expect(db.refund.amount).toBe('900');
expect(db.refund.tx_hash).toBe(txHash);
expect(results.filter(result => result.existing)).toHaveLength(1);
});
test('reverses exact purchase progress and a premium tier granted solely by the payment', async () => {
const Entitlements = require('../src/multiplayer/entitlements');
const db = fixture({
user: {
credits: '6',
total_credits_purchased: '6',
premium_level: 'supporter'
},
grant: {
payment_id: 42,
user_id: 7,
source: 'product_confirmation',
credits_granted: '6',
purchase_progress_granted: '6',
race_entries_granted: 0,
packs_granted: [],
premium_level_granted: 'supporter',
premium_level_previous: 'free',
status: 'active'
}
});
const before = Entitlements.snapshotForUser(db.user);
expect(before.packs['generated-skins']).toBe(true);
const service = new PaymentRefundService({ db, walletService: null, logger });
const result = await service.refundPayment({ paymentId: 42, sendFunds: false });
const after = Entitlements.snapshotForUser(db.user);
expect(result.refund.status).toBe('recorded');
expect(db.user.credits).toBe('0');
expect(db.user.total_credits_purchased).toBe('0');
expect(db.user.premium_level).toBe('free');
expect(after.packs['generated-skins']).toBe(false);
expect(db.grant).toEqual(expect.objectContaining({
status: 'reversed',
credits_reversed: '6',
purchase_progress_reversed: '6'
}));
expect(db.refund).toEqual(expect.objectContaining({
credits_deducted: '6',
purchase_progress_deducted: '6',
premium_level_restored: 'free'
}));
});
test('changed purchase progress routes the whole refund to review without revoking benefits', async () => {
const db = fixture({ user: { credits: '6', total_credits_purchased: '5' } });
const walletService = { processPayout: jest.fn() };
const service = new PaymentRefundService({ db, walletService, logger });
const result = await service.refundPayment({ paymentId: 42, sendFunds: true });
expect(result.refund.status).toBe('needs_review');
expect(result.refund.error_message).toContain('progression cannot be reversed safely');
expect(db.user.credits).toBe('6');
expect(db.user.total_credits_purchased).toBe('5');
expect(db.payment.status).toBe('confirmed');
expect(walletService.processPayout).not.toHaveBeenCalled();
});
test('an unconsumed direct entry with unscoped purchase progress cannot auto-refund', async () => {
const db = fixture({
payment: { payment_type: 'single_game', credits_purchased: '0' },
user: { credits: '0', total_credits_purchased: '1' },
grant: null
});
const walletService = { processPayout: jest.fn() };
const service = new PaymentRefundService({ db, walletService, logger });
const result = await service.refundPayment({ paymentId: 42, sendFunds: true });
expect(result.refund.status).toBe('needs_review');
expect(result.refund.error_message).toContain('purchase progression requires manual reconciliation');
expect(db.user.total_credits_purchased).toBe('1');
expect(db.payment.status).toBe('confirmed');
expect(walletService.processPayout).not.toHaveBeenCalled();
});
test('record-only requests are durable, terminal, and idempotent', async () => {
const db = fixture();
const walletService = { processPayout: jest.fn() };
const service = new PaymentRefundService({ db, walletService, logger });
const first = await service.refundPayment({ paymentId: 42, sendFunds: false });
const second = await service.refundPayment({ paymentId: 42, sendFunds: true });
expect(first.refund.status).toBe('recorded');
expect(second.refund.status).toBe('recorded');
expect(second.existing).toBe(true);
expect(walletService.processPayout).not.toHaveBeenCalled();
expect(db.paymentUpdateCount).toBe(1);
expect(db.creditTransactions).toHaveLength(1);
});
test('ambiguous wallet failure moves to needs_review and is never retried', async () => {
const db = fixture();
const walletService = {
processPayout: jest.fn().mockRejectedValue(new Error('timeout after transfer submission'))
};
const service = new PaymentRefundService({ db, walletService, logger });
const first = await service.refundPayment({ paymentId: 42, sendFunds: true });
const second = await service.refundPayment({ paymentId: 42, sendFunds: true });
expect(first.refund.status).toBe('needs_review');
expect(first.refund.error_message).toContain('timeout');
expect(second.refund.status).toBe('needs_review');
expect(walletService.processPayout).toHaveBeenCalledTimes(1);
expect(db.paymentUpdateCount).toBe(1);
expect(db.creditTransactions).toHaveLength(1);
});
test('success without a valid transaction hash is treated as ambiguous', async () => {
const db = fixture();
const walletService = {
processPayout: jest.fn().mockResolvedValue({ success: true })
};
const service = new PaymentRefundService({ db, walletService, logger });
const result = await service.refundPayment({ paymentId: 42, sendFunds: true });
expect(result.refund.status).toBe('needs_review');
expect(result.refund.error_message).toContain('valid transaction hash');
expect(walletService.processPayout).toHaveBeenCalledTimes(1);
});
test('consumed purchased credits require review and no wallet transfer', async () => {
const db = fixture({ user: { credits: '5' } });
const walletService = {
processPayout: jest.fn().mockResolvedValue({ success: true, txHash })
};
const service = new PaymentRefundService({ db, walletService, logger });
const result = await service.refundPayment({ paymentId: 42, sendFunds: true });
expect(result.refund.status).toBe('needs_review');
expect(result.refund.error_message).toContain('already been consumed');
expect(walletService.processPayout).not.toHaveBeenCalled();
expect(db.user.credits).toBe('5');
expect(db.payment.status).toBe('confirmed');
expect(db.creditTransactions).toHaveLength(0);
});
test('unused race tickets and payment-owned packs reverse exactly once', async () => {
const db = fixture({
user: { credits: '0', total_credits_purchased: '0', race_entries: 2 },
payment: { credits_purchased: '0', payment_type: 'cosmetic_pack' },
grant: {
payment_id: 42,
user_id: 7,
source: 'product_confirmation',
credits_granted: '0',
purchase_progress_granted: '0',
race_entries_granted: 2,
packs_granted: [{ id: 'kenney-3d-characters' }],
premium_level_granted: null,
premium_level_previous: null,
status: 'active'
},
raceLot: { id: 8, original_entries: 2, remaining_entries: 2, refunded_at: null },
packEntitlements: [{
pack_id: 'kenney-3d-characters',
source: 'product_purchase',
metadata: { paymentId: 42 }
}]
});
const service = new PaymentRefundService({ db, walletService: null, logger });
const first = await service.refundPayment({ paymentId: 42, sendFunds: false });
const second = await service.refundPayment({ paymentId: 42, sendFunds: false });
expect(first.refund.status).toBe('recorded');
expect(second.existing).toBe(true);
expect(db.user.race_entries).toBe(0);
expect(db.raceLot.remaining_entries).toBe(0);
expect(db.raceLot.refunded_at).toBeTruthy();
expect(db.packEntitlements).toHaveLength(0);
expect(db.raceTransactions).toHaveLength(1);
expect(db.grant.status).toBe('reversed');
});
test('an escrowed race ticket makes the whole refund needs_review', async () => {
const db = fixture({
user: { credits: '0', total_credits_purchased: '0', race_entries: 2 },
payment: { credits_purchased: '0', payment_type: 'cosmetic_pack' },
grant: {
payment_id: 42,
user_id: 7,
source: 'product_confirmation',
credits_granted: '0',
purchase_progress_granted: '0',
race_entries_granted: 2,
packs_granted: [],
premium_level_granted: null,
premium_level_previous: null,
status: 'active'
},
raceLot: { id: 8, original_entries: 2, remaining_entries: 1, refunded_at: null }
});
const walletService = { processPayout: jest.fn() };
const service = new PaymentRefundService({ db, walletService, logger });
const result = await service.refundPayment({ paymentId: 42, sendFunds: true });
expect(result.refund.status).toBe('needs_review');
expect(result.refund.error_message).toContain('consumed or escrowed');
expect(walletService.processPayout).not.toHaveBeenCalled();
expect(db.user.race_entries).toBe(2);
expect(db.raceLot.remaining_entries).toBe(1);
});
test('a pending unpaid invoice cannot deduct credits or send its expected amount', async () => {
const db = fixture({
payment: { status: 'pending', received_amount: '0' }
});
const walletService = {
processPayout: jest.fn().mockResolvedValue({ success: true, txHash })
};
const service = new PaymentRefundService({ db, walletService, logger });
const result = await service.refundPayment({ paymentId: 42, sendFunds: true });
expect(result.refund.status).toBe('needs_review');
expect(result.refund.amount).toBe('0');
expect(result.refund.error_message).toContain('confirmed payment');
expect(walletService.processPayout).not.toHaveBeenCalled();
expect(db.user.credits).toBe('6');
expect(db.creditTransactions).toHaveLength(0);
});
test('a stranded processing row requires review and is never resent', async () => {
const db = fixture({
payment: { status: 'refunded' },
refund: {
id: 11,
payment_id: 42,
user_id: 7,
status: 'processing',
amount: '900',
payout_address: '5'.repeat(95),
credits_deducted: '4',
reason: 'Admin refund',
tx_hash: null,
error_message: null,
requested_at: new Date(),
processing_started_at: new Date()
}
});
const walletService = { processPayout: jest.fn() };
const service = new PaymentRefundService({ db, walletService, logger });
const result = await service.refundPayment({ paymentId: 42, sendFunds: true });
expect(result.refund.status).toBe('processing');
expect(walletService.processPayout).not.toHaveBeenCalled();
expect(db.paymentUpdateCount).toBe(0);
expect(db.creditTransactions).toHaveLength(0);
});
test('a legacy refunded payment is imported without repeating unknown side effects', async () => {
const db = fixture({
payment: { status: 'refunded', received_amount: '900' }
});
const walletService = { processPayout: jest.fn() };
const service = new PaymentRefundService({ db, walletService, logger });
const result = await service.refundPayment({ paymentId: 42, sendFunds: true });
expect(result.legacyImported).toBe(true);
expect(result.existing).toBe(true);
expect(result.refund.status).toBe('recorded');
expect(result.refund.amount).toBe('900');
expect(walletService.processPayout).not.toHaveBeenCalled();
expect(db.paymentUpdateCount).toBe(0);
expect(db.user.credits).toBe('6');
expect(db.creditTransactions).toHaveLength(0);
});
});
+15 -8
View File
@@ -69,7 +69,8 @@ describe('M1 — single payout ambiguous failure', () => {
const reviewCall = db.query.mock.calls.find(c => Array.isArray(c[1]) && c[1][0] === 'needs_review');
expect(reviewCall).toBeDefined();
expect(reviewCall[1][2]).toBe(42); // payout id
expect(reviewCall[1][2]).toBeNull(); // no valid transaction hash was observed
expect(reviewCall[1][3]).toBe(42); // payout id
const failedCall = db.query.mock.calls.find(c => Array.isArray(c[1]) && c[1][0] === 'failed');
expect(failedCall).toBeUndefined();
expect(gmm.alertService.sendAlert).toHaveBeenCalledWith('single_payout_failed', expect.any(Object));
@@ -88,7 +89,7 @@ describe('M1 — single payout ambiguous failure', () => {
await gmm._processPendingPayouts();
const pendingCall = db.query.mock.calls.find(c => Array.isArray(c[1]) && c[1][0] === 'pending' && /id = \$3/i.test(c[0]));
const pendingCall = db.query.mock.calls.find(c => Array.isArray(c[1]) && c[1][0] === 'pending' && /id = \$4/i.test(c[0]));
expect(pendingCall).toBeDefined();
expect(gmm.alertService.sendAlert).not.toHaveBeenCalled();
});
@@ -124,25 +125,30 @@ describe('M3 — atomic single_game payment claim', () => {
});
});
describe('M4 — completeGame never inserts an unattached payout', () => {
test('refuses payout when the paying user cannot be resolved from the game row', async () => {
describe('M4 — completeGame durably records every committed liability', () => {
test('quarantines a liability when the paying identity cannot be resolved', async () => {
const { gmm, db } = buildGmm();
db.withTransaction.mockImplementation(async (cb) => cb(db));
db.query
.mockResolvedValueOnce({ rows: [{ id: 1, /* no user_id */ game_mode: 'PAID_SINGLE', payout_address: 'addr' }] }) // game record
.mockResolvedValue({ rows: [] }); // completion UPDATE + existing-payout check
.mockResolvedValueOnce({ rows: [] }) // completion UPDATE
.mockResolvedValueOnce({ rows: [] }) // existing-payout check
.mockResolvedValueOnce({ rows: [{ id: 88 }], rowCount: 1 }); // needs_review liability
const result = await gmm.completeGame('sockX', 'game-xyz', true, false, {});
expect(result.reason).toBe('Paying user not resolved');
expect(result.payout).toEqual(expect.objectContaining({ payoutId: 88, status: 'needs_review' }));
const inserted = db.query.mock.calls.find(c => /INSERT INTO payouts/i.test(c[0]));
expect(inserted).toBeUndefined();
expect(gmm.alertService.sendAlert).toHaveBeenCalledWith('payout_unresolved_user', expect.any(Object));
expect(inserted).toBeDefined();
expect(inserted[1]).toContain('solo_winner_identity_review');
expect(gmm.alertService.sendAlert).toHaveBeenCalledWith('payout_liability_needs_review', expect.any(Object));
});
});
describe('M5 — payout amount is clamped to configured bounds', () => {
test('caps an over-max payout and alerts', async () => {
const { gmm, db } = buildGmm();
db.withTransaction.mockImplementation(async (cb) => cb(db));
db.query
.mockResolvedValueOnce({ rows: [{
id: 1, user_id: 1, game_mode: 'PAID_SINGLE', payout_address: 'addr',
@@ -164,6 +170,7 @@ describe('M5 — payout amount is clamped to configured bounds', () => {
test('skips a below-min payout entirely (no insert)', async () => {
const { gmm, db } = buildGmm();
db.withTransaction.mockImplementation(async (cb) => cb(db));
db.query
.mockResolvedValueOnce({ rows: [{
id: 1, user_id: 1, game_mode: 'PAID_SINGLE', payout_address: 'addr',
+21 -5
View File
@@ -1,5 +1,17 @@
const QueueManager = require('../src/network/queueManager');
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
};
}
/**
* Integration-style tests for the payment confirmation flow.
@@ -88,16 +100,18 @@ function buildHarness() {
const fakeSocket = { id: socketId, emit: (event, data) => events.push({ event, data }) };
return {
events, queueManager, paymentHandlers, fakeSocket,
events, queueManager, paymentHandlers, fakeSocket, gameModeManager,
getMonitorCallback: () => monitorCallback
};
}
describe('Payment flow integration: confirm-after-block immediate start', () => {
test('unconfirmed at block tick then confirmed triggers immediate game', async () => {
const { events, queueManager, paymentHandlers, fakeSocket, getMonitorCallback } = buildHarness();
const { events, queueManager, paymentHandlers, fakeSocket, gameModeManager, getMonitorCallback } = buildHarness();
await paymentHandlers.createAndShowPaymentRequest(fakeSocket);
await paymentHandlers.createAndShowPaymentRequest(fakeSocket, {
legalAcknowledgement: legalAcknowledgement(gameModeManager)
});
const monitorCallback = getMonitorCallback();
expect(monitorCallback).toBeDefined();
@@ -122,9 +136,11 @@ describe('Payment flow integration: confirm-after-block immediate start', () =>
});
test('confirmed but UNDERPAID does not start a game or grant anything', async () => {
const { events, queueManager, paymentHandlers, fakeSocket, getMonitorCallback } = buildHarness();
const { events, queueManager, paymentHandlers, fakeSocket, gameModeManager, getMonitorCallback } = buildHarness();
await paymentHandlers.createAndShowPaymentRequest(fakeSocket);
await paymentHandlers.createAndShowPaymentRequest(fakeSocket, {
legalAcknowledgement: legalAcknowledgement(gameModeManager)
});
const monitorCallback = getMonitorCallback();
expect(monitorCallback).toBeDefined();
+43 -14
View File
@@ -75,6 +75,10 @@ describe('Payment & Payout Comprehensive Tests', () => {
gmm = new GameModeManager(mockDb, mockWalletService, mockDebugManager, mockPaymentConfig);
});
afterEach(() => {
gmm?.shutdown();
});
describe('Credit Deduction - Negative Balance Prevention', () => {
test('should fail gracefully when user has insufficient credits', async () => {
const user = { id: 1, credits: 0 };
@@ -242,9 +246,13 @@ describe('Payment & Payout Comprehensive Tests', () => {
// Step 2: withTransaction — client.query calls:
mockDb._mockClient.query
.mockResolvedValueOnce({ rows: [{ id: 123 }] }) // UPDATE payments SET status='confirmed' RETURNING id
.mockResolvedValueOnce({ rows: [{ credits: 15 }] }) // UPDATE users SET credits = credits + 15
.mockResolvedValueOnce({ rows: [] }); // INSERT credit_transactions
.mockResolvedValueOnce({ rows: [{ id: 123, status: 'pending' }] }) // snapshot product promise
.mockResolvedValueOnce({ rows: [{ id: 123 }] }) // mark payment confirmed
.mockResolvedValueOnce({ rows: [{ id: 1, premium_level: 'free' }] }) // lock user
.mockResolvedValueOnce({ rows: [{ id: 1, credits: 15, total_credits_purchased: 15 }] })
.mockResolvedValueOnce({ rows: [] }) // credit transaction
.mockResolvedValueOnce({ rows: [{ payment_id: 123 }], rowCount: 1 }) // entitlement grant
.mockResolvedValueOnce({ rows: [] }); // active pack grants
const result = await gmm.processCreditsPackageConfirmation('socket1', 123, { credits: 10, bonus: 5 });
@@ -261,9 +269,13 @@ describe('Payment & Payout Comprehensive Tests', () => {
// Step 2: withTransaction — client.query calls:
mockDb._mockClient.query
.mockResolvedValueOnce({ rows: [{ id: 123 }] }) // UPDATE payments SET status='confirmed'
.mockResolvedValueOnce({ rows: [{ credits: 30 }] }) // UPDATE users
.mockResolvedValueOnce({ rows: [] }); // INSERT credit_transactions
.mockResolvedValueOnce({ rows: [{ id: 123, status: 'pending' }] })
.mockResolvedValueOnce({ rows: [{ id: 123 }] })
.mockResolvedValueOnce({ rows: [{ id: 1, premium_level: 'free' }] })
.mockResolvedValueOnce({ rows: [{ id: 1, credits: 25, total_credits_purchased: 25 }] })
.mockResolvedValueOnce({ rows: [] })
.mockResolvedValueOnce({ rows: [{ payment_id: 123 }], rowCount: 1 })
.mockResolvedValueOnce({ rows: [] });
const result = await gmm.processCreditsPackageConfirmation('socket1', 123, null);
@@ -279,9 +291,13 @@ describe('Payment & Payout Comprehensive Tests', () => {
// Step 2: withTransaction — client.query calls:
mockDb._mockClient.query
.mockResolvedValueOnce({ rows: [{ id: 123 }] }) // UPDATE payments SET status='confirmed'
.mockResolvedValueOnce({ rows: [{ credits: 10 }] }) // UPDATE users
.mockResolvedValueOnce({ rows: [] }); // INSERT credit_transactions
.mockResolvedValueOnce({ rows: [{ id: 123, status: 'pending' }] })
.mockResolvedValueOnce({ rows: [{ id: 123 }] })
.mockResolvedValueOnce({ rows: [{ id: 1, premium_level: 'free' }] })
.mockResolvedValueOnce({ rows: [{ id: 1, credits: 10, total_credits_purchased: 10 }] })
.mockResolvedValueOnce({ rows: [] })
.mockResolvedValueOnce({ rows: [{ payment_id: 123 }], rowCount: 1 })
.mockResolvedValueOnce({ rows: [] });
const result = await gmm.processCreditsPackageConfirmation('socket1', 123, null);
@@ -295,6 +311,9 @@ describe('Payment & Payout Comprehensive Tests', () => {
gmm.gameMode = 'PAID_SINGLE';
gmm.singleGamePrice = 100000000000;
gmm.directPayoutMultipliers = { escape: 2, escapeWithTreasure: 3 };
// Completion owns a real transaction; route every statement through the ordered
// root query mock so these tests exercise completion + obligation atomically.
mockDb.withTransaction.mockImplementation(async (callback) => callback(mockDb));
});
test('processes winning game with payout correctly', async () => {
@@ -371,12 +390,15 @@ describe('Payment & Payout Comprehensive Tests', () => {
mockDb.query.mockResolvedValueOnce({
rows: [{ id: 1, payout_address: null }] // No address
});
mockDb.query.mockResolvedValueOnce({ rows: [{ id: 1001 }], rowCount: 1 }); // review liability
const result = await gmm.completeGame('socket1', 'game123', true, false, {});
expect(result.success).toBe(true);
expect(result.payout).toBeNull();
expect(result.reason).toBe('No payout address');
expect(result.payout).toEqual(expect.objectContaining({
payoutId: 1001,
status: 'needs_review'
}));
expect(mockWalletService.processPayout).not.toHaveBeenCalled();
});
@@ -654,8 +676,13 @@ describe('Edge Cases and Error Handling', () => {
gmm = new GameModeManager(mockDb, mockWalletService, createMockDebugManager(), createMockPaymentConfig());
});
afterEach(() => {
gmm?.shutdown();
});
test('handles payout insert failure gracefully', async () => {
gmm.gameMode = 'PAID_SINGLE';
mockDb.withTransaction.mockImplementation(async (callback) => callback(mockDb));
mockDb.query.mockResolvedValueOnce({ rows: [{ id: 1, user_id: 1, payment_mode: 'direct' }] });
mockDb.query.mockResolvedValueOnce({ rows: [] }); // game update
@@ -668,9 +695,11 @@ describe('Edge Cases and Error Handling', () => {
const result = await gmm.completeGame('socket1', 'game123', true, false, {});
expect(result.success).toBe(true);
expect(result.payout).toBeNull();
expect(result.payoutError).toBeDefined();
// The payout insert and game completion share one transaction. A failed obligation
// therefore fails/rolls back completion instead of leaving a completed unpaid win.
expect(result.success).toBe(false);
expect(result.payout).toBeUndefined();
expect(result.error).toBeDefined();
});
test('handles database error during credits deduction', async () => {
+171
View File
@@ -0,0 +1,171 @@
const GameModeManager = require('../src/game/gameModeManager');
const {
PAYOUT_ADMISSION_LOCK_KEY,
reservePayoutCapacity
} = require('../src/services/payoutAdmissionService');
describe('serialized payout-liability admission', () => {
const oldCritical = process.env.BALANCE_CRITICAL;
beforeEach(() => { process.env.BALANCE_CRITICAL = '50'; });
afterAll(() => {
if (oldCritical === undefined) delete process.env.BALANCE_CRITICAL;
else process.env.BALANCE_CRITICAL = oldCritical;
});
function capacityClient() {
const calls = [];
return {
calls,
query: jest.fn(async (sql, params) => {
calls.push({ sql, params });
if (/AS payout_rows/i.test(sql)) {
return { rows: [{ payout_rows: '100', solo_commitments: '200', match_commitments: '300' }] };
}
return { rows: [] };
})
};
}
test('requires unlocked balance for outstanding + new liability + reserve under one advisory lock', async () => {
const client = capacityClient();
const walletService = { isHealthy: true, getBalance: jest.fn().mockResolvedValue({ unlocked_balance: '1050' }) };
const result = await reservePayoutCapacity({
client,
walletService,
newLiability: '400',
gameModeManager: null
});
expect(result.required).toBe(1050n);
expect(result.outstanding).toBe(600n);
expect(client.calls[0]).toEqual(expect.objectContaining({
params: [PAYOUT_ADMISSION_LOCK_KEY]
}));
expect(client.calls[0].sql).toMatch(/pg_advisory_xact_lock/i);
});
test('fails closed one atomic unit below exact committed coverage', async () => {
const client = capacityClient();
await expect(reservePayoutCapacity({
client,
walletService: { isHealthy: true, getBalance: async () => ({ unlocked_balance: '1049' }) },
newLiability: '400'
})).rejects.toMatchObject({ code: 'PAYOUT_RESERVE_INSUFFICIENT' });
});
test('counts only match liabilities that can still owe a payout', async () => {
const client = capacityClient();
await reservePayoutCapacity({
client,
walletService: { isHealthy: true, getBalance: async () => ({ unlocked_balance: '1050' }) },
newLiability: '400'
});
const liabilityQuery = client.calls.find(({ sql }) => /AS payout_rows/i.test(sql)).sql;
expect(liabilityQuery).toMatch(
/FROM matches m[\s\S]*m\.status IN \('starting', 'active', 'finished'\)[\s\S]*NOT EXISTS/i
);
expect(liabilityQuery).not.toMatch(/m\.status IN \([^)]*'cancelled'/i);
});
function creditManager({ payoutEligible, getBalance }) {
const calls = [];
const client = {
query: jest.fn(async (sql) => {
calls.push(sql);
if (/AS payout_rows/i.test(sql)) {
return { rows: [{ payout_rows: '0', solo_commitments: '0', match_commitments: '0' }] };
}
if (/UPDATE users[\s\S]*credits = credits -/i.test(sql)) {
return { rows: [{ credits: 1, total_credits_purchased: 2, payout_address: 'addr' }], rowCount: 1 };
}
if (/UPDATE games SET game_mode = 'PAID_CREDITS'/i.test(sql)) {
return { rows: [{ id: 9 }], rowCount: 1 };
}
return { rows: [], rowCount: 1 };
})
};
const manager = Object.create(GameModeManager.prototype);
manager.db = { withTransaction: async callback => callback(client) };
manager.walletService = { isHealthy: true, getBalance };
manager.creditsPerGameCost = 1;
manager.requiresPayoutAddressForMode = () => payoutEligible;
manager._computePayoutSnapshot = () => ({
eligible: payoutEligible,
escapeAmount: '200',
treasureAmount: '300',
escapeMult: 2,
treasureMult: 3,
terms: { version: 1, eligible: payoutEligible }
});
return { manager, calls };
}
test('existing-credit payout game is denied before debit when balance is unknown', async () => {
const { manager, calls } = creditManager({
payoutEligible: true,
getBalance: jest.fn().mockRejectedValue(new Error('wallet offline'))
});
await expect(manager._processGameStartWithCredits(
{ id: 1, credits: 2, payout_address: 'addr' }, 'sock', 'seed'
)).rejects.toMatchObject({ code: 'PAYOUT_RESERVE_UNVERIFIED' });
expect(calls.some(sql => /credits = credits -/i.test(sql))).toBe(false);
});
test('payout-disabled prestige start stays open and never reads wallet balance', async () => {
const getBalance = jest.fn().mockRejectedValue(new Error('wallet offline'));
const { manager } = creditManager({ payoutEligible: false, getBalance });
const result = await manager._processGameStartWithCredits(
{ id: 1, credits: 2, payout_address: null }, 'sock', 'seed'
);
expect(result.success).toBe(true);
expect(getBalance).not.toHaveBeenCalled();
});
test('authoritative credit start rejects a missing required payout address before debit', async () => {
const { manager, calls } = creditManager({ payoutEligible: true, getBalance: async () => ({ unlocked_balance: '1000' }) });
const result = await manager._processGameStartWithCredits(
{ id: 1, credits: 2, payout_address: null }, 'sock', 'seed'
);
expect(result).toEqual(expect.objectContaining({ success: false }));
expect(calls).toEqual([]);
});
test('authoritative direct-payment claim rechecks the locked user payout address', async () => {
const calls = [];
const client = {
query: jest.fn(async (sql) => {
calls.push(sql);
if (/SELECT \* FROM payments/i.test(sql)) {
return { rows: [{ id: 4, user_id: 1, status: 'confirmed' }], rowCount: 1 };
}
if (/SELECT id, payout_address[\s\S]*FROM users/i.test(sql)) {
return { rows: [{ id: 1, payout_address: null }], rowCount: 1 };
}
return { rows: [], rowCount: 1 };
})
};
const manager = Object.create(GameModeManager.prototype);
manager.db = { withTransaction: async callback => callback(client) };
manager._computePayoutSnapshot = () => ({
eligible: false, escapeAmount: '0', treasureAmount: '0', terms: {}
});
manager._requiresPaidFairnessV2 = () => false;
manager.requiresPayoutAddressForMode = () => true;
const result = await manager._processGameStartWithPayment(
{ id: 1, payout_address: 'stale-client-cache' }, { id: 4 }, 'seed'
);
expect(result).toEqual(expect.objectContaining({ success: false }));
expect(calls.some(sql => /UPDATE games/i.test(sql))).toBe(false);
expect(calls.some(sql => /UPDATE payments/i.test(sql))).toBe(false);
});
});
+20
View File
@@ -0,0 +1,20 @@
const fs = require('fs');
const path = require('path');
describe('browser payout disclosures', () => {
test('renders server-computed outcomes and never recreates multiplier amounts', () => {
const paymentUi = fs.readFileSync(
path.join(__dirname, '../html/js/ui/paymentUI.js'),
'utf8'
);
const help = fs.readFileSync(
path.join(__dirname, '../html/js/ui/helpModal.js'),
'utf8'
);
expect(paymentUi).toContain('this.config?.payoutOutcomes?.[mode]');
expect(paymentUi).toContain('configured cap applied');
expect(paymentUi).not.toMatch(/parseFloat\([^\n]+\)\s*\*\s*[^\n]*multiplier/i);
expect(help).not.toMatch(/Escape pays[\s\S]{0,80}×/);
});
});
+116 -3
View File
@@ -70,18 +70,131 @@ describe('PayoutRetryService.retryPayout', () => {
.mockResolvedValue({ rows: [] });
const wallet = {
checkTransactionStatus: jest.fn(),
processPayout: jest.fn().mockResolvedValue({ success: true, txHash: 'txNEW', fee: 10 })
processPayout: jest.fn().mockResolvedValue({ success: true, txHash: 'd'.repeat(64), fee: 10 })
};
await svcWith(db, wallet).retryPayout({ id: 7, user_id: 1, amount: '100', payout_address: 'wow1addr', status: 'failed', retry_count: 0 });
await svcWith(db, wallet).retryPayout({
id: 7,
user_id: 1,
amount: '100',
payout_address: 'wow1addr',
status: 'processing',
retry_count: 0,
last_error: 'not enough unlocked money to transfer'
});
// tx_hash + completion happen in the SAME statement (atomic).
const completion = findCall(client.query, /UPDATE payouts[\s\S]*tx_hash[\s\S]*status = 'completed'/i);
expect(completion).toBeDefined();
expect(completion[1]).toContain('txNEW');
expect(completion[1]).toContain('d'.repeat(64));
// No standalone pre-transaction tx_hash write on db.query.
expect(findCall(db.query, /UPDATE payouts SET tx_hash = \$1, fee = \$2 WHERE id/i)).toBeUndefined();
// Stats counted once.
expect(findCall(client.query, /UPDATE users[\s\S]*total_amount_won/i)).toBeDefined();
});
test('ambiguous failure without a tx hash is never sent again automatically', async () => {
const { db } = makeHarness();
const wallet = { checkTransactionStatus: jest.fn(), processPayout: jest.fn() };
await svcWith(db, wallet).retryPayout({
id: 8,
user_id: 1,
amount: '100',
payout_address: 'wow1addr',
status: 'processing',
retry_count: 1,
last_error: 'RPC timeout after broadcast'
});
expect(wallet.processPayout).not.toHaveBeenCalled();
const review = findCall(db.query, /SET status = 'needs_review'/i);
expect(review).toBeDefined();
});
test('a recorded transaction hash that is temporarily not found is never resent', async () => {
const { db } = makeHarness();
const wallet = {
checkTransactionStatus: jest.fn().mockResolvedValue({ exists: false }),
processPayout: jest.fn()
};
await svcWith(db, wallet).retryPayout({
id: 11,
user_id: 1,
amount: '100',
payout_address: 'wow1addr',
tx_hash: 'possibly-broadcast-tx',
status: 'processing',
retry_count: 1
});
expect(wallet.processPayout).not.toHaveBeenCalled();
expect(findCall(db.query, /SET status = 'needs_review'/i)).toBeDefined();
});
});
describe('PayoutRetryService.processRetries', () => {
test('stale processing claims are quarantined and never selected for resend', async () => {
const db = {
query: jest.fn().mockResolvedValue({ rows: [{ id: 44 }], rowCount: 1 }),
withTransaction: jest.fn(async (fn) => fn({
query: jest.fn().mockResolvedValue({ rows: [], rowCount: 0 })
}))
};
const wallet = { processPayout: jest.fn() };
const service = new PayoutRetryService({
db,
walletService: wallet,
staleProcessingMs: 60000,
debugManager: { CONSOLE_LOGGING: false }
});
service.retryPayout = jest.fn();
await service.processRetries();
const recovery = findCall(db.query, /status = 'needs_review'[\s\S]*status = 'processing'/i);
expect(recovery).toBeDefined();
expect(service.retryPayout).not.toHaveBeenCalled();
expect(wallet.processPayout).not.toHaveBeenCalled();
});
test('claims failed rows as processing in the same transaction before dispatch', async () => {
const candidate = {
id: 9,
user_id: 1,
payout_address: 'wow1addr',
amount: '100',
status: 'processing',
retry_count: 0,
last_error: 'insufficient unlocked balance'
};
const client = { query: jest.fn() };
client.query
.mockResolvedValueOnce({ rows: [{ ...candidate, status: 'failed' }] })
.mockResolvedValueOnce({ rows: [candidate] });
const db = {
query: jest.fn().mockResolvedValue({ rows: [] }),
withTransaction: jest.fn(async (fn) => fn(client))
};
const service = svcWith(db, { processPayout: jest.fn() });
service.retryPayout = jest.fn().mockResolvedValue();
await service.processRetries();
expect(findCall(client.query, /SET status = 'processing'/i)).toBeDefined();
expect(service.retryPayout).toHaveBeenCalledWith(expect.objectContaining({ id: 9, status: 'processing' }));
});
test('master predicate prevents both scan and dispatch', async () => {
const db = { query: jest.fn().mockResolvedValue({ rows: [] }), withTransaction: jest.fn() };
const wallet = { processPayout: jest.fn() };
const service = new PayoutRetryService({ db, walletService: wallet, isEnabled: () => false });
await service.processRetries();
await service.retryPayout({ id: 10 });
expect(db.withTransaction).not.toHaveBeenCalled();
expect(wallet.processPayout).not.toHaveBeenCalled();
});
});
+75 -4
View File
@@ -8,12 +8,12 @@
const GameModeManager = require('../src/game/gameModeManager');
const createMockDb = () => {
const mockClient = { query: jest.fn().mockResolvedValue({ rows: [] }) };
return {
const db = {
query: jest.fn().mockResolvedValue({ rows: [] }),
withTransaction: jest.fn().mockImplementation(async (cb) => cb(mockClient)),
_mockClient: mockClient
withTransaction: jest.fn()
};
db.withTransaction.mockImplementation(async (cb) => cb(db));
return db;
};
const createMockPaymentConfig = () => ({
@@ -106,4 +106,75 @@ describe('Payout uses the snapshot recorded at game start', () => {
const insertedPayout = db.query.mock.calls.find(c => /INSERT INTO payouts/i.test(c[0]));
expect(insertedPayout).toBeUndefined();
});
test('master payout switch prevents payout creation even when direct payouts are enabled', async () => {
const { gmm, db } = buildGmm();
gmm.payoutsEnabled = false;
gmm.directPayoutEnabled = true;
db.query
.mockResolvedValueOnce({ rows: [{ id: 1, user_id: 1, game_mode: 'PAID_SINGLE', payout_address: 'wow1addr' }] })
.mockResolvedValue({ rows: [] });
const result = await gmm.completeGame('socket1', 'game-master-off', true, true, { moves: 10 });
expect(result.success).toBe(true);
expect(db.query.mock.calls.find(c => /INSERT INTO payouts/i.test(c[0]))).toBeUndefined();
expect(gmm._scheduleBatchPayout).not.toHaveBeenCalled();
});
test('a start-time eligible liability survives a mid-game master kill switch', async () => {
const { gmm, db } = buildGmm();
gmm.payoutsEnabled = false; // dispatch is paused after the game committed its terms
db.query
.mockResolvedValueOnce({ rows: [{
id: 1,
user_id: 1,
status: 'active',
game_mode: 'PAID_SINGLE',
payout_address: 'wow1addr',
payout_eligible: true,
payout_terms: {
version: 1,
eligible: true,
escapeAmount: '222',
treasureAmount: '333',
escapeMultiplier: 2,
treasureMultiplier: 3,
minAmount: '1',
maxAmount: '1000'
}
}] })
.mockResolvedValueOnce({ rows: [] })
.mockResolvedValueOnce({ rows: [] })
.mockResolvedValueOnce({ rows: [{ id: 1, payout_address: 'wow1addr' }] })
.mockResolvedValueOnce({ rows: [{ id: 901 }], rowCount: 1 });
const result = await gmm.completeGame('socket1', 'committed-game', true, false, {});
expect(result.payout).toEqual(expect.objectContaining({ payoutId: 901, amount: '222' }));
expect(db.query.mock.calls.find(c => /INSERT INTO payouts/i.test(c[0]))).toBeDefined();
});
test('a start-time ineligible game never gains a payout after policy is enabled', async () => {
const { gmm, db } = buildGmm();
gmm.payoutsEnabled = true;
gmm.directPayoutEnabled = true;
db.query
.mockResolvedValueOnce({ rows: [{
id: 2,
user_id: 1,
status: 'active',
game_mode: 'PAID_SINGLE',
payout_eligible: false,
payout_terms: { version: 1, mode: 'PAID_SINGLE', eligible: false }
}] })
.mockResolvedValueOnce({ rows: [] });
const result = await gmm.completeGame('socket1', 'ineligible-game', true, true, {});
expect(result.success).toBe(true);
expect(result.payout).toBeNull();
expect(db.query.mock.calls.find(c => /INSERT INTO payouts/i.test(c[0]))).toBeUndefined();
});
});
+38
View File
@@ -0,0 +1,38 @@
const {
normalizeLeaderboardPeriod,
prestigeCutoff,
queryPrestigeLeaderboard
} = require('../src/network/prestigeLeaderboard');
describe('prestige leaderboard periods', () => {
const now = new Date('2026-07-21T16:00:00.000Z');
test.each([
['week', '2026-07-14T16:00:00.000Z'],
['month', '2026-06-21T16:00:00.000Z'],
['all', null],
['unexpected', null]
])('%s resolves to the exact cutoff', (period, expected) => {
const cutoff = prestigeCutoff(period, now);
expect(cutoff?.toISOString() || null).toBe(expected);
});
test('unknown periods normalize to all', () => {
expect(normalizeLeaderboardPeriod('YEAR')).toBe('all');
});
test.each(['week', 'month', 'all'])('queries match rows with a strict boundary for %s', async (period) => {
const db = { query: jest.fn().mockResolvedValue({ rows: [] }) };
await queryPrestigeLeaderboard(db, { period, limit: 17, now });
const [sql, params] = db.query.mock.calls[0];
expect(sql).toContain("m.economy = 'credits_prestige'");
expect(sql).toContain('me.placement = 1');
expect(sql).toContain('m.winner_user_id = me.user_id');
expect(sql).toContain("m.ruleset_id <> 'coop-escape'");
expect(sql).not.toContain('me.escaped');
expect(sql).toContain('m.ended_at > $1::timestamptz');
expect(params[0]?.toISOString() || null).toBe(prestigeCutoff(period, now)?.toISOString() || null);
expect(params[1]).toBe(17);
});
});
+200
View File
@@ -0,0 +1,200 @@
'use strict';
const {
CAPTURE_RULESET_IDS,
DEFAULT_SEED,
assertCaptureSafety,
assertCaptureStateEnvironment,
capturePageHtml,
capturePresentation,
captureResultSummary,
captureStateHash,
captureTraceHeader,
createCaptureRoom,
parseArgs,
planBotMove,
validateOptions
} = require('../src/scripts/pvp-capture');
describe('deterministic PvP capture harness', () => {
function options(extra = {}) {
return validateOptions(parseArgs([
'--confirm-local-free-only',
'--no-video',
...extra.args || []
]));
}
test('requires an explicit acknowledgement and can never run in production', () => {
const safe = options();
expect(() => assertCaptureSafety({ NODE_ENV: 'development' }, { ...safe, confirmed: false }))
.toThrow('--confirm-local-free-only');
expect(() => assertCaptureSafety({ NODE_ENV: 'production' }, safe))
.toThrow('NODE_ENV=development or NODE_ENV=test');
expect(() => assertCaptureSafety({ NODE_ENV: 'development', PAYOUTS_ENABLED: 'true' }, safe))
.toThrow('money-enabled environments');
expect(() => assertCaptureSafety({ NODE_ENV: 'test', MATCH_CRYPTO_RACE_ENABLED: '1' }, safe))
.toThrow('MATCH_CRYPTO_RACE_ENABLED');
expect(() => assertCaptureSafety({ NODE_ENV: 'test', PAYMENTS_ENABLED: 'true' }, safe))
.toThrow('PAYMENTS_ENABLED');
expect(() => assertCaptureSafety({ NODE_ENV: 'test', CREDITS_PAYOUT_ENABLED: 'true' }, safe))
.toThrow('CREDITS_PAYOUT_ENABLED');
expect(() => assertCaptureSafety({ NODE_ENV: 'test', WALLET_RPC_URL: 'http://127.0.0.1:18083' }, safe))
.toThrow('wallet-configured environments');
expect(assertCaptureSafety({ NODE_ENV: 'test' }, safe)).toBe(true);
});
test('refuses inherited gameplay tuning so a seed has one authoritative replay', () => {
expect(() => assertCaptureStateEnvironment({ DIFFICULTY_PRESET: 'casino' }))
.toThrow('inherited gameplay tuning: DIFFICULTY_PRESET');
expect(() => assertCaptureSafety({ NODE_ENV: 'test', DUNGEON_WIDTH: '70' }, options()))
.toThrow('inherited gameplay tuning: DUNGEON_WIDTH');
expect(() => assertCaptureSafety({ NODE_ENV: 'development', MONSTER_SPEED: '2' }, options()))
.toThrow('inherited gameplay tuning: MONSTER_SPEED');
expect(assertCaptureStateEnvironment({})).toBe(true);
});
test('accepts only local renderer/capture options and a fixed-width seed', () => {
expect(parseArgs(['--help']).help).toBe(true);
expect(() => validateOptions(parseArgs([
'--confirm-local-free-only', '--mode', '3d'
]))).toThrow('network-loaded renderers are forbidden');
expect(() => validateOptions(parseArgs([
'--confirm-local-free-only', '--seed', '1234'
]))).toThrow('64 hexadecimal');
expect(() => validateOptions(parseArgs([
'--confirm-local-free-only', '--ruleset', 'crypto-race'
]))).toThrow('--ruleset must be one of');
expect(() => validateOptions(parseArgs([
'--confirm-local-free-only', '--camera', 'wallet'
]))).toThrow('--camera must be action or focus');
expect(() => parseArgs(['--url', 'https://example.com'])).toThrow('Unknown option');
});
test('creates every built-in multiplayer ruleset deterministically with no money state', () => {
const config = options();
const first = createCaptureRoom(config);
const second = createCaptureRoom(config);
expect(config.seed).toBe(DEFAULT_SEED);
expect(first.economy).toBe('free');
expect(first.ruleset.id).toBe('race');
expect(first.ruleset.economy.model).toBe('free');
expect(first.entryFeeAtomic).toBe(0);
expect(first.potAtomic).toBe(0);
expect(first.houseFeeAtomic).toBe(0);
expect(Array.from(first.occupants.keys())).toEqual(['bot-1', 'bot-2', 'bot-3', 'bot-4']);
expect(first.seedHash).toBe(second.seedHash);
expect(first.dungeon.map).toEqual(second.dungeon.map);
expect(first.dungeon.exit).toEqual(second.dungeon.exit);
expect(first.dungeon.treasure).toEqual(second.dungeon.treasure);
for (const rulesetId of CAPTURE_RULESET_IDS) {
const room = createCaptureRoom(options({ args: ['--ruleset', rulesetId] }));
expect(room.ruleset.id).toBe(rulesetId);
expect(room.economy).toBe('free');
expect(room.ruleset.economy.model).toBe('free');
expect(room.entryFeeAtomic).toBe(0);
expect(room.potAtomic).toBe(0);
expect(room.houseFeeAtomic).toBe(0);
}
});
test.each(CAPTURE_RULESET_IDS)('%s uses deterministic bot inputs and authoritative state', rulesetId => {
const config = options({ args: ['--ruleset', rulesetId, '--ticks', '30'] });
const rooms = [createCaptureRoom(config), createCaptureRoom(config)];
rooms.forEach(room => room.start());
for (let tick = 0; tick < 30; tick++) {
for (const room of rooms) {
const state = room.toGameState();
for (const botId of room.occupants.keys()) {
const move = planBotMove(botId, state);
if (Math.abs(move.dx) + Math.abs(move.dy) === 1) {
room.queueMove(botId, move.dx, move.dy);
}
}
room.resolveTick();
}
expect(captureStateHash(rooms[0].toGameState()))
.toBe(captureStateHash(rooms[1].toGameState()));
}
});
test('last-alive bots pursue a rival instead of following the race exit', () => {
const move = planBotMove('bot-1', {
tick: 1,
ruleset: { id: 'last-alive' },
visibleTiles: [["'1", "'1", "'1"]],
exit: [0, 0],
players: [
{ id: 'bot-1', x: 0, y: 0, alive: true, finished: false },
{ id: 'bot-2', x: 2, y: 0, alive: true, finished: false }
]
});
expect(move).toEqual({ dx: 1, dy: 0 });
});
test('presentation and final copy are ruleset-correct, including cooperative results', () => {
expect(capturePresentation('last-alive')).toEqual(expect.objectContaining({
title: 'LAST ALIVE', activeNoun: 'STANDING'
}));
expect(captureResultSummary({
ruleset: { id: 'score-attack' }, winnerId: 'bot-2', tickCount: 55
}, [{ id: 'bot-2', name: 'Nyx', score: 912 }])).toEqual(expect.objectContaining({
headline: 'NYX TOPS THE BOARD', detail: expect.stringContaining('912 POINTS')
}));
expect(captureResultSummary({
ruleset: { id: 'coop-escape' }, winnerId: 'bot-1', tickCount: 40
}, [
{ id: 'bot-1', escaped: true },
{ id: 'bot-2', escaped: true }
])).toEqual(expect.objectContaining({
headline: 'TEAM ESCAPED TOGETHER', cooperative: true
}));
expect(captureResultSummary({
ruleset: { id: 'last-alive' }, winnerId: 'bot-2', tickCount: 80
}, [
{ id: 'bot-1', name: 'Rook', alive: true, finished: false },
{ id: 'bot-2', name: 'Nyx', alive: true, finished: false }
])).toEqual(expect.objectContaining({ headline: 'NYX LEADS AT THE BELL', winnerStatus: 'LEADER' }));
expect(captureResultSummary({
ruleset: { id: 'race' }, winnerId: 'bot-1', tickCount: 10, endReason: 'capture_limit'
}, [{ id: 'bot-1', name: 'Rook' }])).toEqual(expect.objectContaining({
headline: 'ROOK LEADS AT THE BELL', winnerStatus: 'LEADER'
}));
expect(captureResultSummary({
ruleset: { id: 'last-alive' }, winnerId: 'bot-1', tickCount: 10, endReason: 'all_dead'
}, [{ id: 'bot-1', name: 'Rook', alive: false }])).toEqual(expect.objectContaining({
headline: 'ROOK SURVIVED LONGEST', winnerStatus: 'LAST OUT'
}));
});
test('capture page uses local assets, a spectator focus camera, and no payment UI', () => {
const html = capturePageHtml();
expect(html).toContain('focusPlayerId:cameraFocus');
expect(html).toContain("cameraMode=query.get('camera')||'action'");
expect(html).toContain('id="roster"');
expect(html).toContain('actionFocusUntil');
expect(html).toContain("if(entity.kind==='player')entity.label=null");
expect(html).toContain('controls:false');
expect(html).toContain('/socket.io/socket.io.js');
expect(html).not.toMatch(/https?:\/\/(?!127\.0\.0\.1)/);
expect(html).not.toContain('crypto_race');
expect(html).not.toContain('payment');
});
test('parses the camera viewport as replay provenance', () => {
const config = options({ args: ['--viewport', '1080x1920', '--camera', 'focus'] });
const room = createCaptureRoom(config);
const trace = captureTraceHeader(room, capturePresentation(room.ruleset), config);
expect(config.viewport).toEqual({ width: 1080, height: 1920 });
expect(config.camera).toBe('focus');
expect(trace.camera).toEqual({
mode: 'focus',
focus: 'bot-1',
renderer: 'tiles',
viewport: { width: 1080, height: 1920 }
});
});
});
+100
View File
@@ -0,0 +1,100 @@
const fs = require('fs');
const path = require('path');
const vm = require('vm');
function loadMatchCopy() {
const context = {};
context.window = context;
vm.createContext(context);
vm.runInContext(
fs.readFileSync(path.join(__dirname, '../html/js/ui/matchUiCopy.js'), 'utf8'),
context
);
return context.MatchUiCopy;
}
describe('multiplayer UI copy contract', () => {
const copy = loadMatchCopy();
test.each([
['race', 'racer', 'Reach the exit'],
['last-alive', 'contender', 'Stay alive'],
['score-attack', 'delver', 'highest score'],
['coop-escape', 'teammate', 'Work together']
])('%s exposes its own objective and participant language', (id, participant, objective) => {
const mode = copy.normalize({ id });
expect(mode.participant).toBe(participant);
expect(mode.objective).toContain(objective);
});
test.each([
['race', '🏆 You escaped first!'],
['last-alive', '🏆 You are the last delver standing!'],
['score-attack', '🏆 You set the high score!']
])('%s renders a mode-specific competitive victory', (id, expected) => {
const result = copy.finalResult({
ruleset: { id },
winnerId: 'me',
players: [{ id: 'me', placement: 1, score: 812 }]
}, 'me');
expect(result.won).toBe(true);
expect(result.text).toContain(expected);
if (id === 'score-attack') expect(result.text).toContain('score 812');
});
test('co-op reports one team outcome and never promotes placement #1 as an individual winner', () => {
const result = copy.finalResult({
ruleset: { id: 'coop-escape' },
reason: 'all_escaped',
winnerId: null,
players: [
{ id: 'me', placement: 2, escaped: true },
{ id: 'ally', placement: 1, escaped: true }
]
}, 'me');
expect(result.won).toBe(true);
expect(result.text).toContain('🤝 Team escaped!');
expect(result.text).toContain('you reached safety');
expect(result.text).not.toMatch(/placed|winner/i);
});
test('live summaries use the active mode rather than race-only placement copy', () => {
expect(copy.liveSummary({
ruleset: { id: 'last-alive' },
players: [{ id: 'me', alive: true }, { id: 'out', alive: false }]
}, 'me')).toBe('1 contender still in');
expect(copy.liveSummary({
ruleset: { id: 'coop-escape' },
players: [{ id: 'me', escaped: true, finished: true }, { id: 'ally', alive: true }]
}, 'me')).toBe('1/2 safe · 1 still exploring');
});
});
describe('public multiplayer controls and boards', () => {
const matchHtml = fs.readFileSync(path.join(__dirname, '../html/match.html'), 'utf8');
const tavernHtml = fs.readFileSync(path.join(__dirname, '../html/tavern.html'), 'utf8');
const matchClient = fs.readFileSync(path.join(__dirname, '../html/js/matchClient.js'), 'utf8');
const leaderboard = fs.readFileSync(path.join(__dirname, '../html/js/ui/leaderboard.js'), 'utf8');
const designDoc = fs.readFileSync(path.join(__dirname, '../docs/TAVERN_AND_MULTIPLAYER.md'), 'utf8');
test('match and Tavern D-pads are named groups activated through native click', () => {
expect(matchHtml).toMatch(/id="matchDpad"[^>]*role="group"[^>]*aria-label="Match movement controls"/);
expect(tavernHtml).toMatch(/id="tavernDpad"[^>]*role="group"[^>]*aria-label="Tavern movement controls"/);
expect(tavernHtml).toMatch(/id="spectatorDpad"[^>]*role="group"[^>]*aria-label="Match movement controls"/);
expect(matchClient).toContain("b.addEventListener('click'");
expect(tavernHtml).toContain("b.addEventListener('click'");
});
test('Prestige is a distinct public board with an explicit non-mixing disclosure', () => {
expect(leaderboard).toContain('data-board="prestige"');
expect(leaderboard).toContain('Credit-entry competitive PvP scores only');
expect(leaderboard).toContain('never mixed with Free or the Hall of Champions');
});
test('operator docs require restart for mode/ruleset changes and do not promise future combat', () => {
expect(designDoc).toContain('restart the service');
expect(designDoc).toContain('does not hot-reload match modes or rulesets');
expect(designDoc).not.toMatch(/PvP combat (?:remains|is planned)/i);
});
});
+48
View File
@@ -0,0 +1,48 @@
const QueueManager = require('../src/network/queueManager');
function buildManager() {
const captured = [];
const game = {
id: 'game-id',
dbId: 1,
getState: () => ({}),
getProofCommitment: () => ({ commitment: 'c'.repeat(64) })
};
const user = { id: 'socket-a', clientId: 'client-a' };
const io = { to: jest.fn().mockReturnValue({ emit: jest.fn() }) };
const manager = new QueueManager({
debugManager: { getCurrentBlockHeight: () => 10 },
broadcastManager: {},
io,
createGameForUser: jest.fn(async (_user, _type, options) => { captured.push(options); return game; }),
getUserBySocket: () => user,
activeGames: new Map(),
gameModeManager: { processGameStart: jest.fn().mockResolvedValue({ success: true }) },
consoleLogging: false
});
return { manager, captured, user };
}
describe('all queue start paths preserve the consumed fairness proof', () => {
const fairnessProof = { offerId: 'offer', serverSeed: 'a'.repeat(64), clientSeed: '01' };
test('next-block start', async () => {
const { manager, captured } = buildManager();
manager.addPlayer({ serverId: 'socket-a', clientId: 'client-a', userId: 1, fairnessProof });
await manager.startGamesForWaiting(11);
expect(captured[0].fairnessProof).toBe(fairnessProof);
});
test('confirmed-payment immediate start', async () => {
const { manager, captured } = buildManager();
manager.addPlayer({ serverId: 'socket-a', clientId: 'client-a', userId: 1, fairnessProof });
await manager.startGameImmediately('socket-a', 11);
expect(captured[0].fairnessProof).toBe(fairnessProof);
});
test('early entry start', async () => {
const { manager, captured, user } = buildManager();
await manager.startEarlyGame('socket-a', user, 11, { fairnessProof });
expect(captured[0]).toEqual(expect.objectContaining({ earlyEntry: true, fairnessProof }));
});
});
+93
View File
@@ -4,6 +4,19 @@
const RateLimiter = require('../src/network/rateLimiter');
const { clientIp, stableId } = require('../src/network/rateLimitContext');
const SocketHandlers = require('../src/network/socketHandlers');
const { buildCommerceDisclosure } = require('../src/config/commerceDisclosurePolicy');
function legalAcknowledgement(gameModeManager = null) {
const disclosure = buildCommerceDisclosure(gameModeManager, process.env);
return {
policyVersion: disclosure.policyVersion,
ageEligible: true,
termsRead: true,
riskAccepted: true,
testnetUnderstood: disclosure.service.isTestNetwork === true
};
}
describe('rateLimitContext.stableId', () => {
test('prefers the session stable user id over the socket id', () => {
@@ -61,3 +74,83 @@ describe('reconnect cannot bypass the limit (stable-id keyed)', () => {
rl.shutdown();
});
});
describe('payment creation limit is enforced at the socket boundary', () => {
test('blocks the fourth request before wallet or database work', async () => {
const rateLimiter = new RateLimiter({
limits: { 'payment:create': { window: 60000, max: 3 } }
});
const downstream = jest.fn().mockResolvedValue(undefined);
const socket = {
id: 'payment-socket',
handshake: { address: '192.0.2.20', headers: {} },
emit: jest.fn()
};
const context = {
rateLimiter,
sessionManager: { sessions: new Map([[socket.id, { id: 91 }]]) },
paymentHandlers: { handlePaymentRequest: downstream }
};
for (let i = 0; i < 4; i += 1) {
await SocketHandlers.prototype.handlePaymentRequest.call(
context,
socket,
{
type: 'credits_package',
packageId: 'small',
legalAcknowledgement: legalAcknowledgement()
}
);
}
expect(downstream).toHaveBeenCalledTimes(3);
expect(socket.emit).toHaveBeenCalledWith('payment_error', expect.objectContaining({
code: 'RATE_LIMITED'
}));
rateLimiter.shutdown();
});
});
describe('match queue join throttling', () => {
test('bounds joins but never blocks an escrow-releasing leave', async () => {
const rateLimiter = new RateLimiter({
limits: { 'game:queue': { window: 60000, max: 1 } }
});
const socket = {
id: 'match-socket',
handshake: { address: '192.0.2.30', headers: {} },
emit: jest.fn()
};
const enqueue = jest.fn().mockResolvedValue({ success: true });
const leave = jest.fn().mockResolvedValue({ success: true });
const context = {
rateLimiter,
sessionManager: { sessions: new Map([[socket.id, { id: 92 }]]) },
matchQueue: { isEnabled: () => true, enqueue, leave },
_resolveMatchSession: jest.fn().mockResolvedValue({
userId: 92,
socketId: socket.id,
sessionToken: 'test-token'
}),
debugManager: { CONSOLE_LOGGING: false }
};
await SocketHandlers.prototype._handleMatchQueue.call(context, socket, {
action: 'join', economy: 'free'
});
await SocketHandlers.prototype._handleMatchQueue.call(context, socket, {
action: 'join', economy: 'free'
});
await SocketHandlers.prototype._handleMatchQueue.call(context, socket, {
action: 'leave', economy: 'free'
});
expect(enqueue).toHaveBeenCalledTimes(1);
expect(leave).toHaveBeenCalledTimes(1);
expect(socket.emit).toHaveBeenCalledWith('match_error', expect.objectContaining({
code: 'RATE_LIMITED'
}));
rateLimiter.shutdown();
});
});
+284
View File
@@ -0,0 +1,284 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFileSync, spawnSync } = require('child_process');
const sourceBuilder = path.join(__dirname, '..', 'scripts', 'deploy', 'build-release-artifact.sh');
function run(command, args, options = {}) {
return execFileSync(command, args, {
cwd: options.cwd,
encoding: 'utf8',
env: { ...process.env, TZ: 'UTC' },
stdio: options.stdio || ['ignore', 'pipe', 'pipe']
});
}
function write(root, relative, contents, mode = 0o644) {
const target = path.join(root, relative);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, contents, { mode });
}
function fixture() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'wowngeon-release-test-'));
write(root, 'scripts/deploy/build-release-artifact.sh', fs.readFileSync(sourceBuilder), 0o755);
write(root, 'src/index.js', "'use strict';\n");
write(root, 'src/package.json', JSON.stringify({
name: 'fixture',
version: '1.0.0',
scripts: {
start: 'node index.js',
preflight: 'node scripts/preflight.js',
'db:migrate:clone': 'node scripts/migrate-disposable-clone.js',
test: 'jest',
'capture:pvp': 'node scripts/pvp-capture.js',
'canary:stagenet': 'node scripts/stagenet-financial-canary.js',
'db:create': 'node scripts/setup_db.js'
}
}) + '\n');
write(root, 'src/package-lock.json', '{"name":"fixture","lockfileVersion":3}\n');
write(root, 'src/scripts/preflight.js', "'use strict';\n");
write(root, 'src/scripts/migrate-disposable-clone.js', "'use strict';\n");
write(root, 'src/scripts/pvp-capture.js', "throw new Error('source-only ad harness');\n");
write(root, 'src/scripts/stagenet-financial-canary.js', "throw new Error('source-only transfer canary');\n");
write(root, 'src/scripts/setup_db.js', "throw new Error('source-only database helper');\n");
write(root, 'src/scripts/smoke.js', "throw new Error('source-only smoke helper');\n");
write(root, 'src/jest.config.js', "module.exports = {};\n");
write(root, 'src/sim/simulate.js', "throw new Error('source-only simulator');\n");
write(root, 'src/.env.example', 'DB_PASSWORD=CHANGE_ME\n');
write(root, 'src/.env.production.example', 'ADMIN_API_KEY=CHANGE_ME\n');
write(root, 'html/index.html', '<!doctype html>\n');
write(root, 'LICENSE', 'fixture license\n');
write(root, '.env.example', 'DB_PASSWORD=CHANGE_ME\n');
write(root, 'README.md', 'source documentation only\n');
write(root, 'docs/internal.md', 'operator documentation only\n');
write(root, 'test/runtime-exclusion.test.js', "throw new Error('must not ship');\n");
write(root, 'scripts/deploy/example.service', '[Service]\nExecStart=/bin/false\n');
write(root, 'scripts/deploy/migrate-monero-stagenet-wallet.sh', '#!/bin/sh\nexit 1\n', 0o755);
write(root, '.gitignore', 'out*/\n');
run('git', ['init', '-q'], { cwd: root });
run('git', ['config', 'user.name', 'Release Test'], { cwd: root });
run('git', ['config', 'user.email', 'release-test@example.invalid'], { cwd: root });
run('git', ['add', '.'], { cwd: root });
run('git', ['commit', '-qm', 'fixture'], { cwd: root });
return root;
}
function build(root, output, releaseId = 'candidate-1') {
return run(path.join(root, 'scripts/deploy/build-release-artifact.sh'), [
'--release-id', releaseId,
'--output-dir', output
], { cwd: root });
}
function buildAsync(root, output, releaseId = 'candidate-1') {
return new Promise(resolve => {
const child = require('child_process').spawn(
path.join(root, 'scripts/deploy/build-release-artifact.sh'),
['--release-id', releaseId, '--output-dir', output],
{
cwd: root,
env: { ...process.env, TZ: 'UTC' },
stdio: ['ignore', 'pipe', 'pipe']
}
);
let stdout = '';
let stderr = '';
child.stdout.on('data', chunk => { stdout += chunk; });
child.stderr.on('data', chunk => { stderr += chunk; });
child.on('close', status => resolve({ status, stdout, stderr }));
});
}
describe('release artifact builder', () => {
const roots = [];
afterEach(() => {
for (const root of roots.splice(0)) {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('builds byte-identical, runtime-only artifacts with provenance markers', () => {
const root = fixture();
roots.push(root);
const first = path.join(root, 'out-a');
const second = path.join(root, 'out-b');
const output = build(root, first);
build(root, second);
const artifactA = path.join(first, 'candidate-1.tar.gz');
const artifactB = path.join(second, 'candidate-1.tar.gz');
expect(fs.readFileSync(artifactA).equals(fs.readFileSync(artifactB))).toBe(true);
expect(output).toMatch(/^release_id=candidate-1$/m);
expect(output).toMatch(/^commit=[0-9a-f]{40}$/m);
expect(output).toMatch(/^source_sha256=[0-9a-f]{64}$/m);
expect(output).toMatch(/^artifact_sha256=[0-9a-f]{64}$/m);
const members = run('tar', ['-tzf', artifactA]).trim().split('\n');
expect(members).toContain('candidate-1/.release-source-sha256');
expect(members).toContain('candidate-1/.release-commit');
expect(members).toContain('candidate-1/.release-package-lock-sha256');
expect(members).toContain('candidate-1/.release-files.sha256');
expect(members).toContain('candidate-1/LICENSE');
expect(members).toContain('candidate-1/src/index.js');
expect(members).toContain('candidate-1/src/scripts/preflight.js');
expect(members).toContain('candidate-1/src/scripts/migrate-disposable-clone.js');
expect(members.some(member => member.includes('/.git/'))).toBe(false);
expect(members.some(member => member.includes('/node_modules/'))).toBe(false);
expect(members.some(member => /\/(?:\.env)$/.test(member))).toBe(false);
expect(members.some(member => member.startsWith('candidate-1/docs/'))).toBe(false);
expect(members.some(member => member.startsWith('candidate-1/test/'))).toBe(false);
expect(members.some(member => member.startsWith('candidate-1/scripts/'))).toBe(false);
expect(members.some(member => member.endsWith('.service'))).toBe(false);
expect(members).not.toContain('candidate-1/scripts/deploy/migrate-monero-stagenet-wallet.sh');
expect(members.some(member => /^candidate-1\/src\/\.env(?:\.|$)/.test(member))).toBe(false);
expect(members.some(member => /\/\.env(?:\..*)?\.example$/.test(member))).toBe(false);
expect(members).not.toContain('candidate-1/src/scripts/pvp-capture.js');
expect(members).not.toContain('candidate-1/src/scripts/stagenet-financial-canary.js');
expect(members).not.toContain('candidate-1/src/scripts/setup_db.js');
expect(members).not.toContain('candidate-1/src/scripts/smoke.js');
expect(members).not.toContain('candidate-1/src/jest.config.js');
expect(members.some(member => member.startsWith('candidate-1/src/sim/'))).toBe(false);
const allowedTopLevel = new Set([
'.release-commit',
'.release-files.sha256',
'.release-id',
'.release-package-lock-sha256',
'.release-source-sha256',
'LICENSE',
'html',
'src'
]);
const actualTopLevel = new Set(
members
.filter(member => member.startsWith('candidate-1/'))
.map(member => member.slice('candidate-1/'.length).split('/')[0])
.filter(Boolean)
);
expect(actualTopLevel).toEqual(allowedTopLevel);
run('sha256sum', ['--check', path.join(first, 'candidate-1.tar.gz.sha256')], { cwd: first });
const extracted = path.join(root, 'extracted');
fs.mkdirSync(extracted);
run('tar', ['-xzf', artifactA, '-C', extracted]);
run('sha256sum', ['--check', '.release-files.sha256'], {
cwd: path.join(extracted, 'candidate-1')
});
const runtimePackage = JSON.parse(fs.readFileSync(
path.join(extracted, 'candidate-1/src/package.json'), 'utf8'
));
expect(runtimePackage.scripts).toEqual({
start: 'node index.js',
preflight: 'node scripts/preflight.js',
'db:migrate:clone': 'node scripts/migrate-disposable-clone.js'
});
});
test.each([
['tracked modification', root => write(root, 'src/index.js', '// dirty\n')],
['untracked file', root => write(root, 'untracked.txt', 'not reviewed\n')]
])('refuses a dirty worktree: %s', (_label, dirty) => {
const root = fixture();
roots.push(root);
dirty(root);
const result = spawnSync(path.join(root, 'scripts/deploy/build-release-artifact.sh'), [], {
cwd: root,
encoding: 'utf8'
});
expect(result.status).not.toBe(0);
expect(result.stderr).toContain('worktree is dirty');
});
test('refuses tracked symlinks', () => {
const root = fixture();
roots.push(root);
fs.symlinkSync('index.js', path.join(root, 'src', 'linked.js'));
run('git', ['add', '.'], { cwd: root });
run('git', ['commit', '-qm', 'add symlink'], { cwd: root });
const result = spawnSync(path.join(root, 'scripts/deploy/build-release-artifact.sh'), [], {
cwd: root,
encoding: 'utf8'
});
expect(result.status).not.toBe(0);
expect(result.stderr).toContain('tracked symlink, gitlink, or unsafe file mode');
});
test('refuses tracked runtime environment files', () => {
const root = fixture();
roots.push(root);
write(root, '.env.production', 'SECRET=value\n');
run('git', ['add', '-f', '.env.production'], { cwd: root });
run('git', ['commit', '-qm', 'add forbidden env'], { cwd: root });
const result = spawnSync(path.join(root, 'scripts/deploy/build-release-artifact.sh'), [], {
cwd: root,
encoding: 'utf8'
});
expect(result.status).not.toBe(0);
expect(result.stderr).toContain('tracked non-example environment file is forbidden');
});
test('refuses unsafe release IDs and existing output', () => {
const root = fixture();
roots.push(root);
const output = path.join(root, 'out');
let result = spawnSync(path.join(root, 'scripts/deploy/build-release-artifact.sh'), [
'--release-id', '../escape', '--output-dir', output
], { cwd: root, encoding: 'utf8' });
expect(result.status).not.toBe(0);
expect(result.stderr).toContain('release ID must be');
build(root, output, 'candidate-1');
result = spawnSync(path.join(root, 'scripts/deploy/build-release-artifact.sh'), [
'--release-id', 'candidate-1', '--output-dir', output
], { cwd: root, encoding: 'utf8' });
expect(result.status).not.toBe(0);
expect(result.stderr).toContain('refusing to overwrite');
});
test.each([
['missing', scripts => { delete scripts.preflight; }],
['empty', scripts => { scripts.preflight = ' '; }],
['non-string', scripts => { scripts.preflight = { command: 'node scripts/preflight.js' }; }]
])('refuses a %s required runtime package script', (_label, mutate) => {
const root = fixture();
roots.push(root);
const manifestPath = path.join(root, 'src/package.json');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
mutate(manifest.scripts);
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`);
run('git', ['add', 'src/package.json'], { cwd: root });
run('git', ['commit', '-qm', 'break required runtime script'], { cwd: root });
const result = spawnSync(path.join(root, 'scripts/deploy/build-release-artifact.sh'), [], {
cwd: root,
encoding: 'utf8'
});
expect(result.status).not.toBe(0);
expect(result.stderr).toContain('required runtime package script is absent or empty: preflight');
});
test('a losing concurrent build cannot remove the winning same-ID publication', async () => {
const root = fixture();
roots.push(root);
const output = path.join(root, 'out');
const results = await Promise.all([
buildAsync(root, output),
buildAsync(root, output),
buildAsync(root, output),
buildAsync(root, output)
]);
expect(results.filter(result => result.status === 0)).toHaveLength(1);
expect(results.filter(result => result.status !== 0)).toHaveLength(3);
const artifact = path.join(output, 'candidate-1.tar.gz');
const checksum = `${artifact}.sha256`;
expect(fs.existsSync(artifact)).toBe(true);
expect(fs.existsSync(checksum)).toBe(true);
run('sha256sum', ['--check', checksum], { cwd: output });
});
});
+156
View File
@@ -0,0 +1,156 @@
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const crypto = require('crypto');
function loadRenderModes(runtime, preloadedThree = null) {
const window = {
console,
WOWNGEON_RUNTIME: runtime,
localStorage: {
getItem: () => null,
setItem: () => {},
},
RK: {
TileRenderer: function TileRenderer() {},
THREE: preloadedThree,
},
};
window.window = window;
const source = fs.readFileSync(
path.join(__dirname, '../html/js/render/renderModes.js'),
'utf8',
);
vm.runInNewContext(source, { window, console });
return window.RK;
}
function fakeElement(tagName) {
const classes = new Set();
const element = {
tagName,
children: [],
className: '',
textContent: '',
title: '',
attributes: {},
appendChild(child) { this.children.push(child); return child; },
setAttribute(name, value) { this.attributes[name] = String(value); },
classList: {
add(name) { classes.add(name); },
remove(name) { classes.delete(name); },
contains(name) { return classes.has(name); },
},
};
Object.defineProperty(element, 'innerHTML', {
set() { element.children = []; },
});
return element;
}
function loadPickerWithRuntimeDisabled() {
const picker = fakeElement('div');
const head = fakeElement('head');
const document = {
head,
createElement: fakeElement,
getElementById(id) {
if (id === 'renderPicker') return picker;
if (id === 'rp-style') return head.children.find(child => child.id === 'rp-style') || null;
return null;
},
addEventListener() {},
};
const window = {
console,
document,
WOWNGEON_RUNTIME: { rendererCdnEnabled: false },
localStorage: { getItem: () => null, setItem: () => {} },
RK: { TileRenderer: function TileRenderer() {} },
};
window.window = window;
const context = { window, document, console };
vm.runInNewContext(
fs.readFileSync(path.join(__dirname, '../html/js/render/renderModes.js'), 'utf8'),
context,
);
window.RK.entitlements.premium = true;
window.RK.SPGame = { mode: () => 'tiles', setMode: () => true };
vm.runInNewContext(
fs.readFileSync(path.join(__dirname, '../html/js/ui/spRenderPicker.js'), 'utf8'),
context,
);
window.RK.rebuildRenderPicker();
return { picker, RK: window.RK };
}
describe('optional renderer CDN policy', () => {
test('external renderer loading defaults closed', () => {
const RK = loadRenderModes(undefined);
expect(RK.externalRenderersEnabled()).toBe(false);
expect(RK.isModeRuntimeAvailable('3d')).toBe(false);
expect(RK.modeAvailability('3d')).toMatchObject({
usable: false,
runtimeAvailable: false,
reason: 'runtime_unavailable',
});
expect(RK.canUseMode('3d')).toBe(false);
const callback = jest.fn();
RK.ensureThree(callback);
expect(callback).toHaveBeenCalledWith(false);
});
test('a locally preloaded renderer remains usable with CDN loading disabled', () => {
const localThree = {
THREE: { REVISION: 'local-test' },
GLTFLoader: function GLTFLoader() {},
};
const RK = loadRenderModes({ rendererCdnEnabled: false }, localThree);
RK.entitlements.premium = true;
expect(RK.canUseMode('3d')).toBe(true);
const callback = jest.fn();
RK.ensureThree(callback);
expect(callback).toHaveBeenCalledWith(true);
});
test('homepage picker labels policy-disabled 3D as unavailable, not purchasable', () => {
const { picker } = loadPickerWithRuntimeDisabled();
const modeButtons = picker.children[0].children;
const threeButton = modeButtons.find(button => button.textContent.startsWith('3D'));
expect(threeButton.textContent).toContain('⛔');
expect(threeButton.textContent).not.toContain('🔒');
expect(threeButton.title).toMatch(/unavailable on this server/i);
expect(threeButton.title).not.toMatch(/unlock with credits/i);
expect(threeButton.attributes['aria-disabled']).toBe('true');
});
test('server CSP trusts jsDelivr only when the renderer CDN flag is enabled', () => {
const serverSource = fs.readFileSync(
path.join(__dirname, '../src/index.js'),
'utf8',
);
expect(serverSource).toContain('RENDERER_CDN_ENABLED');
expect(serverSource).toMatch(/if \(rendererCdnEnabled\) \{[\s\S]*scriptSources\.push\('https:\/\/cdn\.jsdelivr\.net'\)/);
expect(serverSource).not.toContain("script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net");
});
test('browser pages use the pinned same-origin jQuery 3.7.1 asset', () => {
const asset = fs.readFileSync(path.join(__dirname, '../html/js/lib/jquery-3.7.1.min.js'));
const homepage = fs.readFileSync(path.join(__dirname, '../html/index.html'), 'utf8');
const debugPage = fs.readFileSync(path.join(__dirname, '../html/debug.html'), 'utf8');
expect(asset.toString('utf8', 0, 100)).toContain('jQuery v3.7.1');
expect(crypto.createHash('sha256').update(asset).digest('hex'))
.toBe('3289f31657004339ed678f16c4e626987e57303f2107577ed4973f6c9e105260');
expect(homepage).toContain('js/lib/jquery-3.7.1.min.js');
expect(debugPage).toContain('js/lib/jquery-3.7.1.min.js');
expect(homepage + debugPage).not.toContain('jquery-3.4.1');
});
});
+155
View File
@@ -0,0 +1,155 @@
const RpcService = require('../src/rpc/rpcService');
const { GENESIS_HASHES } = RpcService;
function daemonFixture({ cryptoType, network, height = 123, overrides = {} }) {
const target = cryptoType === 'WOW' ? 300 : 120;
return {
count: { count: height },
info: {
status: 'OK',
height,
target,
nettype: network,
offline: false,
untrusted: false,
synchronized: true,
...overrides
},
header: {
status: 'OK',
untrusted: false,
block_header: { hash: GENESIS_HASHES[`${cryptoType}:${network}`] }
}
};
}
function fixtureHttp(fixtures) {
return {
post: jest.fn(async (url, body) => {
const endpoint = url.replace(/\/json_rpc$/, '');
const fixture = fixtures[endpoint];
if (fixture instanceof Error) throw fixture;
if (!fixture) throw new Error('unknown endpoint');
if (body.method === 'getblockcount') return { data: { result: fixture.count } };
if (body.method === 'get_info') return { data: { result: fixture.info } };
if (body.method === 'get_block_header_by_height') {
return { data: { result: fixture.header } };
}
throw new Error(`unexpected method: ${body.method}`);
})
};
}
describe('RPCService pinned production identity', () => {
test.each([
['XMR', 'mainnet'],
['XMR', 'stagenet'],
['XMR', 'testnet'],
['WOW', 'mainnet']
])('accepts only the pinned %s %s genesis', async (cryptoType, network) => {
const endpoint = 'http://daemon.test';
const service = new RpcService({
http: fixtureHttp({ [endpoint]: daemonFixture({ cryptoType, network }) }),
primaryEndpoint: endpoint,
fallbackEndpoint: endpoint,
cryptoType,
network,
identityRequired: true
});
const health = await service.healthCheck();
expect(health.healthy).toBe(true);
expect(health.identity).toMatchObject({
required: true,
verified: true,
expected: { cryptoType, network },
actual: { cryptoType, network }
});
expect(JSON.stringify(health.identity)).not.toContain(GENESIS_HASHES[`${cryptoType}:${network}`]);
});
test('cannot pass WOW identity with an XMR mainnet daemon', async () => {
const endpoint = 'http://daemon.test';
const service = new RpcService({
http: fixtureHttp({ [endpoint]: daemonFixture({ cryptoType: 'XMR', network: 'mainnet' }) }),
primaryEndpoint: endpoint,
fallbackEndpoint: endpoint,
cryptoType: 'WOW',
network: 'mainnet',
identityRequired: true
});
const health = await service.healthCheck();
expect(health.healthy).toBe(false);
expect(health.identity.verified).toBe(false);
expect(health.primary.identityVerified).toBe(false);
});
test('selects a correctly identified fallback when the primary is the wrong network', async () => {
const primary = 'http://primary.test';
const fallback = 'http://fallback.test';
const http = fixtureHttp({
[primary]: daemonFixture({ cryptoType: 'XMR', network: 'mainnet' }),
[fallback]: daemonFixture({ cryptoType: 'XMR', network: 'stagenet' })
});
const service = new RpcService({
http,
primaryEndpoint: primary,
fallbackEndpoint: fallback,
cryptoType: 'XMR',
network: 'stagenet',
identityRequired: true
});
const height = await service.getBlockHeight();
expect(height).toBe(123);
expect(service.currentEndpoint).toBe(fallback);
expect(service.getIdentityStatus()).toMatchObject({
verified: true,
actual: { cryptoType: 'XMR', network: 'stagenet' }
});
});
test.each([
['zero height', { height: 0 }],
['offline daemon', { offline: true }],
['untrusted response', { untrusted: true }],
['unsynchronized daemon', { synchronized: false }],
['non-OK status', { status: 'BUSY' }]
])('fails closed for %s', async (_label, overrides) => {
const endpoint = 'http://daemon.test';
const fixture = daemonFixture({ cryptoType: 'XMR', network: 'stagenet', overrides });
if (overrides.height !== undefined) fixture.count.count = overrides.height;
const service = new RpcService({
http: fixtureHttp({ [endpoint]: fixture }),
primaryEndpoint: endpoint,
fallbackEndpoint: endpoint,
cryptoType: 'XMR',
network: 'stagenet',
identityRequired: true
});
await expect(service.healthCheck()).resolves.toMatchObject({ healthy: false });
});
test('treats an HTTP 200 JSON-RPC error as unhealthy', async () => {
const endpoint = 'http://daemon.test';
const http = { post: jest.fn().mockResolvedValue({
data: { error: { code: -1, message: 'daemon error' } }
}) };
const service = new RpcService({
http,
primaryEndpoint: endpoint,
fallbackEndpoint: endpoint,
cryptoType: 'XMR',
network: 'stagenet',
identityRequired: true
});
await expect(service.healthCheck()).resolves.toMatchObject({ healthy: false });
});
});
+71
View File
@@ -0,0 +1,71 @@
const RpcService = require('../src/rpc/rpcService');
describe('RPCService production health', () => {
const previous = {};
beforeAll(() => {
previous.PRIMARY_RPC_ENDPOINT = process.env.PRIMARY_RPC_ENDPOINT;
previous.FALLBACK_RPC_ENDPOINT = process.env.FALLBACK_RPC_ENDPOINT;
});
beforeEach(() => {
jest.clearAllMocks();
process.env.PRIMARY_RPC_ENDPOINT = 'http://127.0.0.1:18081';
process.env.FALLBACK_RPC_ENDPOINT = 'http://127.0.0.1:28081';
});
afterAll(() => {
for (const [key, value] of Object.entries(previous)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
test('reports ready when the primary daemon responds', async () => {
const http = { post: jest.fn().mockResolvedValue({ data: { result: { count: 123 } } }) };
const health = await new RpcService({ http }).healthCheck();
expect(health).toMatchObject({
healthy: true,
primary: { status: 'healthy' }
});
});
test('reports ready when only the fallback daemon responds', async () => {
const http = { post: jest.fn()
.mockRejectedValueOnce(new Error('primary offline'))
.mockResolvedValueOnce({ data: { result: { count: 123 } } }) };
const health = await new RpcService({ http }).healthCheck();
expect(health).toMatchObject({
healthy: true,
primary: { status: 'unhealthy' },
fallback: { status: 'healthy' }
});
});
test('reports not ready when every daemon endpoint is offline', async () => {
const http = { post: jest.fn().mockRejectedValue(new Error('offline')) };
const health = await new RpcService({ http }).healthCheck();
expect(health.healthy).toBe(false);
});
test('redacts credentials from endpoint labels', () => {
expect(RpcService.endpointLabel('https://rpc-user:rpc-secret@example.test/json_rpc'))
.toBe('https://example.test/json_rpc');
});
test('strict block count never substitutes the cached UI height', async () => {
const service = new RpcService({
http: { post: jest.fn().mockRejectedValue(new Error('offline')) }
});
service.lastBlockHeight = 777;
await expect(service.getBlockCountStrict()).rejects.toThrow();
await expect(service.getBlockHeight()).resolves.toBe(777);
});
});
+130 -2
View File
@@ -59,6 +59,15 @@ function findAdjacentWalkable(room) {
return null;
}
function stageSequentialExit(room) {
room.dungeon.exit = [2, 2];
room.walkable[2][1] = true;
room.walkable[2][2] = true;
room.walkable[3][2] = true;
room.occupants.get('a').moveTo(1, 2);
room.occupants.get('b').moveTo(2, 3);
}
describe('MatchRoom drives new rulesets', () => {
test('last-alive PvP: stepping onto a rival kills them and ends the match with the survivor as winner', () => {
const room = new MatchRoom({
@@ -87,6 +96,89 @@ describe('MatchRoom drives new rulesets', () => {
expect(room.playerStates.get('b').placement).toBe(2);
});
test('same-tick PvP is independent of packet order and eliminated actors never retaliate', () => {
const run = (submissionOrder) => {
const room = new MatchRoom({
ruleset: { id: 'ordered-pvp', winCondition: { type: WIN.LAST_ALIVE }, entities: { monster: false, pvpCombat: true }, players: { min: 2, max: 4 } },
entrants: { a: { userId: 1 }, b: { userId: 2 }, c: { userId: 3 } },
seed: SEED
});
room.start();
const ids = ['a', 'b', 'c'].sort((left, right) =>
room._tickActionPriority(left, room.tickCount + 1)
.localeCompare(room._tickActionPriority(right, room.tickCount + 1))
);
const [attacker, victim, downstream] = ids;
room.occupants.get(attacker).moveTo(2, 2);
room.occupants.get(victim).moveTo(3, 2);
room.occupants.get(downstream).moveTo(4, 2);
room.walkable[2][2] = true;
room.walkable[2][3] = true;
room.walkable[2][4] = true;
const moves = new Map([
[attacker, [1, 0]], // kills victim first by committed tick priority
[victim, [1, 0]] // would kill downstream if post-mortem actions ran
]);
for (const id of submissionOrder(ids)) room.queueMove(id, ...moves.get(id));
const result = room.resolveTick();
return {
attacker,
victim,
downstream,
events: result.events,
status: room.status,
winnerId: room.winnerId,
players: Array.from(room.playerStates.entries()).map(([id, state]) => [id, {
alive: state.alive,
killedBy: state.killedBy,
moves: state.moves,
placement: state.placement
}])
};
};
const forward = run(ids => [ids[0], ids[1]]);
const reverse = run(ids => [ids[1], ids[0]]);
expect(reverse).toEqual(forward);
expect(forward.players.find(([id]) => id === forward.victim)[1].alive).toBe(false);
expect(forward.players.find(([id]) => id === forward.downstream)[1].alive).toBe(true);
expect(forward.events).toEqual(expect.arrayContaining([
expect.objectContaining({
type: 'move_failed', id: forward.victim, reason: 'eliminated_before_action'
})
]));
});
test('last-alive corpses remain visible but do not trap surviving players', () => {
const room = new MatchRoom({
ruleset: {
id: 'corpse-pass-through', winCondition: { type: WIN.LAST_ALIVE },
entities: { monster: false, pvpCombat: true }, players: { min: 2, max: 4 }
},
entrants: { a: { userId: 1 }, b: { userId: 2 }, c: { userId: 3 } },
seed: SEED
});
room.start();
room.walkable[2][1] = true;
room.walkable[2][2] = true;
room.walkable[2][3] = true;
room.occupants.get('a').moveTo(1, 2);
room.occupants.get('b').moveTo(2, 2);
room.occupants.get('c').moveTo(3, 2);
room._killPlayer('b', 'monster');
room.queueMove('a', 1, 0);
const result = room.resolveTick();
expect(room.occupants.get('b')).toBeDefined();
expect(room.occupants.get('a')).toEqual(expect.objectContaining({ x: 2, y: 2 }));
expect(result.events).toEqual(expect.arrayContaining([
expect.objectContaining({ type: 'move', id: 'a' })
]));
});
test('high-score: reaching the exit does NOT instantly win — the match keeps going', () => {
const room = new MatchRoom({
ruleset: { id: 'test-score', winCondition: { type: WIN.HIGH_SCORE }, entities: { monster: false }, players: { min: 1, max: 4 } },
@@ -94,14 +186,50 @@ describe('MatchRoom drives new rulesets', () => {
seed: SEED
});
room.start();
const exit = room.dungeon.exit;
room.occupants.get('a').moveTo(exit[0], exit[1]);
stageSequentialExit(room);
room.queueMove('a', 1, 0);
const res = room.resolveTick();
expect(room.playerStates.get('a').escaped).toBe(true);
expect(room.status).toBe('active'); // b is still in play — no instant win
expect(room.winnerId).toBeNull();
expect(res.finished).toBe(false);
room.queueMove('b', 0, -1);
const second = room.resolveTick();
expect(room.playerStates.get('b').escaped).toBe(true);
expect(room.occupants.get('b')).toEqual(expect.objectContaining({ x: 2, y: 2 }));
expect(second.events).toEqual(expect.arrayContaining([
expect.objectContaining({ type: 'player_exit', id: 'b' })
]));
});
test('co-op success is collective and never invents an individual winner', () => {
const room = new MatchRoom({
ruleset: {
id: 'test-coop', mode: 'coop',
winCondition: { type: WIN.ALL_ESCAPE },
entities: { monster: false }, players: { min: 2, max: 2 }
},
entrants: { a: { userId: 1 }, b: { userId: 2 } },
seed: SEED
});
room.start();
stageSequentialExit(room);
room.queueMove('a', 1, 0);
room.resolveTick();
expect(room.status).toBe('active');
room.queueMove('b', 0, -1);
const second = room.resolveTick();
expect(room.endReason).toBe('all_escaped');
expect(second.events).toEqual(expect.arrayContaining([
expect.objectContaining({ type: 'player_exit', id: 'b' })
]));
room.finalize();
expect(room.winnerId).toBeNull();
expect([...room.playerStates.values()].map(s => s.placement).sort()).toEqual([1, 2]);
});
test('race (default) still ends the moment someone escapes', () => {
+93
View File
@@ -0,0 +1,93 @@
const { isPayoutProcessingEnabled, isWalletRequired } = require('../src/config/runtimePolicy');
const baseConfig = () => ({
payouts: {
enabled: true,
rules: {
direct: { enabled: true },
credits: { enabled: true }
}
},
modes: {
direct: { enabled: true },
credits: { enabled: true }
}
});
const baseManager = () => ({
payoutsEnabled: true,
directPayoutEnabled: true,
creditsPayoutEnabled: true
});
describe('runtime payout worker policy', () => {
test('master config switch suppresses every per-mode payout worker', () => {
const config = baseConfig();
config.payouts.enabled = false;
expect(isPayoutProcessingEnabled(config, baseManager())).toBe(false);
});
test('manager emergency switch also suppresses workers after hot reload', () => {
const manager = baseManager();
manager.payoutsEnabled = false;
expect(isPayoutProcessingEnabled(baseConfig(), manager)).toBe(false);
});
test('disabled modes cannot accidentally activate a worker through stale payout flags', () => {
const config = baseConfig();
config.modes.direct.enabled = false;
config.modes.credits.enabled = false;
expect(isPayoutProcessingEnabled(config, baseManager())).toBe(false);
});
test('one explicitly active policy is enough to process its queued payouts', () => {
const config = baseConfig();
config.modes.direct.enabled = false;
const manager = baseManager();
manager.directPayoutEnabled = false;
expect(isPayoutProcessingEnabled(config, manager)).toBe(true);
});
test('accepted durable liabilities keep the generic worker alive after admission is disabled', () => {
const config = baseConfig();
config.modes.direct.enabled = false;
config.modes.credits.enabled = false;
const manager = baseManager();
manager.directPayoutEnabled = false;
manager.creditsPayoutEnabled = false;
expect(isPayoutProcessingEnabled(config, manager)).toBe(false);
expect(isPayoutProcessingEnabled(config, manager, {
settleAcceptedLiabilities: true
})).toBe(true);
config.payouts.enabled = false;
expect(isPayoutProcessingEnabled(config, manager, {
settleAcceptedLiabilities: true
})).toBe(false);
});
test('wallet remains mandatory for payout-only accepted-liability settlement', () => {
const config = baseConfig();
config.paymentsEnabled = false;
config.modes.direct.enabled = false;
config.modes.credits.enabled = false;
const manager = baseManager();
manager.directPayoutEnabled = false;
manager.creditsPayoutEnabled = false;
expect(isWalletRequired(config, manager)).toBe(false);
expect(isWalletRequired(config, manager, {
settleAcceptedLiabilities: true
})).toBe(true);
config.payouts.enabled = false;
expect(isWalletRequired(config, manager, {
settleAcceptedLiabilities: true
})).toBe(false);
});
});
+74 -1
View File
@@ -47,6 +47,16 @@ function createMockDb() {
users.set(id, newUser);
return { rows: [newUser] };
}
if (text.includes('UPDATE users') && text.includes('SET socket_id = $1') &&
text.includes('anon_token = $2') && text.includes('anon_token = $4')) {
const [socketId, newToken, userId, presentedToken] = params;
const user = users.get(userId);
if (!user || user.anon_token !== presentedToken) return { rows: [] };
user.socket_id = socketId;
user.anon_token = newToken;
user.last_seen = new Date();
return { rows: [{ ...user }] };
}
if (text.includes('UPDATE users SET socket_id')) {
const socketId = params[0];
const userId = params[1];
@@ -282,6 +292,69 @@ describe('SQL Injection Prevention', () => {
expect(replay.resumed).toBe(false);
});
test('concurrent resume is compare-and-swap: one wins, the loser is rejected, and stale live identity is disconnected', async () => {
const localDb = createMockDb();
const staleDisconnect = jest.fn();
const io = {
sockets: {
sockets: new Map([['cas-socket-old', { disconnect: staleDisconnect }]])
}
};
const manager = new SessionManager({
db: localDb,
debugManager: { CONSOLE_LOGGING: false },
gameModeManager: { creditsPerGameCost: 1 },
io
});
const created = await manager.resumeOrCreate({
socketId: 'cas-socket-old',
ipAddress: '127.0.0.1',
resumeToken: null
});
// Hold both SELECT-by-token calls until they have each observed the old token,
// forcing the race to be decided by the conditional UPDATE rather than timing.
const baseQuery = localDb.query;
let waitingSelects = 0;
let releaseFirstSelect;
localDb.query = jest.fn(async (text, params = []) => {
const result = await baseQuery(text, params);
if (text.includes('SELECT * FROM users WHERE anon_token') && params[0] === created.token) {
waitingSelects += 1;
if (waitingSelects === 1) {
await new Promise(resolve => { releaseFirstSelect = resolve; });
} else if (releaseFirstSelect) {
releaseFirstSelect();
}
}
return result;
});
const attempts = await Promise.allSettled([
manager.resumeOrCreate({
socketId: 'cas-socket-a',
ipAddress: '127.0.0.1',
resumeToken: created.token
}),
manager.resumeOrCreate({
socketId: 'cas-socket-b',
ipAddress: '127.0.0.1',
resumeToken: created.token
})
]);
const fulfilled = attempts.filter(r => r.status === 'fulfilled');
const rejected = attempts.filter(r => r.status === 'rejected');
expect(fulfilled).toHaveLength(1);
expect(fulfilled[0].value.resumed).toBe(true);
expect(rejected).toHaveLength(1);
expect(rejected[0].reason.code).toBe('SESSION_TOKEN_REPLAY');
expect(manager.sessions.has('cas-socket-old')).toBe(false);
await expect(manager.getBySocket('cas-socket-old')).resolves.toBeNull();
expect(staleDisconnect).toHaveBeenCalledWith(true);
});
test('should safely handle malicious socket ID', async () => {
const maliciousSocketId = "'; DROP TABLE users; --";
@@ -378,4 +451,4 @@ module.exports = {
);
return result.rows[0];
}
};
};
+16 -3
View File
@@ -9,21 +9,34 @@
const SessionManager = require('../src/network/sessionManager');
function makeDb({ singleGamePayments = [], gamesForPayment = new Set(), recoveredReasons = new Set() }) {
function makeDb({ singleGamePayments = [], gamesForPayment = new Set(), recoveredReasons = new Set(), entitlementPaymentIds = new Set() }) {
let credits = 0;
const handler = async (text, params = []) => {
// credits_package recovery queries (unprocessed + orphaned) — none in these tests
if (/FROM payments[\s\S]*credits_package/i.test(text)) return { rows: [] };
if (/FROM payments\s+WHERE id = \$1[\s\S]*FOR UPDATE/i.test(text)) {
const p = singleGamePayments.find(row => row.id === params[0]);
return { rows: p ? [{ id: p.id, user_id: 7, status: 'confirmed', payment_type: 'single_game' }] : [] };
}
// outer "unconsumed single_game" discovery query
if (/payment_type = 'single_game'/i.test(text)) {
return {
rows: singleGamePayments
.filter(p => !gamesForPayment.has(p.id) && !recoveredReasons.has(`single_game_recovered:${p.id}`))
.filter(p => !gamesForPayment.has(p.id)
&& !entitlementPaymentIds.has(p.id)
&& !recoveredReasons.has(`single_game_recovered:${p.id}`))
.map(p => ({ id: p.id, confirmed_at: p.confirmed_at }))
};
}
if (/SELECT id FROM payments WHERE id = \$1 FOR UPDATE/i.test(text)) return { rows: [{ id: params[0] }] };
if (/SELECT 1 FROM games WHERE payment_id/i.test(text)) return { rows: gamesForPayment.has(params[0]) ? [{}] : [] };
if (/SELECT payment_id FROM payment_entitlement_grants/i.test(text)) {
return { rows: entitlementPaymentIds.has(params[0]) ? [{ payment_id: params[0] }] : [] };
}
if (/INSERT INTO payment_entitlement_grants/i.test(text)) {
if (entitlementPaymentIds.has(params[0])) return { rows: [], rowCount: 0 };
entitlementPaymentIds.add(params[0]);
return { rows: [{ payment_id: params[0] }], rowCount: 1 };
}
if (/FROM credit_transactions WHERE user_id = \$1 AND reason = \$2/i.test(text)) {
return { rows: recoveredReasons.has(params[1]) ? [{}] : [] };
}
+329
View File
@@ -0,0 +1,329 @@
const express = require('express');
const http = require('http');
const nacl = require('../src/node_modules/tweetnacl');
const createAuthRoutes = require('../src/routes/auth');
function normalized(sql) {
return sql.replace(/\s+/g, ' ').trim();
}
function createStateDb(initialUsers) {
const users = new Map(initialUsers.map(user => [user.id, { ...user }]));
const challenges = new Map();
let challengeId = 1;
async function query(sql, params = []) {
const text = normalized(sql);
if (text.includes('SELECT id, socket_id, anon_token FROM users')) {
const row = Array.from(users.values()).find(user =>
user.socket_id === params[0] && user.anon_token === params[1]);
return { rows: row ? [{ id: row.id, socket_id: row.socket_id, anon_token: row.anon_token }] : [] };
}
if (text.startsWith('INSERT INTO smirk_challenges')) {
const [challenge, socketId, userId, token] = params;
const user = users.get(userId);
if (!user || user.socket_id !== socketId || user.anon_token !== token) return { rows: [] };
const row = {
id: challengeId++, challenge, socket_id: socketId, user_id: userId,
used: false, expires_at: Date.now() + 300000
};
challenges.set(challenge, row);
return { rows: [{ id: row.id }] };
}
if (text.startsWith('DELETE FROM smirk_challenges')) return { rows: [], rowCount: 0 };
if (text.startsWith('UPDATE smirk_challenges SET used = TRUE')) {
const [challenge, userId, socketId] = params;
const row = challenges.get(challenge);
if (!row || row.used || row.user_id !== userId || row.socket_id !== socketId
|| row.expires_at <= Date.now()) return { rows: [] };
row.used = true;
return { rows: [{ id: row.id }] };
}
if (text.includes('SELECT id FROM users WHERE id = $1 AND socket_id = $2 AND anon_token = $3 FOR UPDATE')) {
const user = users.get(params[0]);
return {
rows: user && user.socket_id === params[1] && user.anon_token === params[2]
? [{ id: user.id }]
: []
};
}
if (text.includes('SELECT id, socket_id, payout_address FROM users WHERE smirk_public_key')) {
const owner = Array.from(users.values()).find(user =>
user.smirk_public_key === params[0] && user.id !== params[1]);
return { rows: owner ? [{
id: owner.id,
socket_id: owner.socket_id,
payout_address: owner.payout_address
}] : [] };
}
if (text.startsWith('UPDATE users SET socket_id = NULL, anon_token = $1')) {
const [newToken, userId, socketId, oldToken] = params;
const user = users.get(userId);
if (!user || user.socket_id !== socketId || user.anon_token !== oldToken) return { rows: [] };
user.socket_id = null;
user.anon_token = newToken;
return { rows: [{ id: user.id }] };
}
if (text.startsWith('UPDATE users SET socket_id = $1, anon_token = $2')) {
const [socketId, newToken, userId, provenKey] = params;
const user = users.get(userId);
if (!user || user.smirk_public_key !== provenKey) return { rows: [] };
user.socket_id = socketId;
user.anon_token = newToken;
return { rows: [{ id: user.id, payout_address: user.payout_address }] };
}
if (text.includes('SELECT id FROM users WHERE socket_id = $1 FOR UPDATE')) {
return {
rows: Array.from(users.values())
.filter(user => user.socket_id === params[0])
.map(user => ({ id: user.id }))
};
}
if (text.startsWith('UPDATE users SET smirk_public_key = $1')) {
const [key, userId, socketId, token] = params;
const user = users.get(userId);
if (!user || user.socket_id !== socketId || user.anon_token !== token) return { rows: [] };
user.smirk_public_key = key;
return { rows: [{ id: user.id }] };
}
if (text.includes('SELECT smirk_public_key, payout_address FROM users WHERE anon_token = $1')) {
const user = Array.from(users.values()).find(row => row.anon_token === params[0]);
return { rows: user ? [{
smirk_public_key: user.smirk_public_key,
payout_address: user.payout_address
}] : [] };
}
return { rows: [] };
}
return {
users,
challenges,
query: jest.fn(query),
withTransaction: jest.fn(async callback => callback({ query }))
};
}
function makeSessionManager(tokens = []) {
let index = 0;
return {
generateSecureToken: jest.fn(() => tokens[index++] || `generated-token-${index}`),
disconnectUserSessions: jest.fn()
};
}
async function startServer(db, sessionManager) {
const app = express();
app.use(express.json());
app.use(createAuthRoutes({ db, sessionManager }));
app.use((err, _req, res, _next) => {
res.status(err.statusCode || 500).json({
error: err.safeMessage || err.message,
code: err.code || 'ERROR'
});
});
return new Promise(resolve => {
const server = http.createServer(app);
server.listen(0, '127.0.0.1', () => resolve(server));
});
}
async function request(server, path, { body, token } = {}) {
const { port } = server.address();
const headers = { 'Content-Type': 'application/json' };
if (token) headers['X-Session-Token'] = token;
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
method: 'POST', headers, body: JSON.stringify(body || {})
});
return {
status: response.status,
headers: response.headers,
json: await response.json().catch(() => ({}))
};
}
function signedLegacyProof(challenge, keyPair = nacl.sign.keyPair()) {
const signature = nacl.sign.detached(Buffer.from(challenge, 'utf8'), keyPair.secretKey);
return {
keyPair,
publicKey: Buffer.from(keyPair.publicKey).toString('hex'),
signature: Buffer.from(signature).toString('hex')
};
}
describe('Smirk stable-session ownership', () => {
test('public victim socket id is insufficient: challenge requires its matching bearer and stores user_id', async () => {
const db = createStateDb([
{ id: 1, socket_id: 'victim-public-socket', anon_token: 'victim-secret' },
{ id: 2, socket_id: 'attacker-socket', anon_token: 'attacker-secret' }
]);
const server = await startServer(db, makeSessionManager());
try {
const missing = await request(server, '/api/auth/smirk/challenge', {
body: { socketId: 'victim-public-socket' }
});
expect(missing.status).toBe(401);
const stolenId = await request(server, '/api/auth/smirk/challenge', {
body: { socketId: 'victim-public-socket' }, token: 'attacker-secret'
});
expect(stolenId.status).toBe(403);
expect(db.challenges.size).toBe(0);
const legitimate = await request(server, '/api/auth/smirk/challenge', {
body: { socketId: 'victim-public-socket' }, token: 'victim-secret'
});
expect(legitimate.status).toBe(200);
expect(db.challenges.get(legitimate.json.challenge)).toMatchObject({
user_id: 1,
socket_id: 'victim-public-socket',
used: false
});
} finally {
server.close();
}
});
test('a challenge issued to one stable user cannot be verified through another session', async () => {
const db = createStateDb([
{ id: 1, socket_id: 'victim-socket', anon_token: 'victim-token', smirk_public_key: null },
{ id: 2, socket_id: 'attacker-socket', anon_token: 'attacker-token', smirk_public_key: null }
]);
const server = await startServer(db, makeSessionManager());
try {
const issued = await request(server, '/api/auth/smirk/challenge', {
body: { socketId: 'victim-socket' }, token: 'victim-token'
});
const proof = signedLegacyProof(issued.json.challenge);
const crossed = await request(server, '/api/auth/smirk/verify', {
body: {
socketId: 'attacker-socket', challenge: issued.json.challenge,
publicKey: proof.publicKey, signature: proof.signature
},
token: 'attacker-token'
});
expect(crossed.status).toBe(400);
expect(db.users.get(1).smirk_public_key).toBeNull();
expect(db.users.get(2).smirk_public_key).toBeNull();
} finally {
server.close();
}
});
test('legitimate wallet linking still succeeds for the authenticated session', async () => {
const db = createStateDb([
{ id: 1, socket_id: 'owner-socket', anon_token: 'owner-token', smirk_public_key: null }
]);
const server = await startServer(db, makeSessionManager());
try {
const issued = await request(server, '/api/auth/smirk/challenge', {
body: { socketId: 'owner-socket' }, token: 'owner-token'
});
const proof = signedLegacyProof(issued.json.challenge);
const linked = await request(server, '/api/auth/smirk/verify', {
body: {
socketId: 'owner-socket', challenge: issued.json.challenge,
publicKey: proof.publicKey, signature: proof.signature
},
token: 'owner-token'
});
expect(linked.status).toBe(200);
expect(linked.json).toMatchObject({ success: true, linked: true });
expect(db.users.get(1).smirk_public_key).toBe(proof.publicKey);
} finally {
server.close();
}
});
test('wallet adoption atomically displaces the fresh row, rotates tokens, leaves one socket owner, and revokes live sessions', async () => {
const ownerKeyPair = nacl.sign.keyPair();
const ownerKey = Buffer.from(ownerKeyPair.publicKey).toString('hex');
const db = createStateDb([
{
id: 1, socket_id: 'old-owner-socket', anon_token: 'old-owner-token',
smirk_public_key: ownerKey, payout_address: 'owner-address'
},
{
id: 2, socket_id: 'fresh-public-socket', anon_token: 'fresh-token',
smirk_public_key: null, payout_address: null
}
]);
const sessionManager = makeSessionManager(['new-owner-token', 'revoked-fresh-token']);
const server = await startServer(db, sessionManager);
try {
const issued = await request(server, '/api/auth/smirk/challenge', {
body: { socketId: 'fresh-public-socket' }, token: 'fresh-token'
});
const proof = signedLegacyProof(issued.json.challenge, ownerKeyPair);
const adopted = await request(server, '/api/auth/smirk/verify', {
body: {
socketId: 'fresh-public-socket', challenge: issued.json.challenge,
publicKey: proof.publicKey, signature: proof.signature
},
token: 'fresh-token'
});
expect(adopted.status).toBe(200);
expect(adopted.json).toMatchObject({
success: true,
adopted: true,
sessionToken: 'new-owner-token',
address: 'owner-address'
});
expect(db.users.get(1)).toMatchObject({
socket_id: 'fresh-public-socket', anon_token: 'new-owner-token'
});
expect(db.users.get(2)).toMatchObject({
socket_id: null, anon_token: 'revoked-fresh-token'
});
expect(Array.from(db.users.values()).filter(u => u.socket_id === 'fresh-public-socket'))
.toHaveLength(1);
expect(Array.from(db.users.values()).some(u => u.anon_token === 'old-owner-token')).toBe(false);
expect(Array.from(db.users.values()).some(u => u.anon_token === 'fresh-token')).toBe(false);
await new Promise(resolve => setImmediate(resolve));
expect(sessionManager.disconnectUserSessions).toHaveBeenCalledWith(
[2, 1],
['fresh-public-socket', 'old-owner-socket']
);
} finally {
server.close();
}
});
test('challenge issuance is bounded per IP', async () => {
const db = createStateDb([
{ id: 1, socket_id: 'rate-socket', anon_token: 'rate-token', smirk_public_key: null }
]);
const server = await startServer(db, makeSessionManager());
try {
for (let i = 0; i < 10; i += 1) {
const result = await request(server, '/api/auth/smirk/challenge', {
body: { socketId: 'rate-socket' }, token: 'rate-token'
});
expect(result.status).toBe(200);
}
const limited = await request(server, '/api/auth/smirk/challenge', {
body: { socketId: 'rate-socket' }, token: 'rate-token'
});
expect(limited.status).toBe(429);
expect(Number(limited.headers.get('retry-after'))).toBeGreaterThan(0);
} finally {
server.close();
}
});
});
+75
View File
@@ -0,0 +1,75 @@
const { isSmirkEnabled } = require('../src/auth/smirkPolicy');
const GameModeManager = require('../src/game/gameModeManager');
const ClientSocketHandlers = require('../html/js/network/socketHandlers');
function gameModeInfoFixture() {
const manager = Object.create(GameModeManager.prototype);
Object.assign(manager, {
creditsPayoutBaseValue: 0,
cryptoType: 'WOW',
network: 'mainnet',
isTestNetwork: false,
configSnapshot: { modes: { credits: { packages: [] } }, earlyEntry: {} },
gameMode: 'FREE',
freePlayEnabled: true,
singleGamePrice: 0,
creditsPackagePrice: 0,
creditsPerGameCost: 1,
paymentsEnabled: false,
directModeEnabled: false,
creditsModeEnabled: false,
payoutsEnabled: false,
directPayoutMultipliers: {},
creditPayoutMultipliers: {},
formatAtomic: () => '0',
formatAtomicHuman: () => '0',
getCosmeticProducts: () => [],
isPayoutEnabledForMode: () => false,
_getMatchEconomies: () => [],
_getMatchRulesetInfo: () => ({})
});
return manager.getGameModeInfo();
}
describe('Smirk explicit opt-in policy', () => {
const original = {};
beforeEach(() => {
for (const key of ['SMIRK_ENABLED', 'MONERO_NETWORK']) original[key] = process.env[key];
});
afterEach(() => {
for (const key of Object.keys(original)) {
if (original[key] === undefined) delete process.env[key];
else process.env[key] = original[key];
}
});
test('server policy is off for missing/truthy-ish values and every test network', () => {
expect(isSmirkEnabled({ MONERO_NETWORK: 'mainnet' })).toBe(false);
expect(isSmirkEnabled({ SMIRK_ENABLED: 'TRUE', MONERO_NETWORK: 'mainnet' })).toBe(false);
expect(isSmirkEnabled({ SMIRK_ENABLED: '1', MONERO_NETWORK: 'mainnet' })).toBe(false);
expect(isSmirkEnabled({ SMIRK_ENABLED: 'true', MONERO_NETWORK: 'stagenet' })).toBe(false);
expect(isSmirkEnabled({ SMIRK_ENABLED: 'true', MONERO_NETWORK: 'testnet' })).toBe(false);
expect(isSmirkEnabled({ SMIRK_ENABLED: 'true', MONERO_NETWORK: 'mainnet' })).toBe(true);
});
test('game-mode payload defaults Smirk off and advertises it only for exact mainnet opt-in', () => {
delete process.env.SMIRK_ENABLED;
process.env.MONERO_NETWORK = 'mainnet';
expect(gameModeInfoFixture().smirkEnabled).toBe(false);
process.env.SMIRK_ENABLED = 'true';
expect(gameModeInfoFixture().smirkEnabled).toBe(true);
process.env.MONERO_NETWORK = 'stagenet';
expect(gameModeInfoFixture().smirkEnabled).toBe(false);
});
test('browser gate accepts only the explicit boolean true sent by the server', () => {
expect(ClientSocketHandlers._isSmirkExplicitlyEnabled({})).toBe(false);
expect(ClientSocketHandlers._isSmirkExplicitlyEnabled({ smirkEnabled: 'true' })).toBe(false);
expect(ClientSocketHandlers._isSmirkExplicitlyEnabled({ smirkEnabled: false })).toBe(false);
expect(ClientSocketHandlers._isSmirkExplicitlyEnabled({ smirkEnabled: true })).toBe(true);
});
});
+50 -30
View File
@@ -230,39 +230,58 @@ describe('(ix) route layer — single-use challenge consume rejects replay', ()
const createAuthRoutes = require('../src/routes/auth');
// Mock DB modelling the atomic single-use UPDATE ... WHERE used=FALSE ... RETURNING.
function mockDb(challengeValue, socketId) {
const challenges = new Map([[challengeValue, { id: 1, socket_id: socketId, used: false }]]);
return {
challenges,
query: async (sql, params = []) => {
const s = sql.replace(/\s+/g, ' ').trim();
if (/^UPDATE smirk_challenges SET used = TRUE/i.test(s)) {
const [ch, sock] = params;
const row = challenges.get(ch);
if (row && row.socket_id === sock && row.used === false) {
row.used = true; // atomic FALSE -> TRUE, exactly once
return { rows: [{ id: row.id }] };
}
return { rows: [] };
}
if (/SELECT id FROM users WHERE socket_id = \$1$/i.test(s)) {
return { rows: [{ id: 77 }] };
}
if (/SELECT id FROM users WHERE smirk_public_key = \$1 AND id != \$2/i.test(s)) {
return { rows: [] }; // not linked elsewhere
}
if (/^UPDATE users SET smirk_public_key = \$1/i.test(s)) {
return { rows: [] };
function mockDb(challengeValue, socketId, token) {
const user = { id: 77, socket_id: socketId, anon_token: token };
const challenges = new Map([[
challengeValue,
{ id: 1, socket_id: socketId, user_id: user.id, used: false }
]]);
const query = async (sql, params = []) => {
const s = sql.replace(/\s+/g, ' ').trim();
if (/SELECT id, socket_id, anon_token FROM users/i.test(s)) {
return params[0] === user.socket_id && params[1] === user.anon_token
? { rows: [user] }
: { rows: [] };
}
if (/^UPDATE smirk_challenges SET used = TRUE/i.test(s)) {
const [ch, userId, sock] = params;
const row = challenges.get(ch);
if (row && row.user_id === userId && row.socket_id === sock && row.used === false) {
row.used = true; // atomic FALSE -> TRUE, exactly once
return { rows: [{ id: row.id }] };
}
return { rows: [] };
},
}
if (/SELECT id FROM users WHERE id = \$1 AND socket_id = \$2 AND anon_token = \$3 FOR UPDATE/i.test(s)) {
return params[0] === user.id && params[1] === user.socket_id && params[2] === user.anon_token
? { rows: [{ id: user.id }] }
: { rows: [] };
}
if (/SELECT id, socket_id, payout_address FROM users WHERE smirk_public_key/i.test(s)) {
return { rows: [] }; // not linked elsewhere
}
if (/^UPDATE users SET smirk_public_key = \$1/i.test(s)) {
return { rows: [{ id: user.id }] };
}
return { rows: [] };
};
return {
challenges,
query,
withTransaction: async (fn) => fn({ query }),
};
}
function startServer(db) {
const app = express();
app.use(express.json());
app.use(createAuthRoutes({ db }));
app.use(createAuthRoutes({
db,
sessionManager: {
generateSecureToken: () => 'unused-test-token',
disconnectUserSessions: jest.fn()
}
}));
// Minimal error handler mirroring the app's (ValidationError -> 400, etc.).
app.use((err, req, res, _next) => {
res.status(err.statusCode || 500).json({ error: err.message });
@@ -273,11 +292,11 @@ describe('(ix) route layer — single-use challenge consume rejects replay', ()
});
}
async function postJson(server, path, body) {
async function postJson(server, path, body, token) {
const { port } = server.address();
const res = await fetch(`http://127.0.0.1:${port}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', 'X-Session-Token': token },
body: JSON.stringify(body),
});
const json = await res.json().catch(() => ({}));
@@ -287,20 +306,21 @@ describe('(ix) route layer — single-use challenge consume rejects replay', ()
test('a valid NIP-98 event links once, then the identical replay is rejected', async () => {
const sk = generateSecretKey();
const socketId = 'sock-xyz-1';
const token = 'route-session-token';
const challenge = 'route-nonce-9f8e7d';
// created_at must be genuinely fresh — the route uses real Date.now().
const ev = buildEvent(sk, { created_at: Math.floor(Date.now() / 1000), challenge });
const db = mockDb(challenge, socketId);
const db = mockDb(challenge, socketId, token);
const server = await startServer(db);
try {
const first = await postJson(server, PATH_SUFFIX, { socketId, event: ev });
const first = await postJson(server, PATH_SUFFIX, { socketId, event: ev }, token);
expect(first.status).toBe(200);
expect(first.json).toMatchObject({ success: true, linked: true });
expect(db.challenges.get(challenge).used).toBe(true);
// Replay the exact same event: the single-use consume now fails -> 400.
const second = await postJson(server, PATH_SUFFIX, { socketId, event: ev });
const second = await postJson(server, PATH_SUFFIX, { socketId, event: ev }, token);
expect(second.status).toBe(400);
expect(second.json.error).toBeTruthy();
} finally {
+84
View File
@@ -0,0 +1,84 @@
const {
createSocketOriginAllowRequest,
normalizedConfiguredOrigin,
normalizedRequestOrigin
} = require('../src/network/socketOriginPolicy');
function allowed(env, headers) {
const allowRequest = createSocketOriginAllowRequest(env);
return new Promise((resolve, reject) => {
allowRequest({ headers }, (error, accepted) => {
if (error) reject(error);
else resolve(accepted);
});
});
}
describe('Socket.IO production Origin policy', () => {
test('development preserves permissive behavior', async () => {
await expect(allowed({ NODE_ENV: 'development' }, { origin: 'not a URL' }))
.resolves.toBe(true);
await expect(allowed({}, { origin: 'https://cross-origin.example' }))
.resolves.toBe(true);
});
test('production allows an absent Origin only after HOSTED_BY validates', async () => {
const env = { NODE_ENV: 'production', HOSTED_BY: 'https://play.example.test' };
await expect(allowed(env, {})).resolves.toBe(true);
await expect(allowed(env, undefined)).resolves.toBe(true);
});
test('allows only the exact normalized scheme and host', async () => {
const env = {
NODE_ENV: 'production',
HOSTED_BY: 'HTTPS://Play.Example.Test:443/public/path?ignored=yes'
};
await expect(allowed(env, { origin: 'https://play.example.test' })).resolves.toBe(true);
await expect(allowed(env, { origin: 'https://PLAY.EXAMPLE.TEST:443/' })).resolves.toBe(true);
await expect(allowed(env, { origin: 'http://play.example.test' })).resolves.toBe(false);
await expect(allowed(env, { origin: 'https://other.example.test' })).resolves.toBe(false);
await expect(allowed(env, { origin: 'https://play.example.test:444' })).resolves.toBe(false);
});
test.each([
'null',
'not a URL',
'ftp://play.example.test',
'https://user:password@play.example.test',
'https://play.example.test/socket/path',
'https://play.example.test/./',
'https://play.example.test?query=yes',
'https://play.example.test#fragment',
''
])('rejects malformed browser Origin %p', async (origin) => {
await expect(allowed({
NODE_ENV: 'production',
HOSTED_BY: 'https://play.example.test'
}, { origin })).resolves.toBe(false);
});
test('rejects a non-string or multi-value Origin header', async () => {
const env = { NODE_ENV: 'production', HOSTED_BY: 'https://play.example.test' };
await expect(allowed(env, { origin: ['https://play.example.test'] })).resolves.toBe(false);
await expect(allowed(env, { origin: null })).resolves.toBe(false);
});
test.each([
undefined,
'',
'not a URL',
'ftp://play.example.test',
'https://user:password@play.example.test'
])('fails closed for invalid production HOSTED_BY %p', async (hostedBy) => {
const env = { NODE_ENV: 'production', HOSTED_BY: hostedBy };
await expect(allowed(env, {})).resolves.toBe(false);
await expect(allowed(env, { origin: 'https://play.example.test' })).resolves.toBe(false);
});
test('normalizers preserve default-port normalization and reject request paths', () => {
expect(normalizedConfiguredOrigin('https://EXAMPLE.test:443/path')).toBe('https://example.test');
expect(normalizedRequestOrigin('https://EXAMPLE.test:443/')).toBe('https://example.test');
expect(normalizedRequestOrigin('https://example.test/path')).toBeNull();
});
});
+161
View File
@@ -0,0 +1,161 @@
const fs = require('fs');
const path = require('path');
const GameModeManager = require('../src/game/gameModeManager');
function paymentConfig() {
return {
getConfig: () => ({
paymentsEnabled: true,
currency: { symbol: 'WOW', decimals: 11 },
modes: {
direct: { enabled: true, price: 100n, requiresAddress: true },
credits: { enabled: true, creditsPerGame: 1, requiresAddress: true, packages: [{ id: 'p', credits: 1, price: 100n }] }
},
payouts: {
enabled: true,
rules: {
direct: { enabled: true, multipliers: { escape: 2, escapeWithTreasure: 3 }, minPayout: 1n, maxPayout: 1000n },
credits: { enabled: true, multipliers: { escape: 2, escapeWithTreasure: 3 }, baseValue: 100n, minPayout: 1n, maxPayout: 1000n }
}
},
preferences: { preferCreditsFirst: true },
earlyEntry: { enabled: false }
}),
getLegacyGameMode: () => 'PAID_SINGLE',
eventBus: { on() {} }
};
}
function managerWith(db) {
const manager = new GameModeManager(
db,
{ processPayout: jest.fn(), processBatchPayout: jest.fn() },
{ CONSOLE_LOGGING: false },
paymentConfig()
);
manager._scheduleBatchPayout = jest.fn();
return manager;
}
describe('solo liability commitment', () => {
test('credit debit and immutable payout terms are written in the same transaction', async () => {
const calls = [];
const client = {
async query(sql, params) {
calls.push({ sql, params });
if (/UPDATE users[\s\S]*credits = credits -/i.test(sql)) {
return { rows: [{ credits: 4, total_credits_purchased: 5 }], rowCount: 1 };
}
if (/UPDATE games SET game_mode = 'PAID_CREDITS'/i.test(sql)) {
return { rows: [{ id: 10 }], rowCount: 1 };
}
return { rows: [], rowCount: 1 };
}
};
const db = {
query: jest.fn(),
withTransaction: jest.fn(async callback => callback(client))
};
const manager = managerWith(db);
const result = await manager._processGameStartWithCredits({ id: 1, credits: 5, payout_address: 'addr' }, 'sock', 'seed');
expect(result.success).toBe(true);
expect(db.withTransaction).toHaveBeenCalledTimes(1);
const commitment = calls.find(call => /payout_committed_at = NOW\(\)/i.test(call.sql));
expect(commitment).toBeDefined();
expect(commitment.params[6]).toBe(true);
expect(JSON.parse(commitment.params[7])).toEqual(expect.objectContaining({
version: 2,
mode: 'PAID_CREDITS',
eligible: true,
escapeAmount: '200',
treasureAmount: '300',
minAmount: '1',
maxAmount: '1000'
}));
});
test('a missing game row aborts before a credit ledger entry can be committed', async () => {
const calls = [];
const client = {
async query(sql) {
calls.push(sql);
if (/UPDATE users[\s\S]*credits = credits -/i.test(sql)) {
return { rows: [{ credits: 4 }], rowCount: 1 };
}
if (/UPDATE games SET game_mode = 'PAID_CREDITS'/i.test(sql)) {
return { rows: [], rowCount: 0 };
}
return { rows: [], rowCount: 1 };
}
};
const manager = managerWith({ query: jest.fn(), withTransaction: async callback => callback(client) });
await expect(manager._processGameStartWithCredits({ id: 1, credits: 5, payout_address: 'addr' }, 'sock', 'missing'))
.rejects.toMatchObject({ code: 'GAME_ROW_REQUIRED' });
expect(calls.some(sql => /INSERT INTO credit_transactions/i.test(sql))).toBe(false);
});
test('completion rolls back if its payout obligation cannot be inserted', async () => {
const durable = { status: 'active', payout: false };
const gameRow = {
id: 5,
user_id: 1,
game_mode: 'PAID_SINGLE',
status: 'active',
payout_address: 'addr',
payout_eligible: true,
payout_terms: {
version: 1,
eligible: true,
escapeAmount: '200',
treasureAmount: '300',
escapeMultiplier: 2,
treasureMultiplier: 3,
minAmount: '1',
maxAmount: '1000'
}
};
const db = {
query: jest.fn(),
async withTransaction(callback) {
const tx = { status: durable.status, payout: durable.payout };
const client = {
async query(sql) {
if (/FROM games[\s\S]*FOR UPDATE/i.test(sql)) return { rows: [gameRow], rowCount: 1 };
if (/UPDATE games[\s\S]*completed_at = NOW/i.test(sql)) {
tx.status = 'won';
return { rows: [], rowCount: 1 };
}
if (/SELECT id, status FROM payouts/i.test(sql)) return { rows: [], rowCount: 0 };
if (/SELECT id, payout_address FROM users/i.test(sql)) return { rows: [{ id: 1, payout_address: 'addr' }], rowCount: 1 };
if (/INSERT INTO payouts/i.test(sql)) throw new Error('payout insert failed');
return { rows: [], rowCount: 1 };
}
};
const value = await callback(client);
durable.status = tx.status;
durable.payout = tx.payout;
return value;
}
};
const manager = managerWith(db);
const result = await manager.completeGame('sock', 'seed', true, false, {});
expect(result.success).toBe(false);
expect(durable).toEqual({ status: 'active', payout: false });
});
test('database identity guards cover every payout status and commitments are immutable', () => {
const migration = fs.readFileSync(
path.join(__dirname, '../src/migrations/032_solo_liability_invariants.sql'),
'utf8'
);
expect(migration).toMatch(/CREATE UNIQUE INDEX idx_payouts_one_per_game[\s\S]*WHERE game_id IS NOT NULL;/i);
expect(migration).toMatch(/CREATE UNIQUE INDEX idx_payouts_one_per_match[\s\S]*WHERE match_id IS NOT NULL;/i);
expect(migration).not.toMatch(/CREATE UNIQUE INDEX idx_payouts_one_per_(?:game|match)[\s\S]{0,160}status IN/i);
expect(migration).toMatch(/reject_game_payout_commitment_mutation/i);
});
});
+193
View File
@@ -0,0 +1,193 @@
const GameManager = require('../src/game/gameManager');
const MovementManager = require('../src/game/movementManager');
const SuspendedGameManager = require('../src/network/suspendedGameManager');
function makeHarness(completions, { retryMs = 1000 } = {}) {
const targeted = [];
const io = {
to: jest.fn(socketId => ({
emit: (event, payload) => targeted.push({ socketId, event, payload })
})),
emit: jest.fn()
};
const game = {
id: 'solo-terminal-seed',
socketId: 'socket-1',
dbUserId: 44,
player: { hasTreasure: true },
moveCount: 17,
startedAt: Date.now() - 2400,
movePlayer: jest.fn(),
endGame: jest.fn(function endGame() { this.gameState = 'ended'; }),
getProofReveal: jest.fn(() => ({ commitment: 'proof', seed: 'revealed-after-terminal' }))
};
const activeGames = new Map([['socket-1', game]]);
const gameModeManager = {
completeGame: jest.fn(),
db: { query: jest.fn().mockResolvedValue({ rows: [{ name: 'Alice' }] }) }
};
for (const result of completions) gameModeManager.completeGame.mockResolvedValueOnce(result);
const manager = new GameManager({
activeGames,
io,
broadcastManager: {},
debugManager: { CONSOLE_LOGGING: false },
gameModeManager,
settlementRetryBaseMs: retryMs,
settlementRetryMaxMs: retryMs
});
return { manager, game, activeGames, gameModeManager, io, targeted };
}
describe('solo terminal settlement retry', () => {
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
test('retains one frozen result after success:false and publishes it once after retry succeeds', async () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-07-21T12:00:00Z'));
const harness = makeHarness([
{ success: false, error: 'Temporary database error' },
{ success: true, payout: { payoutId: 91, status: 'queued', amount: '300', multiplier: 3 } }
]);
const first = await harness.manager.handleGameOver(
{ id: 'socket-1' },
harness.game,
'won',
'escaped',
'You escaped!',
777
);
expect(first.success).toBe(false);
expect(harness.activeGames.get('socket-1')).toBe(harness.game);
expect(harness.game.settlementPending).toBe(true);
expect(harness.game.endGame).toHaveBeenCalledTimes(1);
expect(harness.targeted.filter(event => event.event === 'game_over')).toHaveLength(0);
expect(harness.targeted.filter(event => event.event === 'game_settlement_pending')).toHaveLength(1);
expect(harness.io.emit).not.toHaveBeenCalledWith('win_feed', expect.anything());
// Retaining the game blocks both movement and replacement-game creation while the exact
// terminal transaction is unresolved.
const movement = new MovementManager({
activeGames: harness.activeGames,
io: harness.io,
debugManager: {},
moveCooldown: 0
});
movement.handleMove('socket-1', { direction: 'right' });
expect(harness.game.movePlayer).not.toHaveBeenCalled();
await expect(harness.manager.createGameForUser({ id: 'socket-1' }))
.rejects.toThrow('active or settlement-pending game');
// Even accidental object mutation cannot change the already-frozen DB arguments.
harness.game.moveCount = 999;
harness.game.player.hasTreasure = false;
await jest.advanceTimersByTimeAsync(1000);
expect(harness.gameModeManager.completeGame).toHaveBeenCalledTimes(2);
for (const call of harness.gameModeManager.completeGame.mock.calls) {
expect(call).toEqual([
'socket-1',
'solo-terminal-seed',
true,
true,
{
moves: 17,
durationSeconds: 2,
score: 777,
reason: 'escaped',
outcome: 'escaped'
}
]);
}
expect(harness.activeGames.has('socket-1')).toBe(false);
expect(harness.game.settlementPending).toBe(false);
expect(harness.game.settlementCommitted).toBe(true);
expect(harness.targeted.filter(event => event.event === 'game_over')).toHaveLength(1);
expect(harness.io.emit.mock.calls.filter(([event]) => event === 'leaderboard_update')).toHaveLength(1);
expect(harness.io.emit.mock.calls.filter(([event]) => event === 'win_feed')).toHaveLength(1);
// A late duplicate terminal callback is a no-op after commit.
await harness.manager.handleGameOver(
{ id: 'socket-1' }, harness.game, 'lost', 'timeout', 'Too late', 0
);
expect(harness.gameModeManager.completeGame).toHaveBeenCalledTimes(2);
expect(harness.targeted.filter(event => event.event === 'game_over')).toHaveLength(1);
});
test('graceful shutdown cancels timers and drains an unresolved terminal result before DB close', async () => {
jest.useFakeTimers();
const harness = makeHarness([
{ success: false, error: 'Temporary database error' },
{ success: true, payout: null }
], { retryMs: 60000 });
await harness.manager.handleGameOver(
{ id: 'socket-1' }, harness.game, 'lost', 'monster', 'Caught', 0
);
const result = await harness.manager.shutdown({ timeoutMs: 1000, retryIntervalMs: 1 });
expect(result).toEqual({ initial: 1, settled: 1, pending: 0 });
expect(harness.gameModeManager.completeGame).toHaveBeenCalledTimes(2);
expect(harness.activeGames.size).toBe(0);
expect(jest.getTimerCount()).toBe(0);
});
test('targets the reconnect socket and removes its re-keyed ownership after settlement', async () => {
jest.useFakeTimers();
const harness = makeHarness([
{ success: false, error: 'Temporary database error' },
{ success: true, payout: null }
]);
await harness.manager.handleGameOver(
{ id: 'socket-1' }, harness.game, 'won', 'escaped', 'Escaped', 500
);
// Mirrors SuspendedGameManager.restoreGame(): the same object moves to the new socket key.
harness.activeGames.delete('socket-1');
harness.game.socketId = 'socket-2';
harness.activeGames.set('socket-2', harness.game);
await jest.advanceTimersByTimeAsync(1000);
expect(harness.gameModeManager.completeGame.mock.calls[1][0]).toBe('socket-2');
expect(harness.targeted.filter(event => event.event === 'game_over')).toEqual([
expect.objectContaining({ socketId: 'socket-2' })
]);
expect(harness.activeGames.has('socket-1')).toBe(false);
expect(harness.activeGames.has('socket-2')).toBe(false);
});
test('reconnect restores a pending terminal lock without re-counting it as a playable game', () => {
jest.useFakeTimers();
const activeGames = new Map();
const suspended = new SuspendedGameManager({
debugManager: { CONSOLE_LOGGING: false },
activeGames,
cleanupTimeoutMs: 60000
});
const game = {
id: 'pending-reconnect',
settlementPending: true,
player: { x: 1, y: 2, hasTreasure: true },
moveCount: 9
};
const newUser = { joinGame: jest.fn() };
suspended.suspendGame(44, 'socket-old', game);
const restored = suspended.restoreGame(44, 'socket-new', newUser);
expect(restored.game).toBe(game);
expect(activeGames.get('socket-new')).toBe(game);
expect(newUser.joinGame).not.toHaveBeenCalled();
expect(game.user).toBe(newUser);
suspended.cleanup();
expect(jest.getTimerCount()).toBe(0);
});
});
+55
View File
@@ -54,4 +54,59 @@ describe('SP client-state → Scene adapter', () => {
const scene = RK.sceneFromGameState(state, {});
expect(scene.grid[0][0]).toBe('floor');
});
test('multiplayer players[] become racers and an explicit socket id owns the camera', () => {
const state = {
visibleTiles: [[F, F, F], [F, F, F]],
exploredTiles: [[F, F, F], [F, F, F]],
players: [
{ id: 'rival', x: 0, y: 0, name: 'Rival', alive: true },
{ id: 'mine', x: 2, y: 1, name: 'Me', avatar: 'char-ranger', alive: true, placement: 2 }
],
entrance: [0, 0], exit: [2, 1], monster: null, lighting: {}
};
// String second args were used by the original match client; keep them backward compatible.
const scene = RK.sceneFromGameState(state, 'mine');
const racers = scene.entities.filter(e => e.kind === 'player');
expect(racers).toHaveLength(2);
expect(racers.find(e => e.id === 'mine')).toMatchObject({
x: 2, y: 1, label: 'Me', avatar: 'char-ranger', you: true, cameraTarget: true, placement: 2
});
expect(racers.find(e => e.id === 'rival')).toMatchObject({ you: false, cameraTarget: false });
});
test('server you flag is honored and spectator camera falls back to a live racer', () => {
const base = {
visibleTiles: [[F, F, F]], exploredTiles: [[F, F, F]],
entrance: [0, 0], exit: [2, 0], monster: null, lighting: {}
};
const marked = RK.sceneFromGameState({
...base,
players: [{ id: 'a', x: 0, y: 0 }, { id: 'b', x: 1, y: 0, you: true }]
}, {});
expect(marked.entities.find(e => e.id === 'b')).toMatchObject({ you: true, cameraTarget: true });
const spectator = RK.sceneFromGameState({
...base,
players: [
{ id: 'dead', x: 0, y: 0, alive: false },
{ id: 'live', x: 1, y: 0, alive: true },
{ id: 'done', x: 2, y: 0, alive: true, finished: true }
]
}, { viewerId: 'spectator' });
expect(spectator.entities.find(e => e.cameraTarget).id).toBe('live');
expect(spectator.entities.some(e => e.you)).toBe(false);
});
test('match treasure object shape renders until it has a carrier', () => {
const base = {
visibleTiles: [[F, F]], exploredTiles: [[F, F]],
players: [{ id: 'a', x: 0, y: 0 }], monster: null, lighting: {}
};
const loose = RK.sceneFromGameState({ ...base, treasure: { x: 1, y: 0, carrierId: null } }, {});
expect(loose.entities.some(e => e.id === 'treasure')).toBe(true);
const carried = RK.sceneFromGameState({ ...base, treasure: { x: 1, y: 0, carrierId: 'a' } }, {});
expect(carried.entities.some(e => e.id === 'treasure')).toBe(false);
});
});
+269
View File
@@ -0,0 +1,269 @@
-- Behavioral smoke test for migrations 032/035/037/038/039/040.
-- Run with psql -X -v ON_ERROR_STOP=1 after migrations. Everything rolls back.
BEGIN;
DO $economic_invariants$
DECLARE
owner_id INTEGER;
other_id INTEGER;
product_payment_id INTEGER;
solo_payment_id INTEGER;
game_row_id INTEGER;
payout_row_id INTEGER;
match_row_id UUID;
receipt_row_id BIGINT;
receipt_tx CHAR(64) := md5(random()::text) || md5(clock_timestamp()::text);
receipt_output CHAR(64) := md5(clock_timestamp()::text) || md5(random()::text);
saved_address TEXT := 'test-wallet-' || txid_current()::text;
BEGIN
INSERT INTO users (socket_id, username, payout_address)
VALUES ('economic-owner-' || txid_current()::text, 'economic-owner', saved_address)
RETURNING id INTO owner_id;
INSERT INTO users (socket_id, username, payout_address)
VALUES ('economic-other-' || txid_current()::text, 'economic-other', 'test-wallet-other')
RETURNING id INTO other_id;
INSERT INTO payments (
user_id, socket_id, subaddress, address_index, expected_amount, payment_type,
payment_mode, credit_package_id, product_id, product_grants, description,
provider_id, provider_invoice_id, status, expires_at
) VALUES (
owner_id, 'economic-product', 'test-product-subaddress', 771, 100,
'credits_package', 'credits', 'small', 'small', '{"credits":10}'::jsonb,
'10 credits', 'native-monero', 'test-product-invoice', 'pending', NOW() + INTERVAL '1 hour'
) RETURNING id INTO product_payment_id;
-- Legacy pending rows retain one pre-receipt path to snapshot their product promise.
UPDATE payments
SET product_grants = '{"credits":10,"bonus":1}'::jsonb
WHERE id = product_payment_id;
INSERT INTO payment_receipts (
payment_id, provider_id, evidence_type, evidence_id, tx_hash, output_id,
address_index, amount, confirmed
) VALUES (
product_payment_id, 'native-monero', 'chain_output',
receipt_tx || ':' || receipt_output, receipt_tx, receipt_output, 771, 100, TRUE
) RETURNING id INTO receipt_row_id;
BEGIN
UPDATE payments SET expected_amount = 1 WHERE id = product_payment_id;
RAISE EXCEPTION 'receipt-backed invoice price mutation was accepted';
EXCEPTION WHEN check_violation THEN
NULL;
END;
BEGIN
UPDATE payments SET product_grants = '{"credits":999}'::jsonb WHERE id = product_payment_id;
RAISE EXCEPTION 'receipt-backed product promise mutation was accepted';
EXCEPTION WHEN check_violation THEN
NULL;
END;
-- Settlement and refund state remain operationally mutable.
UPDATE payments
SET status = 'confirmed', received_amount = 100, confirmed_at = NOW()
WHERE id = product_payment_id;
UPDATE payments SET status = 'refunded' WHERE id = product_payment_id;
BEGIN
UPDATE payments SET provider_invoice_id = 'rewritten' WHERE id = product_payment_id;
RAISE EXCEPTION 'refunded invoice provider identity mutation was accepted';
EXCEPTION WHEN check_violation THEN
NULL;
END;
INSERT INTO payments (
user_id, socket_id, subaddress, address_index, expected_amount, payment_type,
product_id, product_grants, provider_id, provider_invoice_id, status
) VALUES (
owner_id, 'economic-expired', 'test-expired-subaddress', 773, 50,
'credits_package', 'expired-small', '{"credits":5}'::jsonb,
'native-monero', 'test-expired-invoice', 'expired'
) RETURNING id INTO product_payment_id;
BEGIN
UPDATE payments SET subaddress = 'rewritten-expired-address' WHERE id = product_payment_id;
RAISE EXCEPTION 'expired invoice destination mutation was accepted';
EXCEPTION WHEN check_violation THEN
NULL;
END;
BEGIN
UPDATE payment_receipts SET amount = 1 WHERE id = receipt_row_id;
RAISE EXCEPTION 'payment receipt update was accepted';
EXCEPTION WHEN check_violation THEN
NULL;
END;
BEGIN
DELETE FROM payment_receipts WHERE id = receipt_row_id;
RAISE EXCEPTION 'payment receipt deletion was accepted';
EXCEPTION WHEN check_violation THEN
NULL;
END;
INSERT INTO payments (
user_id, socket_id, subaddress, address_index, expected_amount, payment_type,
provider_id, provider_invoice_id, status, fairness_proof_version,
fairness_offer_id, fairness_offer_issued_at, fairness_commitment,
fairness_server_seed, fairness_client_seed, fairness_bound_at
) VALUES (
owner_id, 'economic-solo', 'test-solo-subaddress', 772, 100, 'single_game',
'native-monero', 'test-solo-invoice', 'pending', 2,
'test-offer-' || txid_current()::text, NOW(), repeat('a', 64),
repeat('b', 64), 'client-seed', NOW()
) RETURNING id INTO solo_payment_id;
BEGIN
UPDATE payments SET user_id = other_id WHERE id = solo_payment_id;
RAISE EXCEPTION 'fairness-bound invoice owner mutation was accepted';
EXCEPTION WHEN check_violation THEN
NULL;
END;
INSERT INTO games (
user_id, socket_id, game_mode, payment_id, status, dungeon_seed,
payout_escape_amount, payout_treasure_amount, payout_escape_mult,
payout_treasure_mult, payout_address, payout_eligible, payout_terms,
payout_committed_at
) VALUES (
owner_id, 'economic-game', 'PAID_SINGLE', solo_payment_id, 'active',
'economic-seed-' || txid_current()::text, 200, 300, 2, 3,
saved_address, TRUE, '{"escape":200,"treasure":300}'::jsonb, NOW()
) RETURNING id INTO game_row_id;
BEGIN
UPDATE games SET user_id = other_id WHERE id = game_row_id;
RAISE EXCEPTION 'committed game owner mutation was accepted';
EXCEPTION WHEN check_violation THEN
NULL;
END;
UPDATE games SET moves_made = moves_made + 1 WHERE id = game_row_id;
INSERT INTO payouts (
user_id, game_id, payout_address, amount, multiplier, reason, status
) VALUES (
owner_id, game_row_id, 'PENDING_NO_ADDRESS', 200, 2,
'solo_winner_no_address', 'needs_review'
) RETURNING id INTO payout_row_id;
-- The sole destination exception is the exact saved-address claim transition.
UPDATE payouts
SET payout_address = saved_address, status = 'pending'
WHERE id = payout_row_id;
UPDATE payouts SET retry_count = retry_count + 1 WHERE id = payout_row_id;
BEGIN
UPDATE payouts SET payout_address = 'test-wallet-rewritten' WHERE id = payout_row_id;
RAISE EXCEPTION 'claimed payout destination mutation was accepted';
EXCEPTION WHEN check_violation THEN
NULL;
END;
BEGIN
UPDATE payouts SET amount = 1 WHERE id = payout_row_id;
RAISE EXCEPTION 'payout amount mutation was accepted';
EXCEPTION WHEN check_violation THEN
NULL;
END;
BEGIN
INSERT INTO payouts (
user_id, payout_address, amount, multiplier, reason, status
) VALUES (
owner_id, 'PENDING_NO_ADDRESS', 1, 1,
'solo_winner_no_address', 'pending'
);
RAISE EXCEPTION 'invalid no-address payout shape was accepted';
EXCEPTION WHEN check_violation THEN
NULL;
END;
-- An unresolved paid winner remains recorded for manual review, but its missing owner means
-- the saved-address exception must never make it dispatchable automatically.
INSERT INTO payouts (
user_id, payout_address, amount, multiplier, reason, status
) VALUES (
NULL, 'PENDING_NO_ADDRESS', 1, 1,
'solo_winner_identity_review', 'needs_review'
) RETURNING id INTO payout_row_id;
BEGIN
UPDATE payouts
SET payout_address = saved_address, status = 'pending'
WHERE id = payout_row_id;
RAISE EXCEPTION 'unresolved solo winner address claim was accepted';
EXCEPTION WHEN check_violation THEN
NULL;
END;
INSERT INTO matches (
economy, variant, ruleset_id, difficulty_preset, max_players,
seed_hash, dungeon, start_block_height
) VALUES (
'credits_prestige', 'pvp', 'last-alive', 'race', 2,
repeat('c', 64),
jsonb_build_object('match_fairness_freeze', jsonb_build_object(
'version', 'future-block-freeze-v2',
'freezeBlockHeight', 800,
'targetBlockHeight', 802,
'entropyDelayBlocks', 2,
'economy', 'credits_prestige',
'rulesetId', 'last-alive',
'queueEntryIds', jsonb_build_array('81', '82'),
'freezeCommitment', repeat('c', 64)
)),
802
) RETURNING id INTO match_row_id;
BEGIN
UPDATE matches SET entropy_precommit_tip_height = 800 WHERE id = match_row_id;
RAISE EXCEPTION 'partial paid entropy precommit marker was accepted';
EXCEPTION WHEN check_violation THEN
NULL;
END;
UPDATE matches
SET entropy_precommit_tip_height = 800,
entropy_precommit_verified_at = NOW()
WHERE id = match_row_id;
BEGIN
UPDATE matches SET entropy_precommit_tip_height = 799 WHERE id = match_row_id;
RAISE EXCEPTION 'paid entropy precommit witness mutation was accepted';
EXCEPTION WHEN check_violation THEN
NULL;
END;
BEGIN
UPDATE matches SET seed_hash = repeat('d', 64) WHERE id = match_row_id;
RAISE EXCEPTION 'verified pending paid freeze identity mutation was accepted';
EXCEPTION WHEN check_violation THEN
NULL;
END;
-- The single activation transition may replace the freeze envelope with the derived proof.
UPDATE matches
SET status = 'starting',
seed_hash = repeat('e', 64),
dungeon = jsonb_build_object('match_fairness', jsonb_build_object(
'version', 'future-chain-block-v2',
'freezeCommitment', repeat('c', 64)
))
WHERE id = match_row_id;
BEGIN
UPDATE matches SET ruleset_id = 'race' WHERE id = match_row_id;
RAISE EXCEPTION 'active paid match fairness identity mutation was accepted';
EXCEPTION WHEN check_violation THEN
NULL;
END;
UPDATE matches SET status = 'active', started_at = NOW() WHERE id = match_row_id;
RAISE NOTICE 'economic invariants behavioral test passed';
END
$economic_invariants$;
ROLLBACK;
+382
View File
@@ -0,0 +1,382 @@
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const {
LIVE_CONFIRM,
SAFE_PROFILE_CONFIRM,
SCENARIOS,
addVisible,
assertEmptyFinancialDatabase,
assertInvoiceNotOwned,
assertLiveSafety,
canonicalAcknowledgement,
chooseMove,
readConfiguration,
safeMessage,
validateExpectedDatabaseName,
verifyCanaryDatabaseHandshake,
verifyGameOver
} = require('../src/scripts/stagenet-financial-canary');
const {
CANARY_HANDSHAKE_PROTOCOL,
computeProof
} = require('../src/services/canaryDatabaseIdentity');
const SCRIPT = path.join(__dirname, '../src/scripts/stagenet-financial-canary.js');
function configEnv(overrides = {}) {
return {
E2E_MODE: 'preflight',
E2E_TARGET: 'http://127.0.0.1:3102',
...overrides
};
}
function botState() {
return {
goal: null,
objective: null,
frontiersTried: new Set()
};
}
function visibleState({ player, exit, treasure }) {
return {
player,
exit,
treasure,
monster: null,
visibleTiles: {
0: { 0: "'1", 1: "'1", 2: "'1" },
1: { 0: "'1", 1: "'1", 2: "'1" }
}
};
}
describe('XMR stagenet financial canary safety contract', () => {
test('recognizes only the two explicit financial scenarios', () => {
expect(Object.keys(SCENARIOS)).toEqual(['direct-2x', 'credits-3x']);
expect(SCENARIOS['direct-2x']).toEqual(expect.objectContaining({
paymentType: 'single_game',
gameMode: 'PAID_SINGLE',
multiplier: 2,
collectTreasure: false
}));
expect(SCENARIOS['credits-3x']).toEqual(expect.objectContaining({
paymentType: 'credits_package',
gameMode: 'PAID_CREDITS',
multiplier: 3,
collectTreasure: true
}));
expect(() => readConfiguration(configEnv({ E2E_SCENARIO: 'anything-else' })))
.toThrow(/direct-2x or credits-3x/);
});
test('refuses public targets, embedded credentials, paths, and the wrong port', () => {
expect(() => readConfiguration(configEnv({ E2E_TARGET: 'https://example.test:3102' })))
.toThrow(/localhost/);
expect(() => readConfiguration(configEnv({ E2E_TARGET: 'http://user:pass@127.0.0.1:3102' })))
.toThrow(/credentials/);
expect(() => readConfiguration(configEnv({ E2E_TARGET: 'http://127.0.0.1:3000' })))
.toThrow(/3102/);
expect(() => readConfiguration(configEnv({ E2E_TARGET: 'http://127.0.0.1:3102/app' })))
.toThrow(/path/);
});
test('requires a scenario outside public preflight and scenario-specific live intent', () => {
expect(() => readConfiguration(configEnv({ E2E_MODE: 'database-preflight' })))
.toThrow(/E2E_SCENARIO/);
const env = configEnv({
E2E_MODE: 'live-stagenet',
E2E_SCENARIO: 'credits-3x',
E2E_MAX_TRANSFER_ATOMIC: '500000000000',
E2E_CONFIRM: LIVE_CONFIRM,
E2E_SCENARIO_CONFIRM: SCENARIOS['credits-3x'].scenarioConfirm,
E2E_CANARY_PROFILE: SAFE_PROFILE_CONFIRM,
E2E_DATABASE_NONCE_FILE: '/run/credentials/canary/database-nonce'
});
const config = readConfiguration(env);
expect(() => assertLiveSafety(config, env)).not.toThrow();
expect(() => assertLiveSafety(config, { ...env, E2E_SCENARIO_CONFIRM: 'DIRECT_2X_ESCAPE' }))
.toThrow(/CREDITS_PACKAGE_THEN_3X/);
expect(() => assertLiveSafety(config, { ...env, E2E_CONFIRM: '' }))
.toThrow(/E2E_CONFIRM/);
const noNonceConfig = readConfiguration({ ...env, E2E_DATABASE_NONCE_FILE: '' });
expect(() => assertLiveSafety(noNonceConfig, env)).toThrow(/DATABASE_NONCE_FILE/);
expect(() => assertLiveSafety(
readConfiguration({ ...env, E2E_MAX_TRANSFER_ATOMIC: '' }), env
)).toThrow(/MAX_TRANSFER/);
});
test('builds exactly the current five-field stagenet acknowledgement', () => {
const acknowledgement = canonicalAcknowledgement({
policyVersion: '2026-07-21-v2',
paidAcknowledgementRequired: true,
service: {
cryptoType: 'XMR',
network: 'stagenet',
isTestNetwork: true,
paymentsEnabled: true,
directPaidEntryEnabled: true,
paidCreditsEnabled: true,
soloPayoutsEnabled: true,
anyPayoutsEnabled: true,
paidPrestigeOnly: false
}
});
expect(acknowledgement).toEqual({
policyVersion: '2026-07-21-v2',
ageEligible: true,
termsRead: true,
riskAccepted: true,
testnetUnderstood: true
});
expect(Object.keys(acknowledgement)).toHaveLength(5);
expect(Object.isFrozen(acknowledgement)).toBe(true);
expect(() => canonicalAcknowledgement({
policyVersion: 'v1',
paidAcknowledgementRequired: true,
service: {
cryptoType: 'XMR',
network: 'mainnet',
isTestNetwork: false,
paymentsEnabled: true,
directPaidEntryEnabled: true,
paidCreditsEnabled: true,
soloPayoutsEnabled: true,
anyPayoutsEnabled: true,
paidPrestigeOnly: false
}
})).toThrow(/stagenet/);
});
test('requires a scenario-named dedicated canary E2E database', () => {
expect(() => validateExpectedDatabaseName(
'monerogue_canary_direct_e2e', SCENARIOS['direct-2x']
)).not.toThrow();
expect(() => validateExpectedDatabaseName(
'monerogue_canary_credits_e2e', SCENARIOS['credits-3x']
)).not.toThrow();
expect(() => validateExpectedDatabaseName('monerogue', SCENARIOS['direct-2x']))
.toThrow(/canary and e2e/);
expect(() => validateExpectedDatabaseName(
'monerogue_canary_credits_e2e', SCENARIOS['direct-2x']
)).toThrow(/scenario tag/);
});
test('freshness guard covers all financial/game ledgers and current migrations', async () => {
const row = {
users: 1,
seed_admins: 1,
required_migrations: 5,
payments: 0,
games: 0,
payouts: 0,
receipts: 0,
refunds: 0,
late_reviews: 0,
credit_transactions: 0,
entitlement_grants: 0,
pack_entitlements: 0,
matches: 0,
match_entrants: 0,
match_events: 0,
match_queue_entries: 0,
race_entry_transactions: 0,
race_entry_lots: 0
};
const write = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
try {
const db = { query: jest.fn().mockResolvedValue({ rows: [row] }) };
await expect(assertEmptyFinancialDatabase(db)).resolves.toBeUndefined();
expect(db.query.mock.calls[0][0]).toContain('payment_receipts');
expect(db.query.mock.calls[0][0]).toContain('payment_entitlement_grants');
expect(db.query.mock.calls[0][0]).toContain('race_entry_lots');
const dirty = { query: jest.fn().mockResolvedValue({
rows: [{ ...row, receipts: 1 }]
}) };
await expect(assertEmptyFinancialDatabase(dirty)).rejects.toThrow(/receipts/);
} finally {
write.mockRestore();
}
});
test('verifies the app and harness exact database identity before the live runner', async () => {
const directory = fs.mkdtempSync('/tmp/wowngeon-canary-handshake-');
const nonceFile = path.join(directory, 'nonce');
const nonce = 'cd'.repeat(32);
fs.writeFileSync(nonceFile, `${nonce}\n`, { mode: 0o600 });
const config = readConfiguration(configEnv({
E2E_MODE: 'live-stagenet',
E2E_SCENARIO: 'direct-2x',
E2E_MAX_TRANSFER_ATOMIC: '1000',
E2E_DATABASE_NONCE_FILE: nonceFile
}));
const identity = {
clusterId: '7612345678901234567',
databaseOid: '24591',
databaseName: 'monerogue_canary_direct_e2e'
};
const db = { query: jest.fn().mockResolvedValue({ rows: [{
cluster_id: identity.clusterId,
database_oid: identity.databaseOid,
database_name: identity.databaseName
}] }) };
const request = jest.fn(async (_target, relative, options) => {
const challenge = options.headers['X-Canary-Database-Challenge'];
return {
response: {
status: 200,
headers: { get: name => name === 'cache-control' ? 'no-store' : null }
},
body: {
protocol: CANARY_HANDSHAKE_PROTOCOL,
challenge,
database: identity,
proof: computeProof(nonce, identity, challenge)
}
};
});
const write = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
try {
await expect(verifyCanaryDatabaseHandshake(config, db, {
E2E_EXPECT_DATABASE: identity.databaseName
}, request)).resolves.toBeUndefined();
expect(request).toHaveBeenCalledWith(config.target,
'/api/canary/database-identity', expect.any(Object));
} finally {
write.mockRestore();
fs.rmSync(directory, { recursive: true, force: true });
}
});
test('requires affirmative proof that invoice and funding wallets differ', () => {
expect(() => assertInvoiceNotOwned({
error: { message: "Address doesn't belong to the wallet" }
})).not.toThrow();
expect(() => assertInvoiceNotOwned({ result: { index: { major: 0, minor: 1 } } }))
.toThrow(/self-payment/);
expect(() => assertInvoiceNotOwned({ error: { message: 'daemon unavailable' } }))
.toThrow(/could not prove/);
});
test('direct bot avoids treasure while credits bot gets treasure before exit', () => {
const directState = visibleState({
player: { x: 0, y: 0, hasTreasure: false },
treasure: [1, 0],
exit: [0, 1]
});
const directKnown = new Map();
addVisible(directKnown, directState);
expect(chooseMove(directKnown, directState, botState(), SCENARIOS['direct-2x']))
.toBe('down');
const creditsState = visibleState({
player: { x: 0, y: 0, hasTreasure: false },
treasure: [1, 0],
exit: [2, 0]
});
const creditsKnown = new Map();
addVisible(creditsKnown, creditsState);
const creditsBot = botState();
expect(chooseMove(creditsKnown, creditsState, creditsBot, SCENARIOS['credits-3x']))
.toBe('right');
const collected = {
...creditsState,
player: { x: 1, y: 0, hasTreasure: true },
treasure: null
};
expect(chooseMove(creditsKnown, collected, creditsBot, SCENARIOS['credits-3x']))
.toBe('right');
});
test.each([
['direct-2x', 10n, false],
['credits-3x', 7n, true]
])('verifies exact game-over liability and two-party proof for %s', (scenarioName, base, treasure) => {
const scenario = SCENARIOS[scenarioName];
const serverSeed = '11'.repeat(32);
const clientSeed = '22'.repeat(32);
const commitment = crypto.createHash('sha256').update(serverSeed).digest('hex');
const effectiveSeed = crypto.createHmac('sha256', serverSeed).update(clientSeed).digest('hex');
const offer = { offerId: '33'.repeat(32), commitment };
const gameId = 'game-proof-id';
const expected = base * BigInt(scenario.multiplier);
const gameOver = {
status: 'won',
reason: 'escaped',
treasure,
payout: {
success: true,
mode: scenario.gameMode,
payout: {
status: 'queued',
payoutId: 9,
amount: expected.toString(),
multiplier: scenario.multiplier
}
},
proof: {
gameId,
offerId: offer.offerId,
commitment,
clientSeed,
serverSeed,
effectiveSeed,
gameResult: { won: true, treasureFound: treasure }
}
};
const write = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
try {
expect(verifyGameOver(gameOver, {
scenario, payoutBase: base, offer, clientSeed
}, { proof: { gameId } })).toEqual({
expected,
payoutId: 9,
gameId
});
} finally {
write.mockRestore();
}
});
test('redacts addresses, hashes, tokens, credentials, and database URL passwords', () => {
const address = '5'.repeat(95);
const hash = 'a'.repeat(64);
const raw = `${address} ${hash} token=secret {"authorization":"jsonsecret"} Bearer othersecret `
+ 'postgres://alice:password@example.invalid/db';
const safe = safeMessage(raw);
expect(safe).toContain('[redacted-address]');
expect(safe).toContain('[redacted-64-byte-value]');
expect(safe).not.toContain(address);
expect(safe).not.toContain(hash);
expect(safe).not.toContain('secret');
expect(safe).not.toContain('password@example');
});
test('source has one non-retriable relay call, a guarded main, and both exact flows', () => {
const source = fs.readFileSync(SCRIPT, 'utf8');
expect((source.match(/funding\.rpc\('transfer'/g) || [])).toHaveLength(1);
expect(source).toMatch(/transferGate\.attempted = true;[\s\S]*do_not_relay: false/);
expect(source).toContain('if (require.main === module)');
expect(source).toContain("type: 'single_game'");
expect(source).toContain("type: 'credits_package'");
expect(source).toContain("socket.emit('auto_start'");
expect(source).toContain('legalAcknowledgement: acknowledgement');
expect(source).not.toContain('console.log(');
const mainBody = source.slice(source.indexOf('async function main('));
expect(mainBody.indexOf('assertLiveSafety(config, env)'))
.toBeLessThan(mainBody.indexOf('publicPreflight(config)'));
expect(mainBody.indexOf('verifyCanaryDatabaseHandshake(config, db, env)'))
.toBeLessThan(mainBody.indexOf('runLive(config, modules, db, legal, env)'));
const manifest = JSON.parse(fs.readFileSync(path.join(__dirname, '../src/package.json'), 'utf8'));
expect(manifest.scripts['canary:stagenet'])
.toBe('node scripts/stagenet-financial-canary.js');
});
});
+32 -2
View File
@@ -27,15 +27,19 @@ describe('TavernMatchBridge', () => {
return {
id,
economy: 'free',
variant: 'race',
tickCount: 5,
status: 'active',
rows: 2,
cols: 3,
occupants: new Map([['a', { id: 'a', x: 1, y: 2, name: 'A', avatar: 'default', facing: 'down' }]]),
playerStates: new Map([['a', { alive: true, finished: false, escaped: false, hasTreasure: false, placement: null }]]),
monster: { x: 3, y: 4 },
treasure: null,
dungeon: { exit: [10, 10] },
dungeon: { map: [["'1", "'1", '#'], ['#', "'1", '>']], entrance: [0, 0], exit: [2, 1] },
winnerId: null,
seedHash: 'deadbeef'
seedHash: 'deadbeef',
seed: 'never-publish-this'
};
}
@@ -65,4 +69,30 @@ describe('TavernMatchBridge', () => {
expect(list[0].matchId).toBe('m3');
expect(list[0].players.length).toBe(1);
});
test('public snapshots include a renderer-safe map but never the unrevealed seed', () => {
const room = makeRoom('m-render');
room.toGameState = () => ({
visibleTiles: room.dungeon.map,
exploredTiles: room.dungeon.map,
lighting: {},
dungeonRows: 2,
dungeonCols: 3,
players: [{ id: 'a', x: 1, y: 1, alive: true }],
monster: { x: 0, y: 1 },
treasure: { x: 1, y: 0, carrierId: null },
entrance: [0, 0],
exit: [2, 1],
seed: room.seed,
privateEvents: [{ input: 'left' }]
});
const { bridge } = makeBridge({ rooms: [room] });
const snap = bridge.getActiveMatches()[0];
expect(snap.visibleTiles).toEqual(room.dungeon.map);
expect(snap.exploredTiles).toEqual(room.dungeon.map);
expect(snap).toMatchObject({ dungeonRows: 2, dungeonCols: 3, seedHash: 'deadbeef' });
expect(snap.seed).toBeUndefined();
expect(snap.privateEvents).toBeUndefined();
});
});
+14 -1
View File
@@ -14,7 +14,7 @@ describe('renderVerifyPage social card meta', () => {
ogImage: 'og-card-xmr.png'
});
expect(html).toContain('property="og:title"');
expect(html).toContain('Monero Roguelike — Provably-Fair Roguelike');
expect(html).toContain('Monero Roguelike — Dungeon Layout Verification');
expect(html).toContain('<meta property="og:image" content="https://monerogue.app/og-card-xmr.png">');
expect(html).toContain('content="summary_large_image"');
expect(html).toContain('<meta property="og:url" content="https://monerogue.app/verify/seedABC">');
@@ -39,4 +39,17 @@ describe('renderVerifyPage social card meta', () => {
expect(html).not.toContain('<script>x');
expect(html).toContain('&quot;&gt;&lt;script&gt;x');
});
test('renders API verification results with text nodes, not dynamic innerHTML', () => {
const html = renderVerifyPage('seed-safe', null, { gameName: 'Wownerogue' });
expect(html).not.toMatch(/result\.innerHTML\s*=/);
expect(html).toMatch(/code\.textContent\s*=/);
expect(html).toMatch(/showFailure\(data\.message \|\| data\.error/);
});
test('states the proof scope without claiming to replay outcomes or payouts', () => {
const html = renderVerifyPage('seed-scope', null, { gameName: 'Wownerogue' });
expect(html).toContain('recorded dungeon layouts');
expect(html).toContain('does not replay player input, block timing, monster turns, the reported outcome, or payout delivery');
});
});
+145
View File
@@ -0,0 +1,145 @@
const WalletRPCService = require('../src/payments/walletRPCService');
const ADDRESSES = Object.freeze({
'XMR:mainnet': `4${'1'.repeat(94)}`,
'XMR:stagenet': `5${'1'.repeat(94)}`,
'XMR:testnet': `9${'1'.repeat(94)}`,
'WOW:mainnet': `Wo${'1'.repeat(95)}`
});
function walletHttp({ address, network, height = 123, valid = true }) {
return {
post: jest.fn(async (_url, body) => {
if (body.method === 'get_version') return { data: { result: { version: 1 } } };
if (body.method === 'get_height') return { data: { result: { height } } };
if (body.method === 'get_address') return { data: { result: { address } } };
if (body.method === 'validate_address') {
return { data: { result: { valid, nettype: network } } };
}
if (body.method === 'create_address') {
return { data: { result: { address, address_index: 1 } } };
}
throw new Error(`unexpected method: ${body.method}`);
})
};
}
describe('WalletRPCService production identity', () => {
const debugManager = { CONSOLE_LOGGING: false };
test.each([
['XMR', 'mainnet'],
['XMR', 'stagenet'],
['XMR', 'testnet'],
['WOW', 'mainnet']
])('accepts a validated %s %s primary address', async (cryptoType, network) => {
const address = ADDRESSES[`${cryptoType}:${network}`];
const service = new WalletRPCService(debugManager, {
http: walletHttp({ address, network }),
walletEndpoint: 'http://wallet.test',
cryptoType,
network,
identityRequired: true
});
await expect(service.initialize()).resolves.toBe(true);
expect(service.getIdentityStatus()).toMatchObject({
required: true,
verified: true,
expected: { cryptoType, network },
actual: { cryptoType, network }
});
expect(JSON.stringify(service.getHealthStatus())).not.toContain(address);
});
test.each([
['wrong chain', 'WOW', 'mainnet', ADDRESSES['XMR:mainnet'], 'mainnet'],
['wrong network', 'XMR', 'mainnet', ADDRESSES['XMR:stagenet'], 'stagenet']
])('fails initialization for the %s without logging the address', async (
_label, cryptoType, expectedNetwork, address, actualNetwork
) => {
const error = jest.spyOn(console, 'error').mockImplementation(() => {});
const service = new WalletRPCService(debugManager, {
http: walletHttp({ address, network: actualNetwork }),
walletEndpoint: 'http://wallet.test',
cryptoType,
network: expectedNetwork,
identityRequired: true
});
await expect(service.initialize()).resolves.toBe(false);
expect(service.getIdentityStatus().verified).toBe(false);
expect(error.mock.calls.flat().join(' ')).not.toContain(address);
error.mockRestore();
});
test('blocks money-changing calls when the configured identity does not match', async () => {
const http = walletHttp({
address: ADDRESSES['XMR:mainnet'],
network: 'mainnet'
});
const service = new WalletRPCService(debugManager, {
http,
walletEndpoint: 'http://wallet.test',
cryptoType: 'WOW',
network: 'mainnet',
identityRequired: true
});
const error = jest.spyOn(console, 'error').mockImplementation(() => {});
await expect(service.createPaymentRequest(1, 'entry', 'user', 'socket'))
.rejects.toMatchObject({ code: 'WALLET_IDENTITY_MISMATCH' });
expect(http.post.mock.calls.map(([, body]) => body.method)).not.toContain('create_address');
expect(error.mock.calls.flat().join(' ')).not.toContain(ADDRESSES['XMR:mainnet']);
error.mockRestore();
});
test('development may connect for diagnostics but remains explicitly unverified', async () => {
const service = new WalletRPCService(debugManager, {
http: walletHttp({ address: ADDRESSES['XMR:stagenet'], network: 'stagenet' }),
walletEndpoint: 'http://wallet.test',
cryptoType: 'XMR',
network: 'mainnet',
identityRequired: false
});
await expect(service.initialize()).resolves.toBe(true);
expect(service.getIdentityStatus()).toMatchObject({ required: false, verified: false });
});
test('a transport-only liveness response cannot revive stale production identity', async () => {
const service = new WalletRPCService(debugManager, {
http: walletHttp({ address: ADDRESSES['XMR:stagenet'], network: 'stagenet' }),
walletEndpoint: 'http://wallet.test',
cryptoType: 'XMR',
network: 'stagenet',
identityRequired: true,
identityMaxAgeMs: 1000
});
await expect(service.initialize()).resolves.toBe(true);
service.networkIdentity.checkedAt = Date.now() - 2000;
service.isHealthy = false;
await expect(service.rpcCall('get_version')).resolves.toBeDefined();
expect(service.isHealthy).toBe(false);
expect(service.getHealthStatus().healthy).toBe(false);
expect(service.getIdentityStatus().verified).toBe(false);
});
test('rejects a zero-height wallet and redacts URL credentials from health', async () => {
const error = jest.spyOn(console, 'error').mockImplementation(() => {});
const service = new WalletRPCService(debugManager, {
http: walletHttp({ address: ADDRESSES['XMR:stagenet'], network: 'stagenet', height: 0 }),
walletEndpoint: 'http://rpc-user:rpc-password@wallet.test',
cryptoType: 'XMR',
network: 'stagenet',
identityRequired: true
});
await expect(service.initialize()).resolves.toBe(false);
expect(service.getHealthStatus().endpoint).toBe('http://wallet.test');
expect(JSON.stringify(service.getHealthStatus())).not.toMatch(/rpc-user|rpc-password/);
error.mockRestore();
});
});
@@ -0,0 +1,70 @@
'use strict';
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const deployPath = name => path.join(__dirname, '..', 'scripts', 'deploy', name);
const deploy = name => fs.readFileSync(deployPath(name), 'utf8');
describe('parallel Monero stagenet wallet candidate boundary', () => {
const preflight = deploy('monero-stagenet-parallel-preflight.py');
const unit = deploy('monerogue-stagenet-wallet-candidate.service');
const runbook = deploy('README-monero-stagenet-parallel-candidate.md');
test('preflight is fixed, read-only, and does not inspect password-bearing sources', () => {
expect(preflight).toContain('LEGACY_UNIT = "monero-wallet-rpc.service"');
expect(preflight).toContain('LEGACY_RPC_PORT = 38083');
expect(preflight).toContain('CANDIDATE_RPC_PORT = 38084');
expect(preflight).toContain('VERIFY_RPC_PORT = 39084');
expect(preflight).toContain('PR_SET_DUMPABLE');
expect(preflight).toContain('O_NOFOLLOW');
expect(preflight).toContain('details.st_nlink != 1');
expect(preflight).not.toContain('/cmdline');
expect(preflight).not.toContain('ExecStart');
expect(preflight).not.toContain('wallet-password');
expect(preflight).not.toMatch(/systemctl\([^\n]*(?:stop|start|restart|enable)/);
expect(preflight).not.toMatch(/os\.(?:mkdir|unlink|remove|rename|chmod|chown|replace)\(/);
expect(preflight).not.toContain('os.O_WRONLY');
expect(preflight).not.toContain('mainnet');
});
test('candidate unit is a manual inert service with no live-app dependency', () => {
expect(unit).toContain('User=monerogue-wallet-candidate');
expect(unit).toContain('Group=monerogue-wallet-candidate');
expect(unit).toContain('SupplementaryGroups=');
expect(unit).toContain('ConditionPathExists=/var/lib/monerogue-stagenet-wallet-candidate/READY');
expect(unit).toContain('--config-file /var/lib/monerogue-stagenet-wallet-candidate/config/wallet-rpc.conf');
expect(unit).toContain('--log-file /dev/null');
expect(unit).toContain('Restart=no');
expect(unit).toContain('LimitCORE=0');
expect(unit).toContain('ProtectSystem=strict');
expect(unit).not.toContain('[Install]');
expect(unit).not.toContain('WantedBy=');
expect(unit).not.toContain('monerogue.service');
expect(unit).not.toContain('38083');
expect(unit).not.toContain('mainnet');
});
test('runbook keeps host mutation and production deployment at NO-GO', () => {
expect(runbook).toContain('STATUS: DESIGN / NO-GO FOR HOST MUTATION OR PRODUCTION DEPLOYMENT');
expect(runbook).toContain('never alters the legacy artifacts');
expect(runbook).toContain('renameat2(RENAME_NOREPLACE)');
expect(runbook).toContain('write `READY` **last**');
expect(runbook).toContain('production-deployable answer is **no**');
expect(runbook).toContain('mnemonic/off-host recovery custody is resolved separately');
});
test('disposable filesystem fixture passes independently', () => {
const result = spawnSync(
'/usr/bin/python3',
[deployPath('monero-stagenet-parallel-fixture-test.py')],
{ encoding: 'utf8', timeout: 15000 }
);
expect({ status: result.status, signal: result.signal, stderr: result.stderr }).toEqual({
status: 0,
signal: null,
stderr: expect.stringContaining('OK')
});
});
});
+236 -16
View File
@@ -3,6 +3,34 @@ const WalletRPCService = require('../src/payments/walletRPCService');
// Minimal debugManager mock
const debugManagerMock = { CONSOLE_LOGGING: false };
function confirmedTransfer({ txid, amount, height, minor, major = 0, confirmations = 1,
doubleSpend = false, unlockTime = 0 }) {
return {
txid,
amount,
height,
confirmations,
double_spend_seen: doubleSpend,
unlock_time: unlockTime,
subaddr_index: { major, minor }
};
}
function incomingOutput({ txHash, amount, blockHeight, minor, major = 0, pubkey,
globalIndex, frozen = false, spent = false, unlocked = false }) {
return {
tx_hash: txHash,
amount,
block_height: blockHeight,
frozen,
global_index: globalIndex,
pubkey,
spent,
subaddr_index: { major, minor },
unlocked
};
}
describe('WalletRPCService interface', () => {
test('exposes expected methods', () => {
@@ -25,6 +53,17 @@ describe('WalletRPCService interface', () => {
await expect(svc.processBatchPayout([])).rejects.toThrow(/destinations/i);
});
test('the injected payout gate blocks every transfer entrypoint before wallet RPC', async () => {
const svc = new WalletRPCService(debugManagerMock, { transferAllowed: () => false });
svc.rpcCall = jest.fn();
await expect(svc.processPayout({ address: '5'.repeat(95), amount: 1 }))
.rejects.toMatchObject({ code: 'PAYOUT_DISPATCH_DISABLED', statusCode: 503 });
await expect(svc.processBatchPayout([{ address: '5'.repeat(95), amount: 1 }]))
.rejects.toMatchObject({ code: 'PAYOUT_DISPATCH_DISABLED', statusCode: 503 });
expect(svc.rpcCall).not.toHaveBeenCalled();
});
test('rpcCall wraps failures in ExternalServiceError', async () => {
const svc = new WalletRPCService(debugManagerMock);
svc.walletEndpoint = 'http://127.0.0.1:65535';
@@ -73,10 +112,10 @@ describe('WalletRPCService.normalizeAtomicAmount', () => {
expect(result).toBe(largeValue.toString());
});
test('truncates floating point numbers to integers', () => {
expect(svc.normalizeAtomicAmount(100.9)).toBe(100);
expect(svc.normalizeAtomicAmount(100.1)).toBe(100);
expect(svc.normalizeAtomicAmount(99.9999)).toBe(99);
test('rejects fractional atomic-unit numbers', () => {
expect(() => svc.normalizeAtomicAmount(100.9)).toThrow();
expect(() => svc.normalizeAtomicAmount(100.1)).toThrow();
expect(() => svc.normalizeAtomicAmount(99.9999)).toThrow();
});
test('throws on zero amount', () => {
@@ -154,7 +193,7 @@ describe('WalletRPCService.processPayout', () => {
svc.validateAddress = jest.fn().mockResolvedValue({ valid: true });
svc.rpcCall = jest.fn().mockResolvedValueOnce({
result: {
tx_hash_list: ['abc123def456'],
tx_hash_list: ['a'.repeat(64)],
tx_key_list: ['key789'],
fee_list: [1000000]
}
@@ -168,7 +207,7 @@ describe('WalletRPCService.processPayout', () => {
});
expect(result.success).toBe(true);
expect(result.txHash).toBe('abc123def456');
expect(result.txHash).toBe('a'.repeat(64));
expect(result.txKey).toBe('key789');
expect(result.fee).toBe(1000000);
expect(result.userId).toBe('user1');
@@ -189,7 +228,7 @@ describe('WalletRPCService.processPayout', () => {
test('includes subaddr_indices when subaddressIndex specified', async () => {
svc.validateAddress = jest.fn().mockResolvedValue({ valid: true });
svc.rpcCall = jest.fn().mockResolvedValueOnce({
result: { tx_hash_list: ['xyz'], tx_key_list: ['key'], fee_list: [100] }
result: { tx_hash_list: ['b'.repeat(64)], tx_key_list: ['key'], fee_list: [100] }
});
await svc.processPayout({
@@ -208,7 +247,7 @@ describe('WalletRPCService.processPayout', () => {
test('does not include subaddr_indices for invalid index values', async () => {
svc.validateAddress = jest.fn().mockResolvedValue({ valid: true });
svc.rpcCall = jest.fn().mockResolvedValueOnce({
result: { tx_hash_list: ['xyz'], tx_key_list: ['key'], fee_list: [100] }
result: { tx_hash_list: ['c'.repeat(64)], tx_key_list: ['key'], fee_list: [100] }
});
await svc.processPayout({
@@ -412,20 +451,23 @@ describe('WalletRPCService.checkPaymentStatus', () => {
confirmed: false
});
// Mock RPC calls
// Contract-shaped wallet RPC calls: get_transfers has transaction metadata but no output
// identity; incoming_transfers has pubkey/global_index and uses tx_hash/block_height names.
svc.rpcCall = jest.fn()
.mockResolvedValueOnce({
result: {
pool: [],
in: [{
txid: 'txhash123',
amount: 100000000000,
height: 1000
}]
in: [confirmedTransfer({
txid: 'd'.repeat(64), amount: 100000000000, height: 1000,
confirmations: 5, minor: 5
})]
}
})
.mockResolvedValueOnce({
result: { height: 1005 } // Current height for confirmations
result: { transfers: [incomingOutput({
txHash: 'd'.repeat(64), pubkey: '1'.repeat(64), globalIndex: 12345,
amount: 100000000000, blockHeight: 1000, minor: 5
})] }
});
const status = await svc.checkPaymentStatus('wow1testaddr');
@@ -434,7 +476,185 @@ describe('WalletRPCService.checkPaymentStatus', () => {
expect(status.confirmed).toBe(true);
expect(status.amount).toBe(100000000000);
expect(status.confirmations).toBe(5); // 1005 - 1000
expect(status.txHash).toBe('txhash123');
expect(status.txHash).toBe('d'.repeat(64));
expect(status.receipts).toEqual([expect.objectContaining({
txHash: 'd'.repeat(64),
outputId: '1'.repeat(64),
evidenceId: `${'d'.repeat(64)}:${'1'.repeat(64)}`,
addressIndex: 5,
amount: '100000000000',
confirmed: true
})]);
expect(svc.rpcCall).toHaveBeenNthCalledWith(2, 'incoming_transfers', {
transfer_type: 'all', account_index: 0, subaddr_indices: [5]
});
});
test('does not combine a confirmed dust transfer with an unconfirmed pool top-up', async () => {
svc.addressToUser.set('wow1mixed', {
userId: 'user1', socketId: 'sock1', amount: 1000,
addressIndex: 7, accountIndex: 0, detected: false, confirmed: false
});
svc.rpcCall = jest.fn()
.mockResolvedValueOnce({ result: {
pool: [{ txid: 'e'.repeat(64), amount: 999, subaddr_index: { major: 0, minor: 7 } }],
in: [confirmedTransfer({ txid: 'f'.repeat(64), amount: 1, height: 200,
confirmations: 1, minor: 7 })]
} })
.mockResolvedValueOnce({ result: { transfers: [incomingOutput({
txHash: 'f'.repeat(64), pubkey: '2'.repeat(64), globalIndex: 200,
amount: 1, blockHeight: 200, minor: 7
})] } });
const status = await svc.checkPaymentStatus('wow1mixed');
expect(status.confirmed).toBe(true);
expect(status.in_mempool).toBe(true);
expect(status.complete).toBe(false);
expect(status.amount).toBe(1);
expect(status.pendingAmount).toBe(999);
expect(status.observedAmount).toBe(1000);
expect(status.receipts).toHaveLength(1);
expect(status.receipts[0].txHash).toBe('f'.repeat(64));
});
test('returns every confirmed top-up receipt in deterministic order', async () => {
svc.addressToUser.set('wow1topups', {
userId: 'user1', socketId: 'sock1', amount: 1000,
addressIndex: 8, accountIndex: 0, detected: false, confirmed: false
});
svc.rpcCall = jest.fn()
.mockResolvedValueOnce({ result: { pool: [], in: [
confirmedTransfer({ txid: 'b'.repeat(64), amount: 400, height: 301,
confirmations: 3, minor: 8 }),
confirmedTransfer({ txid: 'a'.repeat(64), amount: 600, height: 302,
confirmations: 2, minor: 8 })
] } })
.mockResolvedValueOnce({ result: { transfers: [
incomingOutput({ txHash: 'b'.repeat(64), pubkey: '3'.repeat(64), globalIndex: 301,
amount: 400, blockHeight: 301, minor: 8 }),
incomingOutput({ txHash: 'a'.repeat(64), pubkey: '4'.repeat(64), globalIndex: 302,
amount: 600, blockHeight: 302, minor: 8 })
] } });
const status = await svc.checkPaymentStatus('wow1topups');
expect(status.complete).toBe(true);
expect(status.amount).toBe(1000);
expect(status.receipts.map(receipt => receipt.txHash)).toEqual([
'a'.repeat(64),
'b'.repeat(64)
]);
expect(status.confirmations).toBe(2);
});
test('counts distinct same-transaction outputs to one subaddress and ignores output replay', async () => {
const txid = '9'.repeat(64);
const firstPubkey = '5'.repeat(64);
const secondPubkey = '6'.repeat(64);
svc.addressToUser.set('wow1sameoutputscope', {
userId: 'user1', socketId: 'sock1', amount: 100,
addressIndex: 13, accountIndex: 0, detected: false, confirmed: false
});
svc.rpcCall = jest.fn()
.mockResolvedValueOnce({ result: { pool: [], in: [confirmedTransfer({
txid, amount: 100, height: 400, confirmations: 4, minor: 13
})] } })
.mockResolvedValueOnce({ result: { transfers: [
incomingOutput({ txHash: txid, pubkey: firstPubkey, globalIndex: 401,
amount: 40, blockHeight: 400, minor: 13 }),
incomingOutput({ txHash: txid, pubkey: secondPubkey, globalIndex: 402,
amount: 60, blockHeight: 400, minor: 13 }),
incomingOutput({ txHash: txid, pubkey: firstPubkey, globalIndex: 401,
amount: 40, blockHeight: 400, minor: 13 })
] } });
const status = await svc.checkPaymentStatus('wow1sameoutputscope');
expect(status.complete).toBe(true);
expect(status.amount).toBe(100);
expect(status.receipts).toHaveLength(2);
expect(status.receipts.map(receipt => receipt.outputId)).toEqual([firstPubkey, secondPubkey]);
});
test('fails closed when a confirmed transfer has no stable output identifier', async () => {
svc.addressToUser.set('wow1missingoutputid', {
userId: 'user1', socketId: 'sock1', amount: 100,
addressIndex: 14, accountIndex: 0, detected: false, confirmed: false
});
svc.rpcCall = jest.fn()
.mockResolvedValueOnce({ result: { pool: [], in: [confirmedTransfer({
txid: '8'.repeat(64), amount: 100, height: 500, confirmations: 1, minor: 14
})] } })
.mockResolvedValueOnce({ result: { transfers: [incomingOutput({
txHash: '8'.repeat(64), amount: 100, blockHeight: 500, minor: 14
})] } });
const status = await svc.checkPaymentStatus('wow1missingoutputid');
expect(status.complete).toBe(false);
expect(status.confirmed).toBe(false);
expect(status.amount).toBe(0);
expect(status.receipts).toEqual([]);
});
test('ignores double-spend-marked and explicitly timelocked incoming transfers', async () => {
svc.addressToUser.set('wow1unsafeoutputs', {
userId: 'user1', socketId: 'sock1', amount: 100,
addressIndex: 15, accountIndex: 0, detected: false, confirmed: false
});
svc.rpcCall = jest.fn()
.mockResolvedValueOnce({ result: { pool: [], in: [
confirmedTransfer({ txid: '7'.repeat(64), amount: 100, height: 600,
confirmations: 2, minor: 15, doubleSpend: true }),
confirmedTransfer({ txid: '6'.repeat(64), amount: 100, height: 601,
confirmations: 2, minor: 15, unlockTime: 42 }),
confirmedTransfer({ txid: '5'.repeat(64), amount: 100, height: 602,
confirmations: 2, minor: 15, unlockTime: Number.MAX_SAFE_INTEGER + 1 })
] } })
.mockResolvedValueOnce({ result: { transfers: [
incomingOutput({ txHash: '7'.repeat(64), pubkey: 'a'.repeat(64), globalIndex: 600,
amount: 100, blockHeight: 600, minor: 15 }),
incomingOutput({ txHash: '6'.repeat(64), pubkey: 'b'.repeat(64), globalIndex: 601,
amount: 100, blockHeight: 601, minor: 15 }),
incomingOutput({ txHash: '5'.repeat(64), pubkey: 'c'.repeat(64), globalIndex: 602,
amount: 100, blockHeight: 602, minor: 15 })
] } });
const status = await svc.checkPaymentStatus('wow1unsafeoutputs');
expect(status.complete).toBe(false);
expect(status.confirmed).toBe(false);
expect(status.amount).toBe(0);
expect(status.receipts).toEqual([]);
});
test('requires the configured minimum confirmations and rejects frozen output evidence', async () => {
svc = new WalletRPCService(debugManagerMock, { minConfirmations: 2 });
svc.addressToUser.set('wow1depth', {
userId: 'user1', socketId: 'sock1', amount: 100,
addressIndex: 16, accountIndex: 0, detected: false, confirmed: false
});
svc.rpcCall = jest.fn()
.mockResolvedValueOnce({ result: { pool: [], in: [
confirmedTransfer({ txid: '4'.repeat(64), amount: 50, height: 700,
confirmations: 1, minor: 16 }),
confirmedTransfer({ txid: '3'.repeat(64), amount: 50, height: 699,
confirmations: 2, minor: 16 })
] } })
.mockResolvedValueOnce({ result: { transfers: [
incomingOutput({ txHash: '4'.repeat(64), pubkey: 'd'.repeat(64), globalIndex: 700,
amount: 50, blockHeight: 700, minor: 16 }),
incomingOutput({ txHash: '3'.repeat(64), pubkey: 'e'.repeat(64), globalIndex: 699,
amount: 50, blockHeight: 699, minor: 16, frozen: true })
] } });
const status = await svc.checkPaymentStatus('wow1depth');
expect(status).toEqual(expect.objectContaining({
complete: false, confirmed: false, amount: 0, confirmations: 0
}));
expect(status.receipts).toEqual([]);
});
});
+116
View File
@@ -0,0 +1,116 @@
const WalletRPCService = require('../src/payments/walletRPCService');
describe('WalletRPCService HTTP authentication', () => {
const originalUser = process.env.WALLET_RPC_USER;
const originalPassword = process.env.WALLET_RPC_PASSWORD;
beforeEach(() => {
delete process.env.WALLET_RPC_USER;
delete process.env.WALLET_RPC_PASSWORD;
});
afterAll(() => {
if (originalUser === undefined) delete process.env.WALLET_RPC_USER;
else process.env.WALLET_RPC_USER = originalUser;
if (originalPassword === undefined) delete process.env.WALLET_RPC_PASSWORD;
else process.env.WALLET_RPC_PASSWORD = originalPassword;
});
test('answers a wallet RPC Digest challenge with the configured credential', async () => {
process.env.WALLET_RPC_USER = 'rpc-user';
process.env.WALLET_RPC_PASSWORD = 'rpc-password';
const unauthorized = new Error('unauthorized');
unauthorized.response = {
status: 401,
headers: {
'www-authenticate': 'Digest realm="monero-rpc", nonce="abc123", qop="auth", opaque="opaque1", algorithm=MD5'
}
};
const http = { post: jest.fn()
.mockRejectedValueOnce(unauthorized)
.mockResolvedValueOnce({ data: { result: { height: 1 } } }) };
const service = new WalletRPCService({ CONSOLE_LOGGING: false }, { http });
await service.rpcCall('get_height');
expect(http.post).toHaveBeenNthCalledWith(2,
expect.stringContaining('/json_rpc'),
expect.objectContaining({ method: 'get_height' }),
expect.objectContaining({
headers: expect.objectContaining({
Authorization: expect.stringMatching(/^Digest .*username="rpc-user".*realm="monero-rpc".*nonce="abc123".*qop=auth/)
})
})
);
});
test('omits authentication for a loopback no-login service', async () => {
const http = { post: jest.fn().mockResolvedValue({ data: { result: { height: 1 } } }) };
const service = new WalletRPCService({ CONSOLE_LOGGING: false }, { http });
await service.rpcCall('get_height');
expect(http.post).toHaveBeenCalledTimes(1);
expect(http.post.mock.calls[0][2].headers).not.toHaveProperty('Authorization');
});
test('production refuses a wallet that accepts the unauthenticated probe', async () => {
const http = { post: jest.fn().mockResolvedValue({ data: { result: { version: 1 } } }) };
const service = new WalletRPCService({ CONSOLE_LOGGING: false }, {
http,
rpcUser: 'rpc-user',
rpcPassword: 'rpc-password',
authRequired: true
});
await expect(service.ensureAuthentication({ force: true }))
.rejects.toMatchObject({ code: 'WALLET_RPC_AUTH_NOT_ENFORCED', statusCode: 503 });
expect(service.getHealthStatus()).toMatchObject({
healthy: false,
authentication: { required: true, verified: false, reason: 'not_enforced' }
});
});
test('production proves both the unauthenticated challenge and an authenticated response', async () => {
const challenge = () => {
const error = new Error('unauthorized');
error.response = {
status: 401,
headers: {
'www-authenticate': 'Digest realm="monero-rpc", nonce="abc123", qop="auth", algorithm=MD5'
}
};
return error;
};
const http = { post: jest.fn(async (_url, _body, config) => {
if (!config?.headers?.Authorization) throw challenge();
return { data: { result: { version: 1 } } };
}) };
const service = new WalletRPCService({ CONSOLE_LOGGING: false }, {
http,
rpcUser: 'rpc-user',
rpcPassword: 'rpc-password',
authRequired: true
});
await expect(service.ensureAuthentication({ force: true })).resolves.toMatchObject({
verified: true,
reason: null
});
expect(http.post).toHaveBeenCalledTimes(2);
expect(http.post.mock.calls[1][2].headers.Authorization).toMatch(/^Digest /);
expect(JSON.stringify(service.getHealthStatus())).not.toMatch(/rpc-user|rpc-password/);
});
test('production refuses missing wallet credentials without contacting RPC', async () => {
const http = { post: jest.fn() };
const service = new WalletRPCService({ CONSOLE_LOGGING: false }, {
http,
authRequired: true
});
await expect(service.ensureAuthentication({ force: true }))
.rejects.toMatchObject({ code: 'WALLET_RPC_AUTH_UNVERIFIED', statusCode: 503 });
expect(http.post).not.toHaveBeenCalled();
});
});
+206
View File
@@ -0,0 +1,206 @@
const fs = require('fs');
const path = require('path');
const vm = require('vm');
function makeElement(tag = 'div') {
return {
tagName: tag.toUpperCase(),
style: {},
children: [],
listeners: {},
capturedPointers: new Set(),
appendChild(child) { this.children.push(child); child.parentNode = this; },
removeChild(child) { this.children = this.children.filter(c => c !== child); child.parentNode = null; },
addEventListener(name, fn) { (this.listeners[name] ||= []).push(fn); },
removeEventListener(name, fn) {
this.listeners[name] = (this.listeners[name] || []).filter(listener => listener !== fn);
},
dispatch(name, event = {}) {
event.target ||= this;
for (const listener of [...(this.listeners[name] || [])]) listener(event);
},
setAttribute() {},
querySelector() { return null; },
setPointerCapture(pointerId) { this.capturedPointers.add(pointerId); },
releasePointerCapture(pointerId) { this.capturedPointers.delete(pointerId); }
};
}
function loadKit(ResizeObserver) {
const document = { createElement: makeElement };
const context = { document, console, Math };
if (ResizeObserver) context.ResizeObserver = ResizeObserver;
context.window = context;
vm.createContext(context);
vm.runInContext(fs.readFileSync(path.join(__dirname, '../html/js/render/zoomControl.js'), 'utf8'), context);
return context.RK;
}
describe('render-kit viewport camera', () => {
test('centers a focus point and clamps the view at map edges', () => {
const RK = loadKit();
const host = makeElement();
host.clientWidth = 300;
host.clientHeight = 200;
const canvas = makeElement('canvas');
canvas.width = 600;
canvas.height = 400;
const renderer = { name: 'tiles', canvas, focusPoint: { x: 500, y: 300 } };
const camera = RK.attachCamera(host, { zoom: 1, min: 0.5, controls: false });
camera.update(renderer);
expect(canvas.style.transform).toBe('translate(-300px,-200px) scale(1)');
renderer.focusPoint = { x: 300, y: 200 };
camera.update(renderer);
expect(canvas.style.transform).toBe('translate(-150px,-100px) scale(1)');
camera.setZoom(0.5);
expect(canvas.style.transform).toBe('translate(0px,0px) scale(0.5)');
});
test('delegates projection zoom and drag pan to the native 3D camera', () => {
const RK = loadKit();
const host = makeElement();
host.clientWidth = 300;
host.clientHeight = 200;
const setZoom = jest.fn();
const setPan = jest.fn();
const renderer = { name: '3d', setZoom, setPan };
const camera = RK.attachCamera(host, { zoom: 1.2, controls: false });
camera.update(renderer);
expect(setZoom).toHaveBeenLastCalledWith(1.2);
expect(setPan).toHaveBeenLastCalledWith(0, 0);
host.dispatch('pointerdown', { pointerId: 1, clientX: 10, clientY: 20 });
host.dispatch('pointermove', { pointerId: 1, clientX: 34, clientY: 31 });
expect(setPan).toHaveBeenLastCalledWith(24, 11);
camera.setZoom(1.5);
expect(setZoom).toHaveBeenLastCalledWith(1.5);
camera.reset();
expect(setPan).toHaveBeenLastCalledWith(0, 0);
});
test('fits a compact room to a responsive share of the viewport and restores that fit', () => {
const RK = loadKit();
const host = makeElement();
host.clientWidth = 900;
host.clientHeight = 550;
const canvas = makeElement('canvas');
canvas.width = 288;
canvas.height = 168;
const renderer = { name: 'tiles', canvas, focusPoint: { x: 144, y: 84 } };
const camera = RK.attachCamera(host, { zoom: 1.25, fitRatio: 0.75, min: 0.5, max: 3.2, controls: false });
camera.fit(renderer);
expect(camera.getZoom()).toBeCloseTo(2.34375);
expect(canvas.style.transform).toBe('translate(112.5px,78.125px) scale(2.34375)');
camera.setZoom(1.1);
camera.reset();
expect(camera.getZoom()).toBeCloseTo(2.34375);
});
test('pans with one pointer and pinches with two pointers', () => {
const RK = loadKit();
const host = makeElement();
host.clientWidth = 300;
host.clientHeight = 200;
const canvas = makeElement('canvas');
canvas.width = 600;
canvas.height = 400;
const renderer = { name: 'tiles', canvas, focusPoint: { x: 300, y: 200 } };
const camera = RK.attachCamera(host, { zoom: 1, min: 0.5, max: 3, controls: false });
camera.update(renderer);
host.dispatch('pointerdown', { pointerId: 1, clientX: 0, clientY: 0 });
host.dispatch('pointermove', { pointerId: 1, clientX: 20, clientY: 10 });
expect(canvas.style.transform).toBe('translate(-130px,-90px) scale(1)');
expect(host.capturedPointers.has(1)).toBe(true);
host.dispatch('pointerdown', { pointerId: 2, clientX: 100, clientY: 10 });
host.dispatch('pointermove', { pointerId: 2, clientX: 100, clientY: 10 });
host.dispatch('pointermove', { pointerId: 2, clientX: 180, clientY: 10 });
expect(camera.getZoom()).toBeCloseTo(2);
host.dispatch('pointerup', { pointerId: 1 });
host.dispatch('pointercancel', { pointerId: 2 });
expect(host.style.cursor).toBe('grab');
});
test('resize auto-fit follows the current renderer after a renderer switch', () => {
let observer;
class FakeResizeObserver {
constructor(callback) { this.callback = callback; observer = this; }
observe = jest.fn();
disconnect = jest.fn();
}
const RK = loadKit(FakeResizeObserver);
const host = makeElement();
host.clientWidth = 900;
host.clientHeight = 600;
const firstCanvas = makeElement('canvas');
firstCanvas.width = 300;
firstCanvas.height = 200;
const secondCanvas = makeElement('canvas');
secondCanvas.width = 600;
secondCanvas.height = 300;
const first = { name: 'tiles', canvas: firstCanvas };
const second = { name: 'iso', canvas: secondCanvas };
const camera = RK.attachCamera(host, { zoom: 1, fitRatio: 0.75, min: 0.5, max: 3, controls: false });
camera.fit(first);
const firstTransform = firstCanvas.style.transform;
camera.update(second);
host.clientWidth = 600;
host.clientHeight = 300;
observer.callback();
expect(camera.getZoom()).toBeCloseTo(0.75);
expect(secondCanvas.style.transform).toBe('translate(75px,37.5px) scale(0.75)');
expect(firstCanvas.style.transform).toBe(firstTransform);
});
test('destroy removes host and control listeners, disconnects resize, and permits clean reattachment', () => {
let observer;
class FakeResizeObserver {
constructor(callback) { this.callback = callback; observer = this; }
observe = jest.fn();
disconnect = jest.fn();
}
const RK = loadKit(FakeResizeObserver);
const host = makeElement();
host.clientWidth = 300;
host.clientHeight = 200;
const nativeSetZoom = jest.fn();
const camera = RK.attachCamera(host, { zoom: 1.2 });
camera.update({ name: '3d', setZoom: nativeSetZoom });
const controls = host.children[0];
const buttons = controls.children.filter(child => child.tagName === 'BUTTON');
host.dispatch('pointerdown', { pointerId: 7, clientX: 10, clientY: 10 });
expect(host.capturedPointers.has(7)).toBe(true);
expect(Object.values(host.listeners).flat()).toHaveLength(6);
expect(buttons.every(button => button.listeners.click.length === 1)).toBe(true);
camera.destroy();
expect(Object.values(host.listeners).flat()).toHaveLength(0);
expect(buttons.every(button => button.listeners.click.length === 0)).toBe(true);
expect(host.children).not.toContain(controls);
expect(host.capturedPointers.size).toBe(0);
expect(observer.disconnect).toHaveBeenCalledTimes(1);
expect(host._rkCamera).toBeUndefined();
const callCount = nativeSetZoom.mock.calls.length;
host.dispatch('wheel', { deltaY: -100, preventDefault: jest.fn() });
observer.callback(); // A queued callback after disconnect must also be inert.
expect(nativeSetZoom).toHaveBeenCalledTimes(callCount);
const replacement = RK.attachCamera(host, { zoom: 1.5, controls: false });
expect(replacement).not.toBe(camera);
camera.destroy(); // Stale/double teardown must not detach the replacement camera.
expect(RK.attachCamera(host, { controls: false })).toBe(replacement);
replacement.destroy();
});
});