diff --git a/src/api/mod.rs b/src/api/mod.rs index 51922d3..338b97a 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -11,6 +11,7 @@ pub mod nip05; pub mod openapi; pub mod premium; pub mod prices; +pub mod tips; pub mod users; pub mod wallet; pub mod website; diff --git a/src/api/openapi.rs b/src/api/openapi.rs index 5bbfdd3..da7322f 100644 --- a/src/api/openapi.rs +++ b/src/api/openapi.rs @@ -68,6 +68,10 @@ use utoipa::OpenApi; crate::api::premium::invoice, crate::api::premium::activate, crate::api::premium::status, + crate::api::tips::create_social_tip, + crate::api::tips::get_sent_social_tips, + crate::api::tips::get_public_social_tip, + crate::api::tips::cancel_social_tip, ), components(schemas( crate::api::health::HealthResponse, @@ -158,6 +162,12 @@ use utoipa::OpenApi; crate::api::premium::InvoiceResp, crate::api::premium::ActivateReq, crate::api::premium::StatusResp, + crate::api::tips::CreateSocialTipRequest, + crate::api::tips::CreateSocialTipResponse, + crate::api::tips::SentTip, + crate::api::tips::SocialTipsResponse, + crate::api::tips::PublicTipInfo, + crate::api::tips::CancelTipResponse, )), tags( (name = "system", description = "Service health and metadata."), @@ -167,7 +177,8 @@ use utoipa::OpenApi; (name = "xmr_wow", description = "Monero/Wownero chain access via LWS (register, balance, history, unspent/random outs, submit, height, confirmations)."), (name = "grin", description = "Grin view-only chain access (rewind_hash scan, tip height, broadcast)."), (name = "grin_relay", description = "Grin slatepack relay: async non-custodial mailbox for interactive Grin transfers (feature-flagged)."), - (name = "premium", description = "Premium subscription: mint a tier invoice, activate on settlement, read status (feature-flagged).") + (name = "premium", description = "Premium subscription: mint a tier invoice, activate on settlement, read status (feature-flagged)."), + (name = "tips", description = "Public social tips: create/fund/claim/clawback share-URL tips (feature-flagged).") ) )] pub struct ApiDoc; diff --git a/src/api/tips.rs b/src/api/tips.rs new file mode 100644 index 0000000..c1c1107 --- /dev/null +++ b/src/api/tips.rs @@ -0,0 +1,417 @@ +//! Public social-tips endpoints. +//! +//! A sender funds a per-tip address and shares a claim URL whose fragment +//! carries the claim key; anyone with the URL claims by sweeping the address to +//! their own wallet. Non-custodial: the backend stores only the encrypted claim +//! blob and a hash of the claim key — never the key or the funds. +//! +//! PUBLIC tips only. Targeted (@username) tips and the socials/bot surface are +//! out of scope for this port: an `is_public = false` request is rejected. + +use std::sync::Arc; + +use axum::{ + extract::{Path, State}, + http::HeaderMap, + routing::{get, post}, + Json, Router, +}; +use serde::{Deserialize, Serialize}; +use tracing::instrument; +use uuid::Uuid; + +use crate::api::middleware::extract_user_id_from_token; +use crate::api::wallet::validate_cn_address; +use crate::error::AppError; +use crate::infra::db::{NewSocialTip, SocialTipRow}; +use crate::models::tip_status::{confirmations_for_asset, TipStatus}; +use crate::AppState; + +/// Max `encrypted_key` size on the wire: 4096 hex chars = 2048 bytes. +const MAX_ENCRYPTED_KEY_HEX: usize = 4096; + +// ── DTOs (match packages/core/src/api/social.ts verbatim) ──────────────────── + +/// Create a public tip. Targeted-only fields (`platform`, `username`, +/// `grin_commitment`, `sender_anonymous`) are accepted for wire-compat but +/// ignored in this port. +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct CreateSocialTipRequest { + #[serde(default)] + pub platform: Option, + #[serde(default)] + pub username: Option, + pub asset: String, + pub amount: i64, + /// Hex-encoded AES-GCM ciphertext of the claim secret; the backend cannot + /// decrypt it. + #[serde(default)] + pub encrypted_key: Option, + pub is_public: bool, + /// SHA256 of the claim key (hex); the key itself never touches the server. + #[serde(default)] + pub claim_key_hash: Option, + #[serde(default)] + pub tip_address: Option, + #[serde(default)] + pub funding_txid: Option, + /// Private view key for the tip address (XMR/WOW), so the backend can scan + /// it for funding + sweep. + #[serde(default)] + pub tip_view_key: Option, + #[serde(default)] + pub grin_commitment: Option, + #[serde(default)] + pub sender_anonymous: bool, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct CreateSocialTipResponse { + pub tip_id: String, + pub status: String, + /// `{TIP_SHARE_BASE_URL}/{tip_id}` for a public tip, else null. + pub share_url: Option, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct SentTip { + pub id: String, + pub sender_user_id: String, + /// Always null in the public-only port (no targeted recipients). + pub recipient_platform: Option, + pub recipient_username: Option, + pub asset: String, + pub amount: i64, + pub is_public: bool, + pub status: String, + pub created_at: String, + pub claimed_at: Option, + pub clawed_back_at: Option, + pub funding_confirmations: i32, + pub confirmations_required: i32, + pub is_claimable: bool, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct SocialTipsResponse { + pub tips: Vec, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct PublicTipInfo { + pub id: String, + pub asset: String, + pub amount: i64, + pub status: String, + pub created_at: String, + pub is_public: bool, + /// Hex-encoded AES-GCM ciphertext; useless without the URL-fragment key. + pub encrypted_key: Option, + pub tip_address: Option, + pub funding_confirmations: i32, + pub confirmations_required: i32, + pub is_claimable: bool, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct CancelTipResponse { + pub ok: bool, +} + +// ── helpers ────────────────────────────────────────────────────────────────── + +/// Reject when tips are not enabled on this instance. +fn ensure_tips_enabled(state: &AppState) -> Result<(), AppError> { + if state.config.features.tips { + Ok(()) + } else { + Err(AppError::ValidationError( + "Tips are not available on this instance.".into(), + )) + } +} + +/// Whether `asset` is a tip-capable chain enabled on this instance. Grin is not +/// supported by the tips port yet. +fn supported_tip_asset(state: &AppState, asset: &str) -> bool { + let c = &state.config.features.chains; + match asset { + "btc" => c.btc, + "ltc" => c.ltc, + "xmr" => c.xmr, + "wow" => c.wow, + _ => false, + } +} + +/// Basic tip-address validation. XMR/WOW reuse the CryptoNote validator; BTC/LTC +/// get a light sanity check (the sender funds and later sweeps THEIR OWN +/// address, so a malformed one merely fails funding verification — it can't +/// misdirect funds to a third party). +fn validate_tip_address(asset: &str, address: &str) -> Result<(), AppError> { + if address.is_empty() || address.len() > 255 { + return Err(AppError::ValidationError("tip_address is invalid.".into())); + } + match asset { + "xmr" | "wow" => validate_cn_address(address), + "btc" | "ltc" => { + if address.len() >= 14 && address.chars().all(|c| c.is_ascii_alphanumeric()) { + Ok(()) + } else { + Err(AppError::ValidationError("tip_address is invalid.".into())) + } + } + _ => Err(AppError::ValidationError("unsupported tip asset.".into())), + } +} + +/// A tip is claimable iff funding is confirmed AND amount-verified AND it hasn't +/// left the claimable window. +fn is_claimable(row: &SocialTipRow) -> bool { + matches!( + TipStatus::from_db(&row.status), + Some(TipStatus::Pending | TipStatus::Claiming) + ) && row.funding_confirmations >= row.confirmations_required + && row.funding_amount_verified +} + +fn to_sent_tip(row: SocialTipRow) -> SentTip { + let claimable = is_claimable(&row); + SentTip { + id: row.id.to_string(), + sender_user_id: row.sender_user_id.to_string(), + recipient_platform: None, + recipient_username: None, + asset: row.asset, + amount: row.amount, + is_public: row.is_public, + status: row.status, + created_at: row.created_at.to_rfc3339(), + claimed_at: row.claimed_at.map(|t| t.to_rfc3339()), + clawed_back_at: row.clawed_back_at.map(|t| t.to_rfc3339()), + funding_confirmations: row.funding_confirmations, + confirmations_required: row.confirmations_required, + is_claimable: claimable, + } +} + +fn to_public_info(row: SocialTipRow) -> PublicTipInfo { + let claimable = is_claimable(&row); + PublicTipInfo { + id: row.id.to_string(), + asset: row.asset, + amount: row.amount, + status: row.status, + created_at: row.created_at.to_rfc3339(), + is_public: row.is_public, + encrypted_key: row.encrypted_key.map(hex::encode), + tip_address: row.tip_address, + funding_confirmations: row.funding_confirmations, + confirmations_required: row.confirmations_required, + is_claimable: claimable, + } +} + +// ── handlers ───────────────────────────────────────────────────────────────── + +/// Create a public tip (optionally as a two-phase draft). +#[utoipa::path( + security(("bearer_auth" = [])), + post, + path = "/tips/social", + request_body = CreateSocialTipRequest, + responses( + (status = 200, description = "Tip created", body = CreateSocialTipResponse), + (status = 400, description = "Tips off, targeted tip, or invalid input"), + (status = 401, description = "Missing or invalid token") + ), + tag = "tips" +)] +#[instrument(skip(state, headers, req))] +pub async fn create_social_tip( + State(state): State>, + headers: HeaderMap, + Json(req): Json, +) -> Result, AppError> { + let user_id = extract_user_id_from_token(&state, &headers).await?; + ensure_tips_enabled(&state)?; + + // Public-only port: targeted tips are rejected outright. + if !req.is_public { + return Err(AppError::ValidationError( + "Targeted tips are not supported on this instance; set is_public = true.".into(), + )); + } + if req.amount <= 0 { + return Err(AppError::ValidationError("amount must be positive.".into())); + } + let asset = req.asset.to_lowercase(); + if !supported_tip_asset(&state, &asset) { + return Err(AppError::ValidationError(format!( + "asset '{asset}' is not enabled for tips on this instance." + ))); + } + + // Public tips require the claim-key hash (mirrors the public_tip_has_hash CHECK). + let claim_key_hash = req + .claim_key_hash + .as_deref() + .filter(|s| !s.is_empty()) + .ok_or_else(|| { + AppError::ValidationError("claim_key_hash is required for a public tip.".into()) + })?; + // A claim-key hash is SHA256(claim key) = exactly 64 hex chars. Validate the + // shape so malformed input is a clean 400, not a DB length-overflow 500. + if claim_key_hash.len() != 64 || hex::decode(claim_key_hash).is_err() { + return Err(AppError::ValidationError( + "claim_key_hash must be a 64-character hex SHA256.".into(), + )); + } + + // encrypted_key: hex, capped at 4096 hex chars. + let encrypted_key: Option> = match req.encrypted_key.as_deref() { + Some(h) if !h.is_empty() => { + if h.len() > MAX_ENCRYPTED_KEY_HEX { + return Err(AppError::ValidationError("encrypted_key is too large.".into())); + } + Some( + hex::decode(h) + .map_err(|_| AppError::ValidationError("encrypted_key must be hex.".into()))?, + ) + } + _ => None, + }; + + let tip_address = req.tip_address.as_deref().filter(|s| !s.is_empty()); + let funding_txid = req.funding_txid.as_deref().filter(|s| !s.is_empty()); + let is_draft = funding_txid.is_none(); + // A draft (no funding yet) must carry the address the sender will fund. + if is_draft && tip_address.is_none() { + return Err(AppError::ValidationError( + "tip_address is required to create a tip draft.".into(), + )); + } + if let Some(addr) = tip_address { + validate_tip_address(&asset, addr)?; + } + + let new = NewSocialTip { + sender_user_id: user_id, + asset: &asset, + amount: req.amount, + claim_key_hash: Some(claim_key_hash), + encrypted_key: encrypted_key.as_deref(), + tip_address, + funding_txid, + tip_view_key: req.tip_view_key.as_deref().filter(|s| !s.is_empty()), + confirmations_required: confirmations_for_asset(&asset), + }; + + let tip = if is_draft { + state.db.create_draft_social_tip(new).await? + } else { + state.db.create_social_tip(new).await? + }; + + let share_url = state + .config + .tip_share_base + .as_ref() + .map(|base| format!("{}/{}", base.trim_end_matches('/'), tip.id)); + + Ok(Json(CreateSocialTipResponse { + tip_id: tip.id.to_string(), + status: tip.status, + share_url, + })) +} + +/// All tips the caller has sent, newest first. +#[utoipa::path( + security(("bearer_auth" = [])), + get, + path = "/tips/social/sent", + responses( + (status = 200, description = "Sent tips", body = SocialTipsResponse), + (status = 400, description = "Tips off"), + (status = 401, description = "Missing or invalid token") + ), + tag = "tips" +)] +#[instrument(skip(state, headers))] +pub async fn get_sent_social_tips( + State(state): State>, + headers: HeaderMap, +) -> Result, AppError> { + let user_id = extract_user_id_from_token(&state, &headers).await?; + ensure_tips_enabled(&state)?; + let rows = state.db.get_sent_social_tips(user_id).await?; + Ok(Json(SocialTipsResponse { + tips: rows.into_iter().map(to_sent_tip).collect(), + })) +} + +/// Public tip metadata for a share-URL holder. UNAUTHENTICATED: the tip id (a +/// UUID) is the bearer token. 404s for an unknown or non-public tip. +#[utoipa::path( + get, + path = "/tips/social/{tip_id}/public", + params(("tip_id" = String, Path, description = "Tip id")), + responses( + (status = 200, description = "Public tip info", body = PublicTipInfo), + (status = 400, description = "Tips off"), + (status = 404, description = "No such public tip") + ), + tag = "tips" +)] +#[instrument(skip(state))] +pub async fn get_public_social_tip( + State(state): State>, + Path(tip_id): Path, +) -> Result, AppError> { + ensure_tips_enabled(&state)?; + let row = state + .db + .get_social_tip(tip_id) + .await? + .filter(|r| r.is_public) + .ok_or_else(|| AppError::NotFound("tip not found".into()))?; + Ok(Json(to_public_info(row))) +} + +/// Cancel a still-unfunded draft (owner-only, draft-only). A funded/claimed tip +/// is recovered via clawback, not cancel. +#[utoipa::path( + security(("bearer_auth" = [])), + post, + path = "/tips/social/{tip_id}/cancel", + params(("tip_id" = String, Path, description = "Tip id")), + responses( + (status = 200, description = "Draft cancelled", body = CancelTipResponse), + (status = 400, description = "Tips off"), + (status = 401, description = "Missing or invalid token"), + (status = 404, description = "No cancellable draft") + ), + tag = "tips" +)] +#[instrument(skip(state, headers))] +pub async fn cancel_social_tip( + State(state): State>, + headers: HeaderMap, + Path(tip_id): Path, +) -> Result, AppError> { + let user_id = extract_user_id_from_token(&state, &headers).await?; + ensure_tips_enabled(&state)?; + match state.db.cancel_draft_social_tip(tip_id, user_id).await? { + Some(_) => Ok(Json(CancelTipResponse { ok: true })), + None => Err(AppError::NotFound("no cancellable draft tip".into())), + } +} + +pub fn routes() -> Router> { + Router::new() + .route("/tips/social", post(create_social_tip)) + .route("/tips/social/sent", get(get_sent_social_tips)) + .route("/tips/social/:tip_id/public", get(get_public_social_tip)) + .route("/tips/social/:tip_id/cancel", post(cancel_social_tip)) +} diff --git a/src/config.rs b/src/config.rs index c8e5506..9e8c15b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -144,6 +144,11 @@ pub struct Config { pub database_url: String, + /// Base URL for public-tip share links: a tip's share URL is + /// `{tip_share_base}/{tip_id}`. Required when `FEATURE_TIPS` is on (checked + /// in `validate`); never hardcode a host (federation). + pub tip_share_base: Option, + pub auth: AuthConfig, pub identity: IdentityConfig, pub secrets: SecretConfig, @@ -705,6 +710,8 @@ impl Config { database_url: env_opt("DATABASE_URL") .ok_or_else(|| cfg_err("DATABASE_URL is required"))?, + tip_share_base: env_opt("TIP_SHARE_BASE_URL"), + auth: AuthConfig { jwt_secret: env_or("JWT_SECRET", ""), jwt_expiry_hours: env_parse("JWT_EXPIRY_HOURS", 24u64)?, @@ -1334,6 +1341,7 @@ mod tests { deployment_mode: DeploymentMode::Single, environment: "development".into(), database_url: "postgres://localhost/smirk".into(), + tip_share_base: None, auth: AuthConfig { jwt_secret: "a".repeat(32), jwt_expiry_hours: 24, diff --git a/src/infra/db/mod.rs b/src/infra/db/mod.rs index 3dbaba4..4aa52ed 100644 --- a/src/infra/db/mod.rs +++ b/src/infra/db/mod.rs @@ -30,7 +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; +pub use social_tips::{NewSocialTip, SocialTipRow}; use sqlx::PgPool; diff --git a/src/infra/db/social_tips.rs b/src/infra/db/social_tips.rs index 0906eea..5b2b1b4 100644 --- a/src/infra/db/social_tips.rs +++ b/src/infra/db/social_tips.rs @@ -12,6 +12,7 @@ use tracing::instrument; use uuid::Uuid; use crate::error::AppError; +use crate::models::tip_status::TipStatus; use super::Database; @@ -66,11 +67,25 @@ pub struct SocialTipRow { pub updated_at: DateTime, } +/// Values for a fresh public tip. `status` is set by the caller +/// (`draft` for the two-phase flow, `pending_confirmation` when funding is +/// attached at create time). +pub struct NewSocialTip<'a> { + pub sender_user_id: Uuid, + pub asset: &'a str, + pub amount: i64, + pub claim_key_hash: Option<&'a str>, + pub encrypted_key: Option<&'a [u8]>, + pub tip_address: Option<&'a str>, + pub funding_txid: Option<&'a str>, + pub tip_view_key: Option<&'a str>, + pub confirmations_required: i32, +} + 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" @@ -80,4 +95,93 @@ impl Database { .await?; Ok(row) } + + /// Insert a public tip in `status`. Always `is_public = TRUE`. + #[instrument(skip(self, new))] + async fn insert_social_tip( + &self, + new: NewSocialTip<'_>, + status: TipStatus, + ) -> Result { + let row = sqlx::query_as::<_, SocialTipRow>(&format!( + "INSERT INTO social_tips \ + (sender_user_id, asset, amount, is_public, claim_key_hash, encrypted_key, \ + tip_address, funding_txid, status, confirmations_required, tip_view_key) \ + VALUES ($1, $2, $3, TRUE, $4, $5, $6, $7, $8, $9, $10) \ + RETURNING {TIP_COLS}" + )) + .bind(new.sender_user_id) + .bind(new.asset) + .bind(new.amount) + .bind(new.claim_key_hash) + .bind(new.encrypted_key) + .bind(new.tip_address) + .bind(new.funding_txid) + .bind(status.as_str()) + .bind(new.confirmations_required) + .bind(new.tip_view_key) + .fetch_one(self.pool()) + .await?; + Ok(row) + } + + /// Two-phase create: a `draft` tip with no funding attached yet. The sender + /// funds `tip_address` off-band, then calls attach-funding to advance it. + #[instrument(skip(self, new))] + pub async fn create_draft_social_tip( + &self, + new: NewSocialTip<'_>, + ) -> Result { + debug_assert!(new.funding_txid.is_none(), "a draft has no funding_txid"); + self.insert_social_tip(new, TipStatus::Draft).await + } + + /// Single-call create with funding already attached: lands in + /// `pending_confirmation` so the funding verifier (not the caller) owns the + /// transition to `pending`. + #[instrument(skip(self, new))] + pub async fn create_social_tip( + &self, + new: NewSocialTip<'_>, + ) -> Result { + self.insert_social_tip(new, TipStatus::PendingConfirmation).await + } + + /// All tips this user has sent, newest first (every status). + #[instrument(skip(self))] + pub async fn get_sent_social_tips( + &self, + sender_user_id: Uuid, + ) -> Result, AppError> { + let rows = sqlx::query_as::<_, SocialTipRow>(&format!( + "SELECT {TIP_COLS} FROM social_tips \ + WHERE sender_user_id = $1 ORDER BY created_at DESC" + )) + .bind(sender_user_id) + .fetch_all(self.pool()) + .await?; + Ok(rows) + } + + /// Cancel a still-unfunded draft. Owner-scoped and `draft`-only, so it can + /// never cancel a funded/claimed tip (that path is clawback). Returns the id + /// if this call cancelled it; `None` if no such draft (wrong owner, wrong + /// status, or unknown id). + #[instrument(skip(self))] + pub async fn cancel_draft_social_tip( + &self, + id: Uuid, + sender_user_id: Uuid, + ) -> Result, AppError> { + let cancelled = sqlx::query_scalar::<_, Uuid>( + "UPDATE social_tips SET status = 'cancelled' \ + WHERE id = $1 AND sender_user_id = $2 AND status = 'draft' \ + RETURNING id", + ) + .bind(id) + .bind(sender_user_id) + .fetch_optional(self.pool()) + .await?; + Ok(cancelled) + } } diff --git a/src/lib.rs b/src/lib.rs index 1f7ce92..41b9dd6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -114,6 +114,7 @@ pub fn build_router(state: Arc) -> Router { .merge(api::landing::routes()) .merge(api::erasure::routes()) .merge(api::premium::routes()) + .merge(api::tips::routes()) .layer(normal), ); diff --git a/tests/tips.rs b/tests/tips.rs new file mode 100644 index 0000000..6bf8234 --- /dev/null +++ b/tests/tips.rs @@ -0,0 +1,170 @@ +//! Public social-tips route tests (Stage 2: create / get-public / get-sent / +//! cancel). Self-skips when `TEST_DATABASE_URL` is unset (see `tests/common`). + +mod common; + +use axum::http::StatusCode; +use serde_json::json; + +/// Boot the app with tips ENABLED + a share base configured (BTC is on by +/// default in the test config). Returns None (skip) when no test DB. +async fn tips_app() -> Option { + common::try_app_with(|c| { + c.features.tips = true; + c.tip_share_base = Some("https://tips.example".into()); + }) + .await +} + +/// A valid SHA256 hash shape: exactly 64 hex chars (4 × 16). +const CLAIM_HASH: &str = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"; +const ENC_KEY_HEX: &str = "cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd"; +const BTC_ADDR: &str = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"; + +fn draft_body() -> serde_json::Value { + json!({ + "asset": "btc", + "amount": 100_000, + "is_public": true, + "claim_key_hash": CLAIM_HASH, + "encrypted_key": ENC_KEY_HEX, + "tip_address": BTC_ADDR, + }) +} + +#[tokio::test] +async fn public_tip_draft_create_get_cancel_flow() { + let Some(app) = tips_app().await else { + eprintln!("skipping: TEST_DATABASE_URL not set"); + return; + }; + let (_uid, token, _refresh) = app.mint_session().await; + + // 1. Create a draft (no funding_txid) -> draft + share_url. + let (status, body) = app + .request("POST", "/api/v1/tips/social", Some(&token), Some(draft_body())) + .await; + assert_eq!(status, StatusCode::OK, "create: {body}"); + assert_eq!(body["status"], "draft"); + let tip_id = body["tip_id"].as_str().expect("tip_id").to_string(); + assert_eq!(body["share_url"], format!("https://tips.example/{tip_id}")); + + // 2. Public read (UNAUTH) surfaces it and is not yet claimable. + let (status, pub_body) = app + .request("GET", &format!("/api/v1/tips/social/{tip_id}/public"), None, None) + .await; + assert_eq!(status, StatusCode::OK, "get_public: {pub_body}"); + assert_eq!(pub_body["is_public"], true); + assert_eq!(pub_body["is_claimable"], false); + assert_eq!(pub_body["encrypted_key"], ENC_KEY_HEX); + assert_eq!(pub_body["tip_address"], BTC_ADDR); + + // 3. Unknown id -> 404 (not 200-with-nulls). + let (status, _) = app + .request( + "GET", + "/api/v1/tips/social/00000000-0000-0000-0000-000000000000/public", + None, + None, + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); + + // 4. Cancel the draft (owner) -> {ok:true}. + let (status, cancel_body) = app + .request("POST", &format!("/api/v1/tips/social/{tip_id}/cancel"), Some(&token), None) + .await; + assert_eq!(status, StatusCode::OK, "cancel: {cancel_body}"); + assert_eq!(cancel_body["ok"], true); + + // 5. Cancelling again -> 404 (no longer a draft). + let (status, _) = app + .request("POST", &format!("/api/v1/tips/social/{tip_id}/cancel"), Some(&token), None) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); + + // 6. Sent list shows the tip as cancelled. + let (status, sent) = app + .request("GET", "/api/v1/tips/social/sent", Some(&token), None) + .await; + assert_eq!(status, StatusCode::OK, "sent: {sent}"); + let tips = sent["tips"].as_array().expect("tips array"); + let found = tips.iter().find(|t| t["id"] == tip_id).expect("tip in sent list"); + assert_eq!(found["status"], "cancelled"); + assert_eq!(found["is_public"], true); +} + +#[tokio::test] +async fn db_create_draft_roundtrips_all_columns() { + let Some(app) = tips_app().await else { return }; + let uid = app.create_user().await; + use smirk_backend_core::infra::db::NewSocialTip; + let enc = hex::decode("cdcd").unwrap(); + let new = NewSocialTip { + sender_user_id: uid, + asset: "btc", + amount: 100_000, + claim_key_hash: Some(CLAIM_HASH), + encrypted_key: Some(&enc), + tip_address: Some(BTC_ADDR), + funding_txid: None, + tip_view_key: None, + confirmations_required: 0, + }; + // Exercises the full 31-column FromRow decode on RETURNING. + let row = app.state.db.create_draft_social_tip(new).await.expect("create draft"); + assert_eq!(row.status, "draft"); + assert_eq!(row.amount, 100_000); + assert!(row.is_public); + assert_eq!(row.encrypted_key.as_deref(), Some(&[0xcd, 0xcd][..])); +} + +#[tokio::test] +async fn targeted_tip_is_rejected() { + let Some(app) = tips_app().await else { return }; + let (_uid, token, _r) = app.mint_session().await; + let (status, body) = app + .request( + "POST", + "/api/v1/tips/social", + Some(&token), + Some(json!({ + "asset": "btc", "amount": 100, "is_public": false, + "platform": "telegram", "username": "bob", + })), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "targeted must be rejected: {body}"); +} + +#[tokio::test] +async fn create_requires_positive_amount_and_claim_hash() { + let Some(app) = tips_app().await else { return }; + let (_uid, token, _r) = app.mint_session().await; + + // Non-positive amount. + let mut b = draft_body(); + b["amount"] = json!(0); + let (status, _) = app.request("POST", "/api/v1/tips/social", Some(&token), Some(b)).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + + // Missing claim_key_hash on a public tip. + let mut b = draft_body(); + b.as_object_mut().unwrap().remove("claim_key_hash"); + let (status, _) = app.request("POST", "/api/v1/tips/social", Some(&token), Some(b)).await; + assert_eq!(status, StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn tips_disabled_returns_400() { + // Default config: FEATURE_TIPS off. + let Some(app) = common::try_app().await else { + eprintln!("skipping: TEST_DATABASE_URL not set"); + return; + }; + let (_uid, token, _r) = app.mint_session().await; + let (status, _) = app + .request("POST", "/api/v1/tips/social", Some(&token), Some(draft_body())) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "tips off must 400"); +}