233 lines
9.6 KiB
TypeScript
233 lines
9.6 KiB
TypeScript
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 {
|
|
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
|
|
): { 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.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)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?)`
|
|
)
|
|
.run(
|
|
server,
|
|
request_id,
|
|
guid,
|
|
fingerprint,
|
|
operation,
|
|
matchId,
|
|
body.amount,
|
|
body.state,
|
|
JSON.stringify(body),
|
|
now
|
|
);
|
|
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 };
|
|
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();
|
|
}
|