From 416fa53c1793a30ae24bcd30286f6975d00d0b85 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Mon, 22 Jun 2026 07:10:55 -0400 Subject: [PATCH] feat: Nostr-native data models + initial schema models/db.rs: User keyed by pubkey_hash/nostr_pubkey/username (pubkey_hash and seed_fingerprint peppered at rest); Wallet, Session (platform=extension|web|nostr), UserKey, GrinSlatepack, AuditLog. Dropped all telegram/discord/social-identity columns and every tip/social/swap struct (swaps land as their own feature unit). migrations/20260622000001_initial_schema: consolidated clean schema; enum types; UNIQUE on identity columns; FK ON DELETE (CASCADE/SET NULL) for self-service erasure; indexes on every lookup column. PostgreSQL 13+. --- migrations/20260622000001_initial_schema.sql | 120 +++++++++ src/lib.rs | 1 + src/models/db.rs | 246 +++++++++++++++++++ src/models/mod.rs | 5 + 4 files changed, 372 insertions(+) create mode 100644 migrations/20260622000001_initial_schema.sql create mode 100644 src/models/db.rs create mode 100644 src/models/mod.rs diff --git a/migrations/20260622000001_initial_schema.sql b/migrations/20260622000001_initial_schema.sql new file mode 100644 index 0000000..fb00db6 --- /dev/null +++ b/migrations/20260622000001_initial_schema.sql @@ -0,0 +1,120 @@ +-- Initial schema for smirk-backend-core. +-- +-- Nostr-native identity: a user is keyed by pubkey_hash (peppered), an optional +-- nostr_pubkey, and an optional reserved username. No platform-login columns. +-- pubkey_hash and seed_fingerprint are stored HMAC-peppered (the pepper lives in +-- config); UNIQUE constraints are on the peppered values. FK ON DELETE clauses +-- support self-service erasure. Requires PostgreSQL 13+ (gen_random_uuid). + +-- ── Enum types ────────────────────────────────────────────────────────────── +CREATE TYPE asset_type AS ENUM ('btc', 'ltc', 'xmr', 'wow', 'grin'); + +CREATE TYPE slatepack_status AS ENUM ( + 'pending_recipient', 'pending_sender', 'finalized', 'expired', 'cancelled' +); + +CREATE TYPE audit_action AS ENUM ( + 'user_created', 'user_login', 'wallet_created', 'wallet_registered', + 'tx_broadcast', 'session_created', 'session_revoked' +); + +-- ── users ─────────────────────────────────────────────────────────────────── +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + username TEXT UNIQUE, + pubkey_hash TEXT UNIQUE, -- peppered at rest + nostr_pubkey TEXT UNIQUE, + wallet_birthday TIMESTAMPTZ, + seed_fingerprint TEXT UNIQUE, -- peppered at rest + xmr_start_height BIGINT, + wow_start_height BIGINT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen_at TIMESTAMPTZ +); +-- UNIQUE constraints already index username/pubkey_hash/nostr_pubkey/seed_fingerprint. + +-- ── wallets ───────────────────────────────────────────────────────────────── +CREATE TABLE wallets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + asset asset_type NOT NULL, + address TEXT NOT NULL, + view_key TEXT, + derivation_index INTEGER, + registered_with_node BOOLEAN NOT NULL DEFAULT FALSE, + registration_error TEXT, + label TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_wallets_user_id ON wallets (user_id); +CREATE INDEX idx_wallets_address ON wallets (address); + +-- ── sessions ──────────────────────────────────────────────────────────────── +CREATE TABLE sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + refresh_token_hash TEXT NOT NULL, -- peppered at rest + platform TEXT NOT NULL, -- extension | web | nostr + device_info TEXT, + ip_address INET, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ, + last_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_sessions_user_id ON sessions (user_id); +CREATE INDEX idx_sessions_refresh_token_hash ON sessions (refresh_token_hash); + +-- ── user_keys ─────────────────────────────────────────────────────────────── +CREATE TABLE user_keys ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + asset asset_type NOT NULL, + public_key TEXT NOT NULL, + public_spend_key TEXT, + key_type TEXT NOT NULL DEFAULT 'primary', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (user_id, asset, key_type) +); +CREATE INDEX idx_user_keys_user_id ON user_keys (user_id); + +-- ── grin_slatepacks ───────────────────────────────────────────────────────── +CREATE TABLE grin_slatepacks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + slate_id TEXT NOT NULL, + sender_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + recipient_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + recipient_address TEXT, + slatepack_content TEXT NOT NULL, + amount_nanogrin BIGINT NOT NULL, + status slatepack_status NOT NULL DEFAULT 'pending_recipient', + response_slatepack TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + finalized_at TIMESTAMPTZ, + tx_hash TEXT +); +CREATE INDEX idx_grin_slatepacks_slate_id ON grin_slatepacks (slate_id); +CREATE INDEX idx_grin_slatepacks_sender ON grin_slatepacks (sender_user_id); +CREATE INDEX idx_grin_slatepacks_recipient ON grin_slatepacks (recipient_user_id); +CREATE INDEX idx_grin_slatepacks_status ON grin_slatepacks (status); + +-- ── audit_logs ────────────────────────────────────────────────────────────── +CREATE TABLE audit_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + action audit_action NOT NULL, + resource_type TEXT, + resource_id UUID, + details JSONB, + ip_address INET, + user_agent TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_audit_logs_user_id ON audit_logs (user_id); +CREATE INDEX idx_audit_logs_action ON audit_logs (action); +CREATE INDEX idx_audit_logs_created_at ON audit_logs (created_at); diff --git a/src/lib.rs b/src/lib.rs index 05b173a..78faff9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,3 +11,4 @@ pub mod api; pub mod config; pub mod error; +pub mod models; diff --git a/src/models/db.rs b/src/models/db.rs new file mode 100644 index 0000000..b1fc8e9 --- /dev/null +++ b/src/models/db.rs @@ -0,0 +1,246 @@ +//! Database entity models. These map directly to tables and are used with sqlx. +//! +//! Identity is Nostr-native: a user is keyed by `pubkey_hash` (the wallet's +//! identity pubkey), an optional `nostr_pubkey`, and an optional reserved +//! `username`. `pubkey_hash` and `seed_fingerprint` are stored HMAC-peppered at +//! rest (the pepper lives in config); the values here are already peppered. + +use chrono::{DateTime, Utc}; +use ipnetwork::IpNetwork; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use uuid::Uuid; + +// ── Enums (mapped to PostgreSQL enum types) ───────────────────────────────── + +/// Supported cryptocurrency assets. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] +#[sqlx(type_name = "asset_type", rename_all = "lowercase")] +pub enum AssetType { + Btc, + Ltc, + Xmr, + Wow, + Grin, +} + +impl AssetType { + pub fn as_str(&self) -> &'static str { + match self { + AssetType::Btc => "btc", + AssetType::Ltc => "ltc", + AssetType::Xmr => "xmr", + AssetType::Wow => "wow", + AssetType::Grin => "grin", + } + } +} + +impl std::fmt::Display for AssetType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +/// Grin slatepack relay status. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] +#[sqlx(type_name = "slatepack_status", rename_all = "snake_case")] +pub enum SlatepackStatus { + PendingRecipient, + PendingSender, + Finalized, + Expired, + Cancelled, +} + +/// Audit log action types. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] +#[sqlx(type_name = "audit_action", rename_all = "snake_case")] +pub enum AuditAction { + UserCreated, + UserLogin, + WalletCreated, + WalletRegistered, + TxBroadcast, + SessionCreated, + SessionRevoked, +} + +// ── Entities (field order matches column order) ───────────────────────────── + +/// A user account. Identity is the wallet's key material, not a platform login. +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct User { + pub id: Uuid, + /// Reserved `@handle` (backs NIP-05). Unique, optional. + pub username: Option, + /// Peppered hash of the wallet identity pubkey. Unique, optional. + pub pubkey_hash: Option, + /// The user's Nostr public key (hex). Unique, optional. + pub nostr_pubkey: Option, + /// Approximate wallet creation time, to bound chain scans. + pub wallet_birthday: Option>, + /// Peppered seed fingerprint, for restore lookup. Unique, optional. + pub seed_fingerprint: Option, + /// Monero scan start height (skip pre-birthday blocks). + pub xmr_start_height: Option, + /// Wownero scan start height. + pub wow_start_height: Option, + pub created_at: DateTime, + pub updated_at: DateTime, + pub last_seen_at: Option>, +} + +/// A wallet associated with a user. +/// +/// Non-custodial: we store the public address and (for XMR/WOW) the view key +/// for balance scanning. We NEVER store a spend key or seed. +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct Wallet { + pub id: Uuid, + pub user_id: Uuid, + pub asset: AssetType, + pub address: String, + /// View key (XMR/WOW only) for balance scanning. + pub view_key: Option, + /// HD derivation index (BTC/LTC). + pub derivation_index: Option, + pub registered_with_node: bool, + pub registration_error: Option, + pub label: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// A session backing a JWT refresh token. +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct Session { + pub id: Uuid, + pub user_id: Uuid, + /// Peppered hash of the refresh token (never the token itself). + pub refresh_token_hash: String, + /// Client kind: `extension`, `web`, or `nostr`. + pub platform: String, + pub device_info: Option, + pub ip_address: Option, + pub created_at: DateTime, + pub expires_at: DateTime, + pub revoked_at: Option>, + pub last_used_at: DateTime, +} + +/// A user's per-asset public key, so others can send to them. +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct UserKey { + pub id: Uuid, + pub user_id: Uuid, + pub asset: AssetType, + /// The public key (format depends on asset). + pub public_key: String, + /// Public spend key (XMR/WOW). + pub public_spend_key: Option, + /// `primary` or `backup`. + pub key_type: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// A Grin slatepack relayed between two parties for an interactive transaction. +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct GrinSlatepack { + pub id: Uuid, + pub slate_id: String, + pub sender_user_id: Uuid, + /// Recipient user (if registered on this backend). + pub recipient_user_id: Option, + /// Recipient slatepack address (if not registered here). + pub recipient_address: Option, + pub slatepack_content: String, + pub amount_nanogrin: i64, + pub status: SlatepackStatus, + pub response_slatepack: Option, + pub created_at: DateTime, + pub updated_at: DateTime, + pub expires_at: DateTime, + pub finalized_at: Option>, + pub tx_hash: Option, +} + +/// Audit log entry for security-relevant actions. +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct AuditLog { + pub id: Uuid, + pub user_id: Option, + pub action: AuditAction, + pub resource_type: Option, + pub resource_id: Option, + pub details: Option, + pub ip_address: Option, + pub user_agent: Option, + pub created_at: DateTime, +} + +// ── Input structs (no auto-generated fields) ──────────────────────────────── + +/// Input for creating a new user. Peppered values are supplied by the db layer. +#[derive(Debug, Clone)] +pub struct NewUser { + pub username: Option, + pub pubkey_hash: Option, + pub nostr_pubkey: Option, + pub wallet_birthday: Option>, + pub seed_fingerprint: Option, + pub xmr_start_height: Option, + pub wow_start_height: Option, +} + +#[derive(Debug, Clone)] +pub struct NewWallet { + pub user_id: Uuid, + pub asset: AssetType, + pub address: String, + pub view_key: Option, + pub derivation_index: Option, + pub label: Option, +} + +#[derive(Debug, Clone)] +pub struct NewSession { + pub user_id: Uuid, + pub refresh_token_hash: String, + pub platform: String, + pub device_info: Option, + pub ip_address: Option, + pub expires_at: DateTime, +} + +#[derive(Debug, Clone)] +pub struct NewUserKey { + pub user_id: Uuid, + pub asset: AssetType, + pub public_key: String, + pub public_spend_key: Option, + pub key_type: String, +} + +#[derive(Debug, Clone)] +pub struct NewGrinSlatepack { + pub slate_id: String, + pub sender_user_id: Uuid, + pub recipient_user_id: Option, + pub recipient_address: Option, + pub slatepack_content: String, + pub amount_nanogrin: i64, + pub expires_at: DateTime, +} + +#[derive(Debug, Clone)] +pub struct NewAuditLog { + pub user_id: Option, + pub action: AuditAction, + pub resource_type: Option, + pub resource_id: Option, + pub details: Option, + pub ip_address: Option, + pub user_agent: Option, +} diff --git a/src/models/mod.rs b/src/models/mod.rs new file mode 100644 index 0000000..36839dd --- /dev/null +++ b/src/models/mod.rs @@ -0,0 +1,5 @@ +//! Data models: database entities (sqlx) and shared types. +//! +//! Request/response DTOs are added alongside the handlers that use them. + +pub mod db;