mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
feat(tips): stage 2 — create / get-public / get-sent / cancel routes
Public-tip create/read/cancel surface matching the client contract: - POST /tips/social (draft or funded), GET /tips/social/sent, GET /tips/social/:id/public (unauth; 404s non-public), POST .../:id/cancel. - DB: insert (draft/pending_confirmation), get_sent, guarded cancel_draft (owner + draft-only). - Validates is_public (targeted rejected), amount>0, claim_key_hash 64-hex, encrypted_key <=4096 hex, tip_address; share_url from TIP_SHARE_BASE_URL. - ensure_tips_enabled gate; openapi paths+schemas+tag registered. No funds move. 5 route/DB tests pass.
This commit is contained in:
@@ -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;
|
||||
|
||||
+12
-1
@@ -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;
|
||||
|
||||
+417
@@ -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<String>,
|
||||
#[serde(default)]
|
||||
pub username: Option<String>,
|
||||
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<String>,
|
||||
pub is_public: bool,
|
||||
/// SHA256 of the claim key (hex); the key itself never touches the server.
|
||||
#[serde(default)]
|
||||
pub claim_key_hash: Option<String>,
|
||||
#[serde(default)]
|
||||
pub tip_address: Option<String>,
|
||||
#[serde(default)]
|
||||
pub funding_txid: Option<String>,
|
||||
/// 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<String>,
|
||||
#[serde(default)]
|
||||
pub grin_commitment: Option<String>,
|
||||
#[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<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub recipient_username: Option<String>,
|
||||
pub asset: String,
|
||||
pub amount: i64,
|
||||
pub is_public: bool,
|
||||
pub status: String,
|
||||
pub created_at: String,
|
||||
pub claimed_at: Option<String>,
|
||||
pub clawed_back_at: Option<String>,
|
||||
pub funding_confirmations: i32,
|
||||
pub confirmations_required: i32,
|
||||
pub is_claimable: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
pub struct SocialTipsResponse {
|
||||
pub tips: Vec<SentTip>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub tip_address: Option<String>,
|
||||
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<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<CreateSocialTipRequest>,
|
||||
) -> Result<Json<CreateSocialTipResponse>, 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<Vec<u8>> = 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<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<SocialTipsResponse>, 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<Arc<AppState>>,
|
||||
Path(tip_id): Path<Uuid>,
|
||||
) -> Result<Json<PublicTipInfo>, 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<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path(tip_id): Path<Uuid>,
|
||||
) -> Result<Json<CancelTipResponse>, 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<Arc<AppState>> {
|
||||
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))
|
||||
}
|
||||
@@ -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<String>,
|
||||
|
||||
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,
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
|
||||
+105
-1
@@ -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<Utc>,
|
||||
}
|
||||
|
||||
/// 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<Option<SocialTipRow>, 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<SocialTipRow, AppError> {
|
||||
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<SocialTipRow, AppError> {
|
||||
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<SocialTipRow, AppError> {
|
||||
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<Vec<SocialTipRow>, 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<Option<Uuid>, 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +114,7 @@ pub fn build_router(state: Arc<AppState>) -> Router {
|
||||
.merge(api::landing::routes())
|
||||
.merge(api::erasure::routes())
|
||||
.merge(api::premium::routes())
|
||||
.merge(api::tips::routes())
|
||||
.layer(normal),
|
||||
);
|
||||
|
||||
|
||||
+170
@@ -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::TestApp> {
|
||||
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");
|
||||
}
|
||||
Reference in New Issue
Block a user