mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
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:
@@ -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();
|
||||||
@@ -20,6 +20,7 @@ mod premium_invoices;
|
|||||||
mod restore_attempts;
|
mod restore_attempts;
|
||||||
mod server_config;
|
mod server_config;
|
||||||
mod sessions;
|
mod sessions;
|
||||||
|
mod social_tips;
|
||||||
mod user_keys;
|
mod user_keys;
|
||||||
mod users;
|
mod users;
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ pub use login_events::LoginStats;
|
|||||||
pub use payment_invoices::PaymentInvoiceRow;
|
pub use payment_invoices::PaymentInvoiceRow;
|
||||||
pub use premium_invoices::PremiumInvoiceRow;
|
pub use premium_invoices::PremiumInvoiceRow;
|
||||||
pub use server_config::SetupState;
|
pub use server_config::SetupState;
|
||||||
|
pub use social_tips::SocialTipRow;
|
||||||
|
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,3 +3,4 @@
|
|||||||
//! Request/response DTOs are added alongside the handlers that use them.
|
//! Request/response DTOs are added alongside the handlers that use them.
|
||||||
|
|
||||||
pub mod db;
|
pub mod db;
|
||||||
|
pub mod tip_status;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user