diff --git a/migrations/20260710000001_social_tips.sql b/migrations/20260710000001_social_tips.sql new file mode 100644 index 0000000..aba6748 --- /dev/null +++ b/migrations/20260710000001_social_tips.sql @@ -0,0 +1,118 @@ +-- Social tips (PUBLIC-only): non-custodial share-URL tips. +-- +-- A sender funds a tip address and publishes a claim URL whose fragment carries +-- the claim key; anyone holding the URL claims by sweeping the tip address to +-- their own wallet. The backend stores only the encrypted claim blob and a hash +-- of the claim key (for status tracking) — it never sees the claim key or the +-- funds. +-- +-- Ported (public subset) from the legacy smirk-backend social_tips subsystem. +-- Targeted / @username tips and the socials + bot surface are intentionally +-- dropped: no recipient_* columns, no user_socials, no announcer. + +-- Bump updated_at on every UPDATE. Self-contained: core defines no shared +-- updated_at trigger function of its own. +CREATE OR REPLACE FUNCTION social_tips_touch_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TABLE social_tips ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + sender_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + asset VARCHAR(8) NOT NULL, -- btc | ltc | xmr | wow + amount BIGINT NOT NULL, -- smallest unit + + -- Public tip: anyone holding the claim URL can claim. + is_public BOOLEAN NOT NULL DEFAULT TRUE, + claim_key_hash VARCHAR(64), -- SHA256(claim key); the key never touches the server + encrypted_key BYTEA, -- claim secret, encrypted; backend cannot decrypt + + -- Funding: the sender funds tip_address, which is swept to the claimer on claim. + tip_address VARCHAR(255), + funding_txid VARCHAR(128), + + status VARCHAR(32) NOT NULL DEFAULT 'draft', + + -- Funding-confirmation tracking. + funding_confirmations INTEGER NOT NULL DEFAULT 0, + funding_confirmed_at TIMESTAMPTZ, + last_confirmation_check TIMESTAMPTZ, + confirmations_required INTEGER NOT NULL DEFAULT 0, + + -- Funding-amount verification: guards against a sender declaring more than + -- they funded (the verifier only flips a tip claimable once the on-chain + -- receipt covers `amount`). + funding_amount_verified BOOLEAN NOT NULL DEFAULT FALSE, + funding_amount_observed BIGINT, + funding_amount_verified_at TIMESTAMPTZ, + + -- Claim. + claimed_at TIMESTAMPTZ, + claimed_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + clawed_back_at TIMESTAMPTZ, + + -- Sweep: a claim settles ('claimed') only once its sweep confirms on-chain. + sweep_txid VARCHAR(128), + sweep_confirmed_at TIMESTAMPTZ, + sweep_block_height INTEGER, + sweep_block_hash TEXT, + sweep_confirmed_dm_sent_at TIMESTAMPTZ, -- exactly-once notify stamp + reorg_notified_at TIMESTAMPTZ, -- exactly-once reorg-notify stamp + + -- LWS (XMR/WOW): the tip address is registered with a view key so the backend + -- can scan it for funding + sweep. + tip_view_key VARCHAR(64), + lws_registered_at TIMESTAMPTZ, + lws_deactivated_at TIMESTAMPTZ, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Lifecycle: + -- draft -> pending_confirmation -> pending -> claiming -> claimed + -- draft -> cancelled + -- pending_confirmation -> funding_mismatch (verifier saw a short receipt) + -- {pending, pending_confirmation, claiming, funding_mismatch, cancelled} -> clawed_back + CONSTRAINT social_tips_status_check CHECK (status IN ( + 'draft', + 'pending_confirmation', + 'pending', + 'claiming', + 'claimed', + 'clawed_back', + 'cancelled', + 'funding_mismatch' + )), + -- Public tips must carry the claim-key hash. + CONSTRAINT public_tip_has_hash CHECK (is_public = FALSE OR claim_key_hash IS NOT NULL) +); + +CREATE INDEX idx_social_tips_sender ON social_tips(sender_user_id, created_at DESC); + +CREATE INDEX idx_social_tips_claim_hash ON social_tips(claim_key_hash) + WHERE claim_key_hash IS NOT NULL; + +-- Confirmation poller: rows still awaiting funding confirmations. +CREATE INDEX idx_social_tips_pending_confirmation + ON social_tips(asset, last_confirmation_check) + WHERE status IN ('pending', 'pending_confirmation') + AND confirmations_required > 0 + AND funding_confirmed_at IS NULL; + +-- Amount verifier: confirmed-but-not-yet-amount-verified rows. +CREATE INDEX idx_social_tips_status_verified + ON social_tips(status, funding_amount_verified) + WHERE status IN ('pending', 'pending_confirmation', 'funding_mismatch'); + +-- Sweep reconciler + lifecycle GC: rows mid-claim. +CREATE INDEX idx_social_tips_claiming ON social_tips(status, updated_at) + WHERE status = 'claiming'; + +CREATE TRIGGER social_tips_updated_at + BEFORE UPDATE ON social_tips + FOR EACH ROW EXECUTE FUNCTION social_tips_touch_updated_at(); diff --git a/src/infra/db/mod.rs b/src/infra/db/mod.rs index 74390fb..3dbaba4 100644 --- a/src/infra/db/mod.rs +++ b/src/infra/db/mod.rs @@ -20,6 +20,7 @@ mod premium_invoices; mod restore_attempts; mod server_config; mod sessions; +mod social_tips; mod user_keys; mod users; @@ -29,6 +30,7 @@ pub use login_events::LoginStats; pub use payment_invoices::PaymentInvoiceRow; pub use premium_invoices::PremiumInvoiceRow; pub use server_config::SetupState; +pub use social_tips::SocialTipRow; use sqlx::PgPool; diff --git a/src/infra/db/social_tips.rs b/src/infra/db/social_tips.rs new file mode 100644 index 0000000..0906eea --- /dev/null +++ b/src/infra/db/social_tips.rs @@ -0,0 +1,83 @@ +//! Social-tips (public) DB access. +//! +//! The `social_tips` row plus its query methods on [`Database`]. PUBLIC tips +//! only — no targeted/recipient columns. Every status transition is a single +//! guarded `UPDATE ... WHERE status IN (...) ... RETURNING`, so concurrent +//! claims / sweeps / clawbacks race safely on the DB (the guards are the +//! money-safety surface; see each method). + +use chrono::{DateTime, Utc}; +use sqlx::FromRow; +use tracing::instrument; +use uuid::Uuid; + +use crate::error::AppError; + +use super::Database; + +/// Every column of `social_tips`, in declaration order. Used verbatim in +/// `SELECT` / `RETURNING` so [`SocialTipRow`]'s `FromRow` mapping can never +/// drift from a `SELECT *`. +pub(crate) const TIP_COLS: &str = "\ + id, sender_user_id, asset, amount, is_public, claim_key_hash, encrypted_key, \ + tip_address, funding_txid, status, funding_confirmations, funding_confirmed_at, \ + last_confirmation_check, confirmations_required, funding_amount_verified, \ + funding_amount_observed, funding_amount_verified_at, claimed_at, claimed_by_user_id, \ + clawed_back_at, sweep_txid, sweep_confirmed_at, sweep_block_height, sweep_block_hash, \ + sweep_confirmed_dm_sent_at, reorg_notified_at, tip_view_key, lws_registered_at, \ + lws_deactivated_at, created_at, updated_at"; + +/// A persisted public social tip. Field names/order match [`TIP_COLS`] (sqlx +/// `FromRow` maps by name). Fields not read until later port stages are held +/// behind `allow(dead_code)`. +#[derive(Debug, Clone, FromRow)] +#[allow(dead_code)] +pub struct SocialTipRow { + pub id: Uuid, + pub sender_user_id: Uuid, + pub asset: String, + pub amount: i64, + pub is_public: bool, + pub claim_key_hash: Option, + pub encrypted_key: Option>, + pub tip_address: Option, + pub funding_txid: Option, + pub status: String, + pub funding_confirmations: i32, + pub funding_confirmed_at: Option>, + pub last_confirmation_check: Option>, + pub confirmations_required: i32, + pub funding_amount_verified: bool, + pub funding_amount_observed: Option, + pub funding_amount_verified_at: Option>, + pub claimed_at: Option>, + pub claimed_by_user_id: Option, + pub clawed_back_at: Option>, + pub sweep_txid: Option, + pub sweep_confirmed_at: Option>, + pub sweep_block_height: Option, + pub sweep_block_hash: Option, + pub sweep_confirmed_dm_sent_at: Option>, + pub reorg_notified_at: Option>, + pub tip_view_key: Option, + pub lws_registered_at: Option>, + pub lws_deactivated_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl Database { + /// Fetch a tip by id, or `None`. The UUID is the public bearer token behind + /// a share URL, so this read is intentionally not owner-scoped. + #[instrument(skip(self))] + #[allow(dead_code)] // consumed by the tips routes in the next stage + pub async fn get_social_tip(&self, id: Uuid) -> Result, AppError> { + let row = sqlx::query_as::<_, SocialTipRow>(&format!( + "SELECT {TIP_COLS} FROM social_tips WHERE id = $1" + )) + .bind(id) + .fetch_optional(self.pool()) + .await?; + Ok(row) + } +} diff --git a/src/models/mod.rs b/src/models/mod.rs index 36839dd..af09b96 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -3,3 +3,4 @@ //! Request/response DTOs are added alongside the handlers that use them. pub mod db; +pub mod tip_status; diff --git a/src/models/tip_status.rs b/src/models/tip_status.rs new file mode 100644 index 0000000..dce5fe3 --- /dev/null +++ b/src/models/tip_status.rs @@ -0,0 +1,165 @@ +//! `TipStatus` — single source of truth for the `social_tips.status` column. +//! +//! The wire/DB strings here MUST match the `social_tips_status_check` CHECK +//! constraint in `migrations/20260710000001_social_tips.sql` exactly. Setting +//! status in SQL uses `.bind(TipStatus::Pending.as_str())` so the literal +//! matches the constraint by construction; `WHERE status IN (...)` filters stay +//! raw string literals (the CHECK constraint already locks the column). +//! +//! When adding a status, land in the same change: the variant here (+ `as_str` +//! / `from_db` arms), a migration extending the CHECK constraint (a fresh ALTER, +//! never editing a deployed one), and every SQL `status IN (...)` guard. + +use std::fmt; + +/// The set of legal `social_tips.status` values (lifecycle order). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum TipStatus { + /// Two-phase creation, pre-broadcast: `funding_txid` not attached yet. Pure + /// DB row, no on-chain footprint. + Draft, + /// Funding tx attached but not yet confirmed to threshold, or the verifier + /// hasn't observed the on-chain receipt. The verifier flips this to + /// `Pending` (covered), `FundingMismatch` (short), or leaves it here. + PendingConfirmation, + /// Funding confirmed AND amount-verified: claimable (URL holders can claim). + Pending, + /// `mark_tip_claiming` lock held; claimer is mid-sweep. Settles to `Claimed` + /// when the sweep confirms on-chain. + Claiming, + /// Sweep confirmed on-chain. Terminal. + Claimed, + /// Sender reclaimed the funds via clawback. Terminal. + ClawedBack, + /// Draft abandoned (manual cancel or GC of a stale draft / stuck + /// pending_confirmation). May still hold on-chain funds — clawback covers + /// the recoverable case. Terminal for the forward lifecycle. + Cancelled, + /// Verifier saw a positive on-chain receipt strictly less than the declared + /// amount. Never becomes claimable; the sender can clawback. NON-terminal. + FundingMismatch, +} + +impl TipStatus { + /// Wire / DB string. MUST match the CHECK constraint exactly. + pub const fn as_str(self) -> &'static str { + match self { + TipStatus::Draft => "draft", + TipStatus::PendingConfirmation => "pending_confirmation", + TipStatus::Pending => "pending", + TipStatus::Claiming => "claiming", + TipStatus::Claimed => "claimed", + TipStatus::ClawedBack => "clawed_back", + TipStatus::Cancelled => "cancelled", + TipStatus::FundingMismatch => "funding_mismatch", + } + } + + /// Parse the DB string back into a `TipStatus`. `None` for any value outside + /// the CHECK set (we prefer `None` over a panic on an unexpected string). + pub fn from_db(s: &str) -> Option { + match s { + "draft" => Some(TipStatus::Draft), + "pending_confirmation" => Some(TipStatus::PendingConfirmation), + "pending" => Some(TipStatus::Pending), + "claiming" => Some(TipStatus::Claiming), + "claimed" => Some(TipStatus::Claimed), + "clawed_back" => Some(TipStatus::ClawedBack), + "cancelled" => Some(TipStatus::Cancelled), + "funding_mismatch" => Some(TipStatus::FundingMismatch), + _ => None, + } + } + + /// True iff the row is past the lifecycle's terminal cliff. The verifier and + /// reconciler skip already-settled rows. `FundingMismatch` is NON-terminal + /// (the sender can still clawback). + pub const fn is_terminal(self) -> bool { + matches!( + self, + TipStatus::Claimed | TipStatus::ClawedBack | TipStatus::Cancelled, + ) + } +} + +impl fmt::Display for TipStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Confirmations required before an asset's funding tx is spendable/claimable. +/// XMR 10, WOW 4, BTC/LTC 0 (immediately spendable). Grin is not supported in +/// the public-tips port yet. +pub const fn confirmations_for_asset(asset: &str) -> i32 { + match asset.as_bytes() { + b"xmr" => 10, + b"wow" => 4, + _ => 0, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trip_every_variant() { + for variant in [ + TipStatus::Draft, + TipStatus::PendingConfirmation, + TipStatus::Pending, + TipStatus::Claiming, + TipStatus::Claimed, + TipStatus::ClawedBack, + TipStatus::Cancelled, + TipStatus::FundingMismatch, + ] { + assert_eq!( + TipStatus::from_db(variant.as_str()), + Some(variant), + "round-trip failed for {variant:?}", + ); + } + } + + #[test] + fn wire_strings_match_check_constraint() { + assert_eq!(TipStatus::Draft.as_str(), "draft"); + assert_eq!(TipStatus::PendingConfirmation.as_str(), "pending_confirmation"); + assert_eq!(TipStatus::Pending.as_str(), "pending"); + assert_eq!(TipStatus::Claiming.as_str(), "claiming"); + assert_eq!(TipStatus::Claimed.as_str(), "claimed"); + assert_eq!(TipStatus::ClawedBack.as_str(), "clawed_back"); + assert_eq!(TipStatus::Cancelled.as_str(), "cancelled"); + assert_eq!(TipStatus::FundingMismatch.as_str(), "funding_mismatch"); + } + + #[test] + fn only_claimed_clawedback_cancelled_are_terminal() { + assert!(TipStatus::Claimed.is_terminal()); + assert!(TipStatus::ClawedBack.is_terminal()); + assert!(TipStatus::Cancelled.is_terminal()); + assert!(!TipStatus::FundingMismatch.is_terminal()); + assert!(!TipStatus::Pending.is_terminal()); + assert!(!TipStatus::Draft.is_terminal()); + assert!(!TipStatus::PendingConfirmation.is_terminal()); + assert!(!TipStatus::Claiming.is_terminal()); + } + + #[test] + fn from_db_returns_none_for_unknown() { + assert_eq!(TipStatus::from_db("bogus"), None); + assert_eq!(TipStatus::from_db(""), None); + assert_eq!(TipStatus::from_db("Draft"), None); + } + + #[test] + fn confirmations_by_asset() { + assert_eq!(confirmations_for_asset("xmr"), 10); + assert_eq!(confirmations_for_asset("wow"), 4); + assert_eq!(confirmations_for_asset("btc"), 0); + assert_eq!(confirmations_for_asset("ltc"), 0); + assert_eq!(confirmations_for_asset("grin"), 0); + } +}