mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-17 14:21:25 +02:00
A browser-based roguelike synchronized with crypto block timing. A run is a race against the next block: reach the exit before the block lands, or the house wins. The same codebase runs the solo dungeon, the social Tavern, and operator-selected PvP and race rulesets, with separate free and paid leaderboards. Self-hostable under the MIT License. See README.md to run it, docs/ARCHITECTURE.md for the design, and docs/DEPLOY.md to operate an instance.
234 lines
9.5 KiB
JavaScript
234 lines
9.5 KiB
JavaScript
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);
|
|
});
|
|
});
|