-- ────────────────────────────────────────────────────────────────────────── -- Tracecoin security hardening -- ────────────────────────────────────────────────────────────────────────── -- This migration tightens the tracecoin_wallets + tracecoin_ledger schema -- so that the wallet/ledger can be operated on safely under concurrent -- load. Specifically: -- -- 1. Prevent negative balances at the database layer (CHECK constraints). -- 2. Prevent double-credits on payment re-verification (unique idempotency -- key on (wallet_id, reference_id) when reference_id IS NOT NULL). -- 3. Add a balance_after column so the ledger is a self-contained -- append-only history with running totals (no need to SUM at read time). -- 4. Add an actor_user_id column so we know who triggered each entry -- (admin adjustments become auditable; system entries are NULL). -- 5. Add metadata JSONB so callers can attach context without growing -- the schema. -- 6. Backfill balance_after for existing rows. -- 7. Add a CHECK that amount != 0. -- 8. Add a CHECK that type is one of the allowed values. -- 9. Add an index on (user_id, created_at DESC) for fast history reads. -- ────────────────────────────────────────────────────────────────────────── -- 1. CHECK constraints ALTER TABLE tracecoin_wallets DROP CONSTRAINT IF EXISTS tracecoin_wallets_balance_nonneg; ALTER TABLE tracecoin_wallets ADD CONSTRAINT tracecoin_wallets_balance_nonneg CHECK (balance >= 0); ALTER TABLE tracecoin_wallets DROP CONSTRAINT IF EXISTS tracecoin_wallets_reserved_nonneg; ALTER TABLE tracecoin_wallets ADD CONSTRAINT tracecoin_wallets_reserved_nonneg CHECK (reserved >= 0); ALTER TABLE tracecoin_wallets DROP CONSTRAINT IF EXISTS tracecoin_wallets_balance_consistent; ALTER TABLE tracecoin_wallets ADD CONSTRAINT tracecoin_wallets_balance_consistent CHECK (balance + reserved <= 2147483647); -- 2. Idempotency: at most one ledger entry per (wallet, reference, type). -- This lets a single payment credit exactly once, while still allowing -- separate RESERVE / RELEASE / CONFIRM rows for a single lead_request. CREATE UNIQUE INDEX IF NOT EXISTS uq_tracecoin_ledger_wallet_reference_type ON tracecoin_ledger (wallet_id, reference_id, type) WHERE reference_id IS NOT NULL; -- 3. balance_after column ALTER TABLE tracecoin_ledger ADD COLUMN IF NOT EXISTS balance_after INTEGER; -- 4. actor_user_id column ALTER TABLE tracecoin_ledger ADD COLUMN IF NOT EXISTS actor_user_id UUID REFERENCES users(id); -- 5. metadata JSONB ALTER TABLE tracecoin_ledger ADD COLUMN IF NOT EXISTS metadata JSONB; -- 7. amount != 0 ALTER TABLE tracecoin_ledger DROP CONSTRAINT IF EXISTS tracecoin_ledger_amount_nonzero; ALTER TABLE tracecoin_ledger ADD CONSTRAINT tracecoin_ledger_amount_nonzero CHECK (amount <> 0); -- 8. type must be one of the allowed values ALTER TABLE tracecoin_ledger DROP CONSTRAINT IF EXISTS tracecoin_ledger_type_valid; ALTER TABLE tracecoin_ledger ADD CONSTRAINT tracecoin_ledger_type_valid CHECK (type IN ('CREDIT', 'DEBIT', 'RESERVE', 'RELEASE', 'ADJUSTMENT')); -- 9. (user_id, created_at) covering index for history reads. -- We don't have user_id on the ledger; we have wallet_id, so we use that. CREATE INDEX IF NOT EXISTS idx_tracecoin_ledger_wallet_created ON tracecoin_ledger (wallet_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_tracecoin_ledger_type ON tracecoin_ledger (type); -- 6. Backfill balance_after for existing rows using a window function. -- This is best-effort: if the data has gaps, balance_after is set to the -- row's amount as a relative number; a re-compute can rebuild it later. WITH ordered AS ( SELECT id, SUM(amount) OVER ( PARTITION BY wallet_id ORDER BY created_at, id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS running FROM tracecoin_ledger ) UPDATE tracecoin_ledger tl SET balance_after = ordered.running FROM ordered WHERE tl.id = ordered.id AND tl.balance_after IS NULL; -- Prevent further updates / deletes to ledger rows. The application code -- must treat the ledger as append-only. CREATE OR REPLACE FUNCTION tracecoin_ledger_immutable() RETURNS TRIGGER AS $$ BEGIN RAISE EXCEPTION 'tracecoin_ledger is append-only; updates and deletes are not allowed'; END; $$ LANGUAGE plpgsql; DROP TRIGGER IF EXISTS trg_tracecoin_ledger_no_update ON tracecoin_ledger; CREATE TRIGGER trg_tracecoin_ledger_no_update BEFORE UPDATE ON tracecoin_ledger FOR EACH ROW EXECUTE FUNCTION tracecoin_ledger_immutable(); DROP TRIGGER IF EXISTS trg_tracecoin_ledger_no_delete ON tracecoin_ledger; CREATE TRIGGER trg_tracecoin_ledger_no_delete BEFORE DELETE ON tracecoin_ledger FOR EACH ROW EXECUTE FUNCTION tracecoin_ledger_immutable(); -- Helper function: get-or-create wallet, lock the row, return id and current -- balance. This is the single point where concurrent access is serialized. CREATE OR REPLACE FUNCTION lock_tracecoin_wallet(p_user_id UUID) RETURNS TABLE(wallet_id UUID, balance INTEGER, reserved INTEGER) LANGUAGE plpgsql AS $$ DECLARE v_id UUID; BEGIN -- Insert if missing. ON CONFLICT DO NOTHING preserves an existing row. INSERT INTO tracecoin_wallets (user_id, balance, reserved) VALUES (p_user_id, 0, 0) ON CONFLICT (user_id) DO NOTHING; SELECT id INTO v_id FROM tracecoin_wallets WHERE user_id = p_user_id; -- Lock the row for the duration of the transaction. RETURN QUERY SELECT tw.id, tw.balance, tw.reserved FROM tracecoin_wallets tw WHERE tw.id = v_id FOR UPDATE; END; $$;