feat(tips): stage 5 — GC workers + fail-closed flag + capabilities + openapi

- GC: cancel_old_drafts (draft >7d -> cancelled), cancel_stuck_pending_confirmation
  (>7d), log_stuck_claiming (read-only, >15min). Draft GC hourly + lifecycle GC
  5min, spawned behind FEATURE_TIPS. Funded-but-unattached safety net is the
  7-day window + clawback incl 'cancelled'.
- config validate(): tips enabled fails closed unless a supported chain
  (btc/ltc/xmr/wow) is on AND tip_share_base is set.
- capabilities: advertise tips=true only when actually serveable.
- regenerated openapi.json (8 /tips/social/* endpoints).

Public-tips port COMPLETE (stages 1-5). Full suite green.
This commit is contained in:
jwinterm
2026-07-10 15:51:20 -04:00
parent b7e3510553
commit 5d8d5e193f
9 changed files with 1124 additions and 2 deletions
+14 -1
View File
@@ -250,7 +250,7 @@ pub fn effective_capabilities(config: &Config) -> CapabilitiesResponse {
nostr_relay: relay_advertised(config),
premium_relay: premium_advertised(config),
feed: feed_advertised(config),
tips: config.features.tips,
tips: tips_advertised(config),
},
restore: RestoreCapability {
policy: config.restore.policy.as_str().to_string(),
@@ -333,6 +333,19 @@ fn feed_advertised(config: &Config) -> bool {
config.feed.enabled && relay_advertised(config)
}
/// Whether public tips are actually serveable: the feature is on, a share-URL
/// base is configured, AND at least one supported tip chain (btc/ltc/xmr/wow)
/// is serviceable. Mirrors the chain/relay presence-downgrade — never advertise
/// tips an instance can't escrow on or build share links for. (Grin is out of
/// the tips port, so it is not a supported tip chain here.)
fn tips_advertised(config: &Config) -> bool {
config.features.tips
&& config.tip_share_base.is_some()
&& ["btc", "ltc", "xmr", "wow"]
.iter()
.any(|asset| chain_serviceable(config, asset))
}
/// Describe this instance's enabled chains and features.
#[utoipa::path(
get,
+53
View File
@@ -1102,6 +1102,27 @@ impl Config {
}
}
// Public tips escrow on-chain and deliver by share URL, so an instance
// with FEATURE_TIPS on but no chain to escrow on, or no TIP_SHARE_BASE_URL
// to build share links from, would advertise a tips subsystem it cannot
// actually serve. Fail closed rather than boot broken. (Grin is out of the
// tips port, so it is NOT a supported tip chain here.)
if self.features.tips {
let c = &self.features.chains;
if !(c.btc || c.ltc || c.xmr || c.wow) {
return Err(cfg_err(
"FEATURE_TIPS is on but no supported tip chain is enabled — enable at least \
one of FEATURE_BTC / FEATURE_LTC / FEATURE_XMR / FEATURE_WOW",
));
}
if self.tip_share_base.is_none() {
return Err(cfg_err(
"FEATURE_TIPS is on but TIP_SHARE_BASE_URL is unset — it is required to build \
public tip share links",
));
}
}
// Pay-to-register gate: when on, the processor wiring must be complete
// and sane, or /capabilities would advertise payment_required while every
// registration then fails. Fail closed at startup instead.
@@ -1561,6 +1582,38 @@ mod tests {
assert!(c.validate().is_err());
}
#[test]
fn tips_enabled_requires_share_base() {
// FEATURE_TIPS on + a tip chain but NO share base → fail closed.
let mut c = valid();
c.features.tips = true;
c.features.chains.btc = true;
c.tip_share_base = None;
assert!(c.validate().is_err(), "tips on without a share base must fail");
}
#[test]
fn tips_enabled_requires_a_tip_chain() {
// FEATURE_TIPS on + a share base but NO tip chain enabled → fail closed.
// (valid() has all chains off.)
let mut c = valid();
c.features.tips = true;
c.tip_share_base = Some("https://tips.example".into());
assert!(c.validate().is_err(), "tips on with no tip chain must fail");
}
#[test]
fn tips_enabled_with_chain_and_share_base_ok() {
let mut c = valid();
c.features.tips = true;
c.features.chains.btc = true;
c.tip_share_base = Some("https://tips.example".into());
assert!(
c.validate().is_ok(),
"tips on with a tip chain + share base is valid"
);
}
#[test]
fn unsupported_price_provider_rejected() {
let mut c = valid();
+1 -1
View File
@@ -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::{NewSocialTip, SocialTipRow};
pub use social_tips::{GcCancelledTip, NewSocialTip, SocialTipRow, StuckClaimingTip};
use sqlx::PgPool;
+99
View File
@@ -80,6 +80,29 @@ pub struct NewSocialTip<'a> {
pub confirmations_required: i32,
}
/// A `social_tips` row cancelled by a GC pass, distilled to the fields the
/// worker logs. NOT the full [`SocialTipRow`] — the GC only needs enough to
/// `warn!` per row (the audit trail for the funded-but-not-attached case).
#[derive(Debug, Clone, FromRow)]
pub struct GcCancelledTip {
pub id: Uuid,
pub asset: String,
pub tip_address: Option<String>,
pub funding_txid: Option<String>,
pub created_at: DateTime<Utc>,
}
/// A stuck `claiming` row surfaced (never mutated) by the lifecycle GC's
/// read-only scan. Fields are exactly what the operator `warn!` needs.
#[derive(Debug, Clone, FromRow)]
pub struct StuckClaimingTip {
pub id: Uuid,
pub asset: String,
pub tip_address: Option<String>,
pub claimed_by_user_id: Option<Uuid>,
pub claimed_at: Option<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.
@@ -778,4 +801,80 @@ impl Database {
.await?;
Ok(())
}
// ── Stage 5: lifecycle garbage collection ───────────────────────────────────
//
// Two janitor passes for rows that would otherwise leak forever:
// - cancel_old_drafts / cancel_stuck_pending_confirmation flip long-stuck
// rows to 'cancelled', RETURNING each cancelled row so the worker warns.
// - log_stuck_claiming is a READ-ONLY scan (mutates NOTHING) that surfaces
// confirm-sweep-never-landed rows for operator follow-up.
//
// MONEY-SAFETY: cancel_old_drafts / cancel_stuck_pending_confirmation flip a
// funded-but-not-attached row to 'cancelled' with NO on-chain funds check —
// the funding tx may have landed while the row sat stuck. The only mitigations
// are (1) the generous 7-day window, (2) the per-row `warn!` the worker emits
// so an operator can grep journald for a funded tip_address, and (3) clawback
// INCLUDING 'cancelled' in its status set (see `clawback_social_tip`) so the
// sender can still recover. 'cancelled' MUST stay in that clawback filter.
/// UPDATE `draft` rows older than 7 days to `cancelled`, RETURNING the
/// cancelled rows (id / asset / tip_address / funding_txid / created_at) so
/// the GC worker can `warn!` per row. Targets `status='draft'` ONLY — anything
/// past the pre-funding stage (pending / claiming / …) has already been
/// broadcast and must NOT be GC'd here (orphaning it would be fund loss).
#[instrument(skip(self))]
pub async fn cancel_old_drafts(&self) -> Result<Vec<GcCancelledTip>, AppError> {
let rows = sqlx::query_as::<_, GcCancelledTip>(
"UPDATE social_tips \
SET status = 'cancelled', updated_at = NOW() \
WHERE status = 'draft' \
AND created_at < NOW() - INTERVAL '7 days' \
RETURNING id, asset, tip_address, funding_txid, created_at",
)
.fetch_all(self.pool())
.await?;
Ok(rows)
}
/// UPDATE `pending_confirmation` rows older than 7 days to `cancelled`,
/// RETURNING each cancelled row so the worker can `warn!`. Targets
/// `status='pending_confirmation'` ONLY — a row that reached `pending` already
/// cleared the funding verifier and is a real tip the sender expects claimed;
/// it must NOT be auto-cancelled here.
#[instrument(skip(self))]
pub async fn cancel_stuck_pending_confirmation(
&self,
) -> Result<Vec<GcCancelledTip>, AppError> {
let rows = sqlx::query_as::<_, GcCancelledTip>(
"UPDATE social_tips \
SET status = 'cancelled', updated_at = NOW() \
WHERE status = 'pending_confirmation' \
AND created_at < NOW() - INTERVAL '7 days' \
RETURNING id, asset, tip_address, funding_txid, created_at",
)
.fetch_all(self.pool())
.await?;
Ok(rows)
}
/// READ-ONLY scan of stuck `claiming` rows for operator observability. Mutates
/// NOTHING — returns rows in `claiming` with no confirmed sweep whose claim is
/// older than 15 minutes (well past the client's ~13s confirm-sweep retry
/// window). Auto-reconciliation is the reconciler's job; this pass only
/// surfaces rows whose `confirm_tip_sweep` never landed so an operator can
/// follow up.
#[instrument(skip(self))]
pub async fn log_stuck_claiming(&self) -> Result<Vec<StuckClaimingTip>, AppError> {
let rows = sqlx::query_as::<_, StuckClaimingTip>(
"SELECT id, asset, tip_address, claimed_by_user_id, claimed_at \
FROM social_tips \
WHERE status = 'claiming' \
AND sweep_confirmed_at IS NULL \
AND claimed_at < NOW() - INTERVAL '15 minutes'",
)
.fetch_all(self.pool())
.await?;
Ok(rows)
}
}
+42
View File
@@ -330,6 +330,48 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
});
}
// Public-tips draft GC: hourly, cancel `draft` rows abandoned mid-flow (> 7
// days, never funded-attached) and warn per cancelled row. Same catch_unwind
// guard as the money-in/out workers.
if state.config.features.tips {
use futures::FutureExt;
let state = state.clone();
tokio::spawn(async move {
let mut tick = tokio::time::interval(std::time::Duration::from_secs(3600));
loop {
tick.tick().await;
let cycle = std::panic::AssertUnwindSafe(
smirk_backend_core::tips::run_tip_draft_gc_cycle(state.clone()),
);
if cycle.catch_unwind().await.is_err() {
tracing::error!("tip draft GC cycle panicked; continuing after backoff");
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
}
}
});
}
// Public-tips lifecycle GC: every 5 min, cancel stuck `pending_confirmation`
// rows (> 7 days) and warn-only-scan stuck `claiming` rows (> 15 min). Same
// catch_unwind guard.
if state.config.features.tips {
use futures::FutureExt;
let state = state.clone();
tokio::spawn(async move {
let mut tick = tokio::time::interval(std::time::Duration::from_secs(300));
loop {
tick.tick().await;
let cycle = std::panic::AssertUnwindSafe(
smirk_backend_core::tips::run_tip_lifecycle_gc_cycle(state.clone()),
);
if cycle.catch_unwind().await.is_err() {
tracing::error!("tip lifecycle GC cycle panicked; continuing after backoff");
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
}
}
});
}
let app = build_router(state);
let listener = tokio::net::TcpListener::bind(&addr).await?;
+117
View File
@@ -0,0 +1,117 @@
//! Lifecycle + draft garbage-collection passes for public social tips.
//!
//! Two periodic janitor cycles, each spawned from `main` behind `FEATURE_TIPS`:
//!
//! - [`run_tip_draft_gc_cycle`] (hourly): cancel `draft` rows abandoned
//! mid-flow (created > 7 days ago, never funded-attached). Cancels the row
//! and `warn!`s per cancelled draft so an operator can audit the
//! funded-but-not-attached case (the sender may have broadcast the funding
//! tx before the attach call landed).
//!
//! - [`run_tip_lifecycle_gc_cycle`] (5 min): cancel `pending_confirmation`
//! rows stuck > 7 days (RBF eviction / mempool drop / LWS blind spot left
//! the funding tx unconfirmed forever), and READ-ONLY-scan `claiming` rows
//! stuck > 15 minutes (`confirm_tip_sweep` never landed) for operator
//! visibility — the scan mutates NOTHING.
//!
//! Ported (public subset) from the legacy `core/tip_draft_gc.rs` +
//! `core/tip_lifecycle_gc.rs`. The intervals + WHERE clauses live in the DB
//! methods (`cancel_old_drafts` / `cancel_stuck_pending_confirmation` /
//! `log_stuck_claiming`); these workers are the warn-per-row surface over them.
//!
//! MONEY-SAFETY: the two cancel passes flip funded-but-not-attached rows to
//! `cancelled` with no on-chain funds check — the 7-day window, the per-row
//! `warn!`, and clawback INCLUDING `cancelled` (see `clawback_social_tip`) are
//! the only mitigations. Neither pass ever touches a `pending` / `claiming`
//! (already-broadcast) row: cancels are `draft`/`pending_confirmation`-only and
//! the claiming scan is read-only.
use std::sync::Arc;
use tracing::{info, warn};
use crate::AppState;
/// One draft-GC cycle: cancel `draft` rows older than 7 days and `warn!` per
/// cancelled row. Never returns an error — a DB failure is logged and swallowed
/// so the spawn loop survives.
pub async fn run_tip_draft_gc_cycle(state: Arc<AppState>) {
match state.db.cancel_old_drafts().await {
Ok(cancelled) => {
for row in &cancelled {
warn!(
tip_id = %row.id,
asset = %row.asset,
tip_address = %row.tip_address.as_deref().unwrap_or("<none>"),
funding_txid = %row.funding_txid.as_deref().unwrap_or("<none>"),
created_at = %row.created_at,
"tip-draft GC: cancelled abandoned draft (operator: check tip_address for \
on-chain funds — a funded-but-not-attached draft is recoverable via clawback)"
);
}
if !cancelled.is_empty() {
info!(count = cancelled.len(), "tip-draft GC: cancelled abandoned drafts");
}
}
Err(e) => warn!(error = %e, "tip-draft GC cycle failed"),
}
}
/// One lifecycle-GC cycle: cancel stuck `pending_confirmation` rows (> 7 days,
/// warn per row) then READ-ONLY-scan stuck `claiming` rows (> 15 min, warn-only,
/// mutates nothing). Two independent passes — a failure in one is logged and
/// does not abort the other. Never returns an error.
pub async fn run_tip_lifecycle_gc_cycle(state: Arc<AppState>) {
match state.db.cancel_stuck_pending_confirmation().await {
Ok(cancelled) => {
for row in &cancelled {
warn!(
tip_id = %row.id,
asset = %row.asset,
tip_address = %row.tip_address.as_deref().unwrap_or("<none>"),
funding_txid = %row.funding_txid.as_deref().unwrap_or("<none>"),
created_at = %row.created_at,
"tip-lifecycle GC: cancelled stuck pending_confirmation (funding tx never \
confirmed — RBF / mempool drop / LWS blind spot suspected; sender should \
re-broadcast or clawback)"
);
}
if !cancelled.is_empty() {
info!(
count = cancelled.len(),
"tip-lifecycle GC: cancelled stuck pending_confirmation rows"
);
}
}
Err(e) => warn!(error = %e, "tip-lifecycle GC: cancel_stuck_pending_confirmation failed"),
}
match state.db.log_stuck_claiming().await {
Ok(stuck) => {
for row in &stuck {
warn!(
tip_id = %row.id,
asset = %row.asset,
tip_address = %row.tip_address.as_deref().unwrap_or("<none>"),
claimed_by_user_id = %row
.claimed_by_user_id
.map(|u| u.to_string())
.unwrap_or_else(|| "<none>".to_string()),
claimed_at = %row
.claimed_at
.map(|t| t.to_rfc3339())
.unwrap_or_else(|| "<none>".to_string()),
"tip-lifecycle GC: stuck claiming row (confirm-sweep never landed) — \
operator-visible only; the sweep reconciler settles genuine on-chain sweeps"
);
}
if !stuck.is_empty() {
warn!(
count = stuck.len(),
"tip-lifecycle GC: observed stuck claiming rows — manual ops follow-up may be needed"
);
}
}
Err(e) => warn!(error = %e, "tip-lifecycle GC: log_stuck_claiming failed"),
}
}
+2
View File
@@ -16,7 +16,9 @@
//! outage can therefore never mass-flip pending tips to `funding_mismatch`.
pub mod confirmation;
pub mod gc;
pub mod sweep_reconciler;
pub use confirmation::run_tip_confirmation_cycle;
pub use gc::{run_tip_draft_gc_cycle, run_tip_lifecycle_gc_cycle};
pub use sweep_reconciler::{run_sweep_reconcile_cycle, run_sweep_reorg_cycle};