Files
cryptowallet/cryptowallet-schema.sql
2026-05-13 00:17:32 +03:00

156 lines
7.9 KiB
SQL
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- ╔══════════════════════════════════════════════════════════════════╗
-- ║ CryptoWallet API — Production DB schema (idempotent, custodial) ║
-- ║ Применять: psql -h <host> -U postgres_user -d postgres -f ... ║
-- ║ Безопасно прогонять повторно на existing БД. ║
-- ╚══════════════════════════════════════════════════════════════════╝
CREATE TABLE IF NOT EXISTS users (
id VARCHAR(26) NOT NULL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
last_name VARCHAR(128),
first_name VARCHAR(128),
middle_name VARCHAR(128),
birth_date DATE,
-- DEPRECATED: исторически generic crypto address; для ETH используем erc20 ниже.
crypto_wallet VARCHAR(255),
phone VARCHAR(16),
inn VARCHAR(12),
kyc_verified BOOLEAN NOT NULL DEFAULT FALSE,
kyc_verified_at TIMESTAMP WITH TIME ZONE,
is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
passport_data VARCHAR(255),
erc20 VARCHAR(255),
-- EXTENSION (custodial wallet support):
-- AES-256-GCM blob: IV(12) || ciphertext || authTag(16), base64. Master-key в Vault.
encrypted_mnemonic TEXT
);
-- Idempotent ALTERs для existing БД без extension-columns
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'encrypted_mnemonic') THEN
ALTER TABLE users ADD COLUMN encrypted_mnemonic TEXT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'erc20') THEN
ALTER TABLE users ADD COLUMN erc20 VARCHAR(255);
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'passport_data') THEN
ALTER TABLE users ADD COLUMN passport_data VARCHAR(255);
END IF;
END $$;
-- Sanity check на blob size. Floor 100 (worst-case 12-word всё-3char mnemonic):
-- plaintext 47 bytes + IV(12) + tag(16) = 75 raw → 100 base64
-- typical 12-word: 113 raw → 152 base64; 24-word: 240 raw → 320 base64
-- (Раньше floor 140 отвергал ~4% валидных 12-word mnemonics — fixed.)
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'users_encrypted_mnemonic_size') THEN
ALTER TABLE users DROP CONSTRAINT users_encrypted_mnemonic_size;
END IF;
ALTER TABLE users
ADD CONSTRAINT users_encrypted_mnemonic_size
CHECK (encrypted_mnemonic IS NULL OR (char_length(encrypted_mnemonic) BETWEEN 100 AND 512));
END $$;
-- Case-insensitive email uniqueness (Alice@x.com ≠ alice@x.com → ACCOUNT HIJACKING fix)
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'users_email_lower_unique') THEN
CREATE UNIQUE INDEX users_email_lower_unique ON users (lower(email));
END IF;
END $$;
-- Partial index для active-user queries
CREATE INDEX IF NOT EXISTS idx_users_active ON users(id) WHERE is_deleted = FALSE;
-- erc20 format check (NULL or 0x + 40 hex)
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'users_erc20_format') THEN
ALTER TABLE users
ADD CONSTRAINT users_erc20_format
CHECK (erc20 IS NULL OR erc20 ~ '^0x[0-9a-fA-F]{40}$');
END IF;
END $$;
-- KYC consistency: verified=true requires verified_at NOT NULL
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'users_kyc_consistency') THEN
ALTER TABLE users
ADD CONSTRAINT users_kyc_consistency
CHECK ((kyc_verified = FALSE) OR (kyc_verified = TRUE AND kyc_verified_at IS NOT NULL));
END IF;
END $$;
-- ── WALLETS ─────────────────────────────────────────────────────────
-- ON DELETE RESTRICT: hard-delete user → запрос отвергнут пока есть wallets.
-- Это защита от unrecoverable fund loss при GDPR-wipe или admin удалении.
-- Use is_deleted=true для soft-delete.
CREATE TABLE IF NOT EXISTS wallets (
id VARCHAR(26) NOT NULL PRIMARY KEY,
user_id VARCHAR(26) NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
chain VARCHAR(16) NOT NULL,
address VARCHAR(128) NOT NULL,
derivation_path VARCHAR(64) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_id, chain),
CHECK (chain IN ('ETH', 'BSC', 'BTC', 'TRX', 'SOL'))
);
CREATE INDEX IF NOT EXISTS idx_wallets_user_id ON wallets(user_id);
CREATE INDEX IF NOT EXISTS idx_wallets_address ON wallets(address);
-- Idempotent FK migration: если raised на старой DB с CASCADE — поменять
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.referential_constraints
WHERE constraint_name LIKE 'wallets_user_id_fkey%' AND delete_rule = 'CASCADE'
) THEN
ALTER TABLE wallets DROP CONSTRAINT IF EXISTS wallets_user_id_fkey;
ALTER TABLE wallets ADD CONSTRAINT wallets_user_id_fkey
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE RESTRICT;
END IF;
END $$;
-- ── AUDIT_LOG (durable sink для критических custodial операций) ─────
-- Pre-mutation INSERT 'pending', post-mutation UPDATE 'completed' с txid.
-- Если INSERT fails — операция НЕ происходит (fail-secure).
CREATE TABLE IF NOT EXISTS audit_log (
id VARCHAR(26) NOT NULL PRIMARY KEY,
user_id VARCHAR(26) NOT NULL,
event VARCHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'success', 'failure')),
error_code VARCHAR(64),
ip VARCHAR(64),
trace_id VARCHAR(64),
meta JSONB,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_audit_log_user_id ON audit_log(user_id);
CREATE INDEX IF NOT EXISTS idx_audit_log_event_created ON audit_log(event, created_at DESC);
-- ── IDEMPOTENCY_KEYS (защита от double-spend на retry) ──────────────
-- Client шлёт Idempotency-Key header. Pre-mutation INSERT row, post-mutation UPDATE с response.
-- На retry — возвращаем cached response без второго broadcast.
CREATE TABLE IF NOT EXISTS idempotency_keys (
user_id VARCHAR(26) NOT NULL,
key VARCHAR(128) NOT NULL,
request_hash VARCHAR(64) NOT NULL,
response_status SMALLINT,
response_body TEXT,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, key)
);
CREATE INDEX IF NOT EXISTS idx_idempotency_keys_created ON idempotency_keys(created_at);
-- Retention cleanup (run via cron): DELETE FROM idempotency_keys WHERE created_at < NOW() - INTERVAL '24 hours';