Files
casino/migrations/0001_ledger.sql
drjones 8045e37c16 feat(ledger): add append-only double-entry engine
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>
2026-08-05 03:23:49 +00:00

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