import { db } from '../db/index.js'; export const BANK_HARD_LIMIT = 1_000_000; export type BankAction = 'balance' | 'deposit' | 'withdraw' | 'commit' | 'cancel'; interface Transfer { server: string; guid: string; match: string; request_id: string; operation: 'deposit' | 'withdraw'; amount: number | 'all'; capacity: number; } interface Result { ok: boolean; state: string; code: string; request_id: string; amount: number; balance: number; } interface Saved { instance_id: string | null; fingerprint: string; state: string; amount: number; match_id: number | null; result: string; } const token = (value: unknown, limit: number): value is string => typeof value === 'string' && value.length > 0 && value.length <= limit && /^[A-Za-z0-9_.:-]+$/.test(value); const integer = (value: unknown, min = 0): value is number => Number.isSafeInteger(value) && Number(value) >= min && Number(value) <= BANK_HARD_LIMIT; export function bankMaximum(): number { const value = Number(process.env.EC_BANK_MAX_BALANCE ?? 250_000); if (!integer(value, 1)) throw new Error('EC_BANK_MAX_BALANCE must be an integer from 1 to 1000000'); return value; } export function bankProfile(guid: string) { const handle = db(); const account = handle.prepare('SELECT balance FROM bank_accounts WHERE guid = ?').get(guid) as { balance: number } | undefined; const pending = handle .prepare( "SELECT operation, amount FROM bank_requests WHERE guid = ? AND state = 'prepared'" ) .get(guid) as { operation: string; amount: number } | undefined; const entries = handle .prepare( `SELECT l.id, l.server, l.amount, l.balance, l.created_at AS createdAt, l.match_id AS matchId, CASE WHEN m.id IS NOT NULL THEN 1 ELSE 0 END AS matchAvailable FROM bank_ledger l LEFT JOIN leaderboard_matches m ON m.id = l.match_id WHERE l.guid = ? ORDER BY l.id DESC LIMIT 20` ) .all(guid) as { id: number; server: string; amount: number; balance: number; createdAt: string; matchId: number | null; matchAvailable: number; }[]; const balance = account?.balance ?? 0; return { balance, available: balance - (pending?.operation === 'withdraw' ? pending.amount : 0), pending: Boolean(pending), entries }; } /** One immediate SQLite transaction serializes limits, reservations and ledger updates. */ export function bankRequest( action: BankAction, value: unknown, authenticatedServer: string, instanceId: string ): { status: number; body: Result } { const result = (status: number, code: string, request_id = '') => ({ status, body: { ok: false, state: 'rejected', code, request_id, amount: 0, balance: 0 } }); if (!value || typeof value !== 'object' || Array.isArray(value)) return result(400, 'invalid_request'); const input = value as Transfer; if (input.server !== authenticatedServer) return result(403, 'wrong_server'); if (!token(input.guid, 64)) return result(400, 'invalid_request'); if (action === 'balance') { const profile = bankProfile(input.guid); return { status: 200, body: { ok: true, state: 'balance', code: profile.pending ? 'pending' : 'ok', request_id: '', amount: profile.available, balance: profile.balance } }; } if ( !token(input.request_id, 64) || !token(input.match, 64) || !['deposit', 'withdraw'].includes(input.operation) || !(integer(input.amount, 1) || (input.operation === 'withdraw' && input.amount === 'all')) || !integer(input.capacity) || ((action === 'deposit' || action === 'withdraw') && action !== input.operation) ) return result(400, 'invalid_request'); const { server, guid, request_id, operation, match, amount, capacity } = input; const fingerprint = JSON.stringify({ guid, operation, match, amount, capacity }); const handle = db(); return handle .transaction(() => { const saved = handle .prepare('SELECT * FROM bank_requests WHERE server = ? AND request_id = ?') .get(server, request_id) as Saved | undefined; if (saved && (saved.instance_id !== instanceId || saved.fingerprint !== fingerprint)) return result(409, 'request_conflict', request_id); const balance = ( handle.prepare('SELECT balance FROM bank_accounts WHERE guid = ?').get(guid) as { balance: number } | undefined )?.balance ?? 0; const now = new Date().toISOString(); const reply = ( state: string, code: string, transferred = 0, resulting = balance ): Result => ({ ok: state !== 'rejected', state, code, request_id, amount: transferred, balance: resulting }); const insert = (body: Result, matchId: number | null = null) => { handle .prepare( `INSERT INTO bank_requests(server,request_id,guid,fingerprint,operation,match_id,amount,state,result,created_at,instance_id) VALUES (?,?,?,?,?,?,?,?,?,?,?)` ) .run( server, request_id, guid, fingerprint, operation, matchId, body.amount, body.state, JSON.stringify(body), now, instanceId ); return { status: 200, body }; }; if (action === 'cancel') { // Tombstones win even when an earlier prepare is still in transit. if (!saved) return insert(reply('cancelled', 'cancelled')); if (saved.state !== 'prepared') return { status: 200, body: JSON.parse(saved.result) as Result }; const body = reply('cancelled', 'cancelled'); handle .prepare( 'UPDATE bank_requests SET state = ?, result = ?, settled_at = ? WHERE server = ? AND request_id = ?' ) .run(body.state, JSON.stringify(body), now, server, request_id); return { status: 200, body }; } if (action === 'commit') { if (!saved) return result(409, 'not_prepared', request_id); if (saved.state !== 'prepared') return { status: 200, body: JSON.parse(saved.result) as Result }; const delta = operation === 'deposit' ? saved.amount : -saved.amount; const resulting = balance + delta; handle .prepare( `INSERT INTO bank_accounts(guid,balance) VALUES (?,?) ON CONFLICT(guid) DO UPDATE SET balance = excluded.balance` ) .run(guid, resulting); handle .prepare( `INSERT INTO bank_ledger(guid,server,request_id,match_id,amount,balance,created_at) VALUES (?,?,?,?,?,?,?)` ) .run(guid, server, request_id, saved.match_id, delta, resulting, now); const body = reply('committed', 'ok', saved.amount, resulting); handle .prepare( 'UPDATE bank_requests SET state = ?, result = ?, settled_at = ? WHERE server = ? AND request_id = ?' ) .run(body.state, JSON.stringify(body), now, server, request_id); return { status: 200, body }; } if (saved) return { status: 200, body: JSON.parse(saved.result) as Result }; if ( !handle .prepare( 'SELECT 1 FROM native_instances WHERE id=? AND server=? AND retired_at IS NULL' ) .get(instanceId, server) ) return result(403, 'retired_instance', request_id); const member = handle .prepare( `SELECT m.id FROM leaderboard_matches m JOIN servers s ON s.name = m.server AND s.destroyed_at IS NULL JOIN leaderboard_match_players p ON p.match_id = m.id AND p.guid = ? AND p.present = 1 WHERE m.server = ? AND m.reporter_match_key = ? AND m.status = 'active' AND m.id = (SELECT MAX(id) FROM leaderboard_matches WHERE server = ?)` ) .get(guid, server, match, server) as { id: number } | undefined; if (!member) return insert(reply('rejected', 'not_in_match')); if ( handle .prepare("SELECT 1 FROM bank_requests WHERE guid = ? AND state = 'prepared'") .get(guid) ) return insert(reply('rejected', 'pending'), member.id); const requested = amount === 'all' ? balance : amount; let code = 'ok'; if (requested === 0) code = 'empty'; else if (operation === 'withdraw' && requested > balance) code = 'insufficient_balance'; else if (operation === 'withdraw' && requested > capacity) code = 'points_limit'; else if (operation === 'deposit' && balance + requested > bankMaximum()) code = 'bank_limit'; return insert( reply(code === 'ok' ? 'prepared' : 'rejected', code, code === 'ok' ? requested : 0), member.id ); }) .immediate(); }