-- 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);