feat(tips): stage 1 — social_tips schema + TipStatus enum + row model

Public-only social-tips foundation ported from legacy smirk-backend:
- migration 20260710000001_social_tips.sql: consolidated public-tip table
  (status CHECK + public_tip_has_hash + worker indexes), targeted/recipient
  columns and the socials/bot surface dropped.
- models::tip_status: TipStatus single-source-of-truth enum (8 variants,
  as_str/from_db/is_terminal) + confirmations_for_asset.
- infra::db::social_tips: SocialTipRow (FromRow) + TIP_COLS + get_social_tip.

Inert (no routes yet); FEATURE_TIPS stays off. Verified: migration applies,
5 TipStatus unit tests pass.
This commit is contained in:
jwinterm
2026-07-10 08:17:06 -04:00
parent 81148945c0
commit 6f34cb9245
5 changed files with 369 additions and 0 deletions
+2
View File
@@ -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;
+83
View File
@@ -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<String>,
pub encrypted_key: Option<Vec<u8>>,
pub tip_address: Option<String>,
pub funding_txid: Option<String>,
pub status: String,
pub funding_confirmations: i32,
pub funding_confirmed_at: Option<DateTime<Utc>>,
pub last_confirmation_check: Option<DateTime<Utc>>,
pub confirmations_required: i32,
pub funding_amount_verified: bool,
pub funding_amount_observed: Option<i64>,
pub funding_amount_verified_at: Option<DateTime<Utc>>,
pub claimed_at: Option<DateTime<Utc>>,
pub claimed_by_user_id: Option<Uuid>,
pub clawed_back_at: Option<DateTime<Utc>>,
pub sweep_txid: Option<String>,
pub sweep_confirmed_at: Option<DateTime<Utc>>,
pub sweep_block_height: Option<i32>,
pub sweep_block_hash: Option<String>,
pub sweep_confirmed_dm_sent_at: Option<DateTime<Utc>>,
pub reorg_notified_at: Option<DateTime<Utc>>,
pub tip_view_key: Option<String>,
pub lws_registered_at: Option<DateTime<Utc>>,
pub lws_deactivated_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
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<Option<SocialTipRow>, 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)
}
}
+1
View File
@@ -3,3 +3,4 @@
//! Request/response DTOs are added alongside the handlers that use them.
pub mod db;
pub mod tip_status;
+165
View File
@@ -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<Self> {
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);
}
}