mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-17 22:31:26 +02:00
Adversarial multi-agent review of the launch candidate produced 22 confirmed
findings (12 more were refuted). This closes the critical, high, and the
mediums/lows that are cheap and real, then does the iso/3D/Tavern art pass.
CRITICAL — unauthenticated remote kill switch
Socket.IO does not wrap listener invocation, and this process treats
uncaughtException/unhandledRejection as fatal, so ONE unguarded throw in ANY
listener exits the server. `socket.emit('debug_ping')` with no argument did
exactly that, and systemd restarts straight back into it. Every registration
in handleConnection now goes through a safe-dispatch wrapper that contains
sync throws and rejected promises to the offending socket; handleDebugPing
also stops trusting its payload.
HIGH
- NIP-98 login now binds the signed event to this deployment's host. The path
check alone let an event signed for any other origin authenticate here, and
linking ADOPTS the account owning that pubkey — full account takeover.
- Fog of war is enforced server-side. getState() shipped absolute
entrance/exit/treasure coordinates and the monster's exact position in every
frame, with concealment implemented only in the browser. Discovery is sticky;
the monster is reported only while in view.
- NODE_ENV must now be explicit and recognized. Unset, every production gate
read false while DebugManager reported production and silenced logging.
- Tavern chat applies the lobby's ban list, rate limiter and user_id
attribution before entering global chat.
- userRegistry is deleted by socket.id (it was keyed by socket.id and deleted
by socket.client.id, so nothing was ever freed).
MEDIUM / LOW
- Payouts: a legitimately split transfer_split kept its transaction hashes
instead of quarantining already-broadcast funds with tx_hash NULL, and
provably pre-broadcast failures return to 'pending' rather than needs_review.
- RateLimiter key parsing fixed — cleanup() freed nothing and expired windows
never reset, because ids contain colons.
- Enter no longer submits a paid entry from behind a modal.
- Lobby chat no longer double-escapes server-escaped text.
- Rate limits for identity:get / address:prompt / early_entry.
- Spectators released when a game ends by disconnect, not just settlement.
- movementManager / addressManager per-socket maps cleaned on disconnect.
Renderers
Iso: opaque shading instead of translucent fog, additive torch/lantern/beacon
light pools, crevice AO, embers, cold memory wash, and it stops reallocating
the whole canvas every animation frame.
3D: instanced level geometry (3 draw calls), procedural stone/flagstone
materials, torch lights + shadows, real meshes for stairs/chest/monster
(they were humanoid capsules), dust motes, wall cutaway, and lighting
rebalanced now that fog is measured from the camera.
Tavern
Block height and hash fragments drift around the stage edge and ripple when a
block lands, fed by a cosmetic chain tip added to the blockheight broadcast.
Speech bubbles work again (they keyed off a socketId the server stopped
sending).
126 suites / 1005 tests pass.
348 lines
16 KiB
JavaScript
348 lines
16 KiB
JavaScript
/**
|
|
* Batch payout dispatch tests (Phase 0.3).
|
|
*
|
|
* transfer_split sends ONE on-chain tx to many destinations, so every payout row in a
|
|
* batch shares that tx_hash. These tests guard:
|
|
* - the whole batch is marked completed with the shared tx_hash in one transaction
|
|
* (no row stranded mid-batch by a unique-index collision), and
|
|
* - an ambiguous batch failure is marked 'needs_review' (NOT 'failed'), so the retry
|
|
* service won't re-send and double-pay.
|
|
*/
|
|
|
|
const GameModeManager = require('../src/game/gameModeManager');
|
|
|
|
const createMockDb = () => {
|
|
const mockClient = { query: jest.fn().mockResolvedValue({ rows: [] }) };
|
|
return {
|
|
query: jest.fn().mockResolvedValue({ rows: [] }),
|
|
withTransaction: jest.fn().mockImplementation(async (cb) => cb(mockClient)),
|
|
_mockClient: mockClient
|
|
};
|
|
};
|
|
|
|
const createMockPaymentConfig = () => ({
|
|
getConfig: () => ({
|
|
paymentsEnabled: true,
|
|
currency: { symbol: 'WOW', decimals: 11 },
|
|
modes: {
|
|
direct: { enabled: true, price: 100000000000n },
|
|
credits: { enabled: true, creditsPerGame: 1, packages: [{ id: 'small', credits: 10, price: '500000000000', bonus: 0 }] }
|
|
},
|
|
payouts: {
|
|
rules: {
|
|
direct: { enabled: true, multipliers: { escape: 2, escapeWithTreasure: 3 } },
|
|
credits: { enabled: false, multipliers: { escape: 1.5, escapeWithTreasure: 2 }, baseValue: 50000000000n }
|
|
}
|
|
},
|
|
preferences: { preferCreditsFirst: true },
|
|
earlyEntry: { enabled: false }
|
|
}),
|
|
getLegacyGameMode: () => 'PAID_SINGLE',
|
|
eventBus: { on: () => {} }
|
|
});
|
|
|
|
function buildGmm(walletService) {
|
|
const db = createMockDb();
|
|
const debugManager = { CONSOLE_LOGGING: false, getCurrentBlockHeight: () => 1 };
|
|
const gmm = new GameModeManager(db, walletService, debugManager, createMockPaymentConfig());
|
|
return { gmm, db };
|
|
}
|
|
|
|
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({ success: true, tx_hash_list: [SHARED_TX_HASH], totalFee: 200 }),
|
|
getBalance: jest.fn().mockResolvedValue({ balance: '10000000000000', unlocked_balance: '10000000000000' })
|
|
};
|
|
const { gmm, db } = buildGmm(walletService);
|
|
|
|
// First withTransaction = gather: SELECT returns 2 pending rows, then UPDATE ... processing.
|
|
db._mockClient.query
|
|
.mockResolvedValueOnce({ rows: TWO_PENDING }) // SELECT ... FOR UPDATE SKIP LOCKED
|
|
.mockResolvedValue({ rows: [] }); // everything else
|
|
|
|
await gmm._processPendingPayouts();
|
|
|
|
// The batch RPC was called once with both destinations.
|
|
expect(walletService.processBatchPayout).toHaveBeenCalledTimes(1);
|
|
expect(walletService.processBatchPayout.mock.calls[0][0]).toHaveLength(2);
|
|
|
|
// The completion UPDATE set the shared tx_hash on the whole batch via id = ANY(...).
|
|
const calls = db._mockClient.query.mock.calls.map(c => c[0]);
|
|
const batchUpdate = calls.find(sql => /UPDATE payouts SET tx_hash/i.test(sql) && /id = ANY/i.test(sql));
|
|
expect(batchUpdate).toBeDefined();
|
|
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(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).
|
|
const statUpdates = calls.filter(sql => /UPDATE users SET total_amount_won/i.test(sql));
|
|
expect(statUpdates.length).toBe(2);
|
|
});
|
|
|
|
test('an ambiguous batch failure is marked needs_review, not failed', async () => {
|
|
const walletService = {
|
|
// Enough unlocked balance so the pre-flight check passes and we reach the transfer.
|
|
getBalance: jest.fn().mockResolvedValue({ balance: '10000000000000', unlocked_balance: '10000000000000' }),
|
|
processPayout: jest.fn(),
|
|
processBatchPayout: jest.fn().mockRejectedValue(new Error('RPC timeout'))
|
|
};
|
|
const { gmm, db } = buildGmm(walletService);
|
|
|
|
db._mockClient.query
|
|
.mockResolvedValueOnce({ rows: TWO_PENDING })
|
|
.mockResolvedValue({ rows: [] });
|
|
|
|
await gmm._processPendingPayouts();
|
|
|
|
// Failure handling uses db.query (outside a transaction) to mark the batch needs_review.
|
|
// Status is passed as a bound param ($1), so check the params array, not the SQL text.
|
|
const failCall = db.query.mock.calls.find(c => Array.isArray(c[1]) && c[1][0] === 'needs_review');
|
|
expect(failCall).toBeDefined();
|
|
expect(failCall[0]).toMatch(/id = ANY/i);
|
|
// Must NOT mark them 'failed' (which the retry service would auto-resend).
|
|
const failedCall = db.query.mock.calls.find(c => Array.isArray(c[1]) && c[1][0] === 'failed');
|
|
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);
|
|
|
|
// ...but the EVIDENCE that the coins were broadcast must survive. A split is exactly what
|
|
// transfer_split is for; quarantining the rows with tx_hash NULL stranded already-sent funds
|
|
// with nothing for an operator (or the retry service's chain guard) to reconcile against.
|
|
expect(review[1][3]).toBe('a'.repeat(64)); // COALESCE($4, tx_hash)
|
|
expect(review[1][1]).toContain('a'.repeat(64)); // last_error carries the full list
|
|
expect(review[1][1]).toContain('b'.repeat(64));
|
|
});
|
|
|
|
test('a PRE-BROADCAST failure returns the batch to pending, not needs_review', async () => {
|
|
// Address validation and the transfer gate run strictly before transfer_split can send
|
|
// anything, so those rows are provably unsent and safe to retry. Quarantining them in
|
|
// needs_review parked them permanently — the retry service deliberately skips that status.
|
|
const preBroadcast = Object.assign(new Error('One or more payout addresses failed validation.'),
|
|
{ preBroadcast: true });
|
|
const walletService = {
|
|
getBalance: jest.fn().mockResolvedValue({ balance: '10000000000000', unlocked_balance: '10000000000000' }),
|
|
processPayout: jest.fn(),
|
|
processBatchPayout: jest.fn().mockRejectedValue(preBroadcast)
|
|
};
|
|
const { gmm, db } = buildGmm(walletService);
|
|
db._mockClient.query
|
|
.mockResolvedValueOnce({ rows: TWO_PENDING })
|
|
.mockResolvedValue({ rows: [] });
|
|
|
|
await gmm._processPendingPayouts();
|
|
|
|
expect(db.query.mock.calls.some(call => call[1]?.[0] === 'needs_review')).toBe(false);
|
|
const deferred = db.query.mock.calls.find(call => call[1]?.[0] === 'pending');
|
|
expect(deferred).toBeDefined();
|
|
});
|
|
|
|
test('an ambiguous POST-broadcast failure still quarantines the batch', async () => {
|
|
const ambiguous = new Error('socket hang up'); // no preBroadcast tag — may have sent
|
|
const walletService = {
|
|
getBalance: jest.fn().mockResolvedValue({ balance: '10000000000000', unlocked_balance: '10000000000000' }),
|
|
processPayout: jest.fn(),
|
|
processBatchPayout: jest.fn().mockRejectedValue(ambiguous)
|
|
};
|
|
const { gmm, db } = buildGmm(walletService);
|
|
db._mockClient.query
|
|
.mockResolvedValueOnce({ rows: TWO_PENDING })
|
|
.mockResolvedValue({ rows: [] });
|
|
|
|
await gmm._processPendingPayouts();
|
|
|
|
expect(db.query.mock.calls.some(call => call[1]?.[0] === 'needs_review')).toBe(true);
|
|
});
|
|
|
|
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.
|
|
const walletService = {
|
|
getBalance: jest.fn().mockResolvedValue({ balance: '500000000000', unlocked_balance: '1000000000' }),
|
|
processPayout: jest.fn(),
|
|
processBatchPayout: jest.fn()
|
|
};
|
|
const { gmm, db } = buildGmm(walletService);
|
|
|
|
db._mockClient.query
|
|
.mockResolvedValueOnce({ rows: TWO_PENDING })
|
|
.mockResolvedValue({ rows: [] });
|
|
|
|
await gmm._processPendingPayouts();
|
|
|
|
// No on-chain transfer was attempted.
|
|
expect(walletService.processBatchPayout).not.toHaveBeenCalled();
|
|
expect(walletService.processPayout).not.toHaveBeenCalled();
|
|
|
|
// The defer UPDATE sets status = 'pending' literally in SQL and passes ids as $1.
|
|
const deferSql = db.query.mock.calls.find(c => /SET status = 'pending'/i.test(c[0]) && /id = ANY/i.test(c[0]));
|
|
expect(deferSql).toBeDefined();
|
|
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' }),
|
|
processPayout: jest.fn(),
|
|
processBatchPayout: jest.fn().mockRejectedValue(new Error('not enough unlocked money to transfer'))
|
|
};
|
|
const { gmm, db } = buildGmm(walletService);
|
|
|
|
db._mockClient.query
|
|
.mockResolvedValueOnce({ rows: TWO_PENDING })
|
|
.mockResolvedValue({ rows: [] });
|
|
|
|
await gmm._processPendingPayouts();
|
|
|
|
// A funds error means nothing broadcast -> safe to retry: status='pending', NOT needs_review.
|
|
const pendingCall = db.query.mock.calls.find(c => Array.isArray(c[1]) && c[1][0] === 'pending' && /id = ANY/i.test(c[0]));
|
|
expect(pendingCall).toBeDefined();
|
|
const reviewCall = db.query.mock.calls.find(c => Array.isArray(c[1]) && c[1][0] === 'needs_review');
|
|
expect(reviewCall).toBeUndefined();
|
|
});
|
|
});
|