mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 15:31:38 +02:00
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:
+14
-1
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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"),
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
Reference in New Issue
Block a user