The Lightning bridge is modelled as the boundary with the outside world and is the one account permitted to go negative; its negative balance is exactly what is owed to players inside the system. All other accounts are floored at zero by both the application and a database trigger. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
97 lines
3.8 KiB
PL/PgSQL
97 lines
3.8 KiB
PL/PgSQL
-- Quantum Arcade ledger: append-only double-entry accounting.
|
|
--
|
|
-- Amounts are millisatoshis stored as BIGINT. No UPDATE or DELETE is ever
|
|
-- issued against these tables; corrections are compensating transactions.
|
|
-- The constraints below restate the application's invariants so that a bug in
|
|
-- the Go layer cannot corrupt the books.
|
|
|
|
CREATE TYPE account_kind AS ENUM ('player', 'house', 'lightning_bridge');
|
|
|
|
CREATE TABLE accounts (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
kind account_kind NOT NULL,
|
|
-- Player accounts key on the ed25519 public key; system accounts use a
|
|
-- stable name. Exactly one of these is set.
|
|
pubkey BYTEA UNIQUE,
|
|
name TEXT UNIQUE,
|
|
nickname TEXT,
|
|
-- The Lightning bridge is the boundary with the outside world: its balance
|
|
-- goes negative by exactly the amount owed to players inside the system.
|
|
-- Every other account is strictly non-negative.
|
|
allow_negative BOOLEAN NOT NULL DEFAULT false,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
CONSTRAINT account_identity CHECK (
|
|
(kind = 'player' AND pubkey IS NOT NULL AND name IS NULL) OR
|
|
(kind <> 'player' AND pubkey IS NULL AND name IS NOT NULL)
|
|
)
|
|
);
|
|
|
|
CREATE TABLE transactions (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
kind TEXT NOT NULL, -- 'bet', 'payout', 'deposit', ...
|
|
round_id BIGINT, -- NULL for non-game transactions
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
|
|
CREATE TABLE postings (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
transaction_id BIGINT NOT NULL REFERENCES transactions(id),
|
|
account_id BIGINT NOT NULL REFERENCES accounts(id),
|
|
-- Positive credits the account, negative debits it.
|
|
amount_msat BIGINT NOT NULL,
|
|
balance_before BIGINT NOT NULL,
|
|
balance_after BIGINT NOT NULL,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
CONSTRAINT amount_nonzero CHECK (amount_msat <> 0),
|
|
CONSTRAINT balance_arithmetic CHECK (balance_after = balance_before + amount_msat)
|
|
);
|
|
|
|
-- A CHECK constraint cannot consult another table, so the non-negative rule is
|
|
-- a trigger. It is the last line of defence behind the application's own check.
|
|
CREATE OR REPLACE FUNCTION enforce_balance_floor() RETURNS TRIGGER AS $$
|
|
DECLARE
|
|
permitted BOOLEAN;
|
|
BEGIN
|
|
SELECT allow_negative INTO permitted FROM accounts WHERE id = NEW.account_id;
|
|
IF NOT permitted AND NEW.balance_after < 0 THEN
|
|
RAISE EXCEPTION 'account % may not go negative (balance would be %)',
|
|
NEW.account_id, NEW.balance_after;
|
|
END IF;
|
|
RETURN NEW;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
CREATE TRIGGER postings_balance_floor
|
|
BEFORE INSERT ON postings
|
|
FOR EACH ROW EXECUTE FUNCTION enforce_balance_floor();
|
|
|
|
CREATE INDEX postings_account_idx ON postings (account_id, id DESC);
|
|
CREATE INDEX postings_transaction_idx ON postings (transaction_id);
|
|
CREATE INDEX transactions_round_idx ON transactions (round_id) WHERE round_id IS NOT NULL;
|
|
|
|
-- Current balance is the most recent posting's balance_after.
|
|
CREATE VIEW account_balances AS
|
|
SELECT DISTINCT ON (account_id)
|
|
account_id, balance_after AS balance_msat
|
|
FROM postings
|
|
ORDER BY account_id, id DESC;
|
|
|
|
-- Enforce append-only at the database level, not just by convention.
|
|
CREATE OR REPLACE FUNCTION reject_mutation() RETURNS TRIGGER AS $$
|
|
BEGIN
|
|
RAISE EXCEPTION 'ledger tables are append-only';
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
CREATE TRIGGER postings_append_only
|
|
BEFORE UPDATE OR DELETE ON postings
|
|
FOR EACH ROW EXECUTE FUNCTION reject_mutation();
|
|
|
|
CREATE TRIGGER transactions_append_only
|
|
BEFORE UPDATE OR DELETE ON transactions
|
|
FOR EACH ROW EXECUTE FUNCTION reject_mutation();
|
|
|
|
INSERT INTO accounts (kind, name, allow_negative) VALUES
|
|
('house', 'house_pot', false),
|
|
('lightning_bridge', 'lightning_bridge', true);
|