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>
This commit is contained in:
96
migrations/0001_ledger.sql
Normal file
96
migrations/0001_ledger.sql
Normal file
@@ -0,0 +1,96 @@
|
||||
-- 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);
|
||||
Reference in New Issue
Block a user