From 5d8d5e193f57d69843bd0b2fef4b571a8425be1e Mon Sep 17 00:00:00 2001 From: jwinterm Date: Fri, 10 Jul 2026 15:51:20 -0400 Subject: [PATCH] =?UTF-8?q?feat(tips):=20stage=205=20=E2=80=94=20GC=20work?= =?UTF-8?q?ers=20+=20fail-closed=20flag=20+=20capabilities=20+=20openapi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- openapi.json | 696 ++++++++++++++++++++++++++++++++++++ src/api/capabilities.rs | 15 +- src/config.rs | 53 +++ src/infra/db/mod.rs | 2 +- src/infra/db/social_tips.rs | 99 +++++ src/main.rs | 42 +++ src/tips/gc.rs | 117 ++++++ src/tips/mod.rs | 2 + tests/tips.rs | 100 ++++++ 9 files changed, 1124 insertions(+), 2 deletions(-) create mode 100644 src/tips/gc.rs diff --git a/openapi.json b/openapi.json index c52bcbe..bded8ab 100644 --- a/openapi.json +++ b/openapi.json @@ -709,6 +709,361 @@ } } }, + "/tips/social": { + "post": { + "tags": [ + "tips" + ], + "summary": "Create a public tip (optionally as a two-phase draft).", + "operationId": "create_social_tip", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateSocialTipRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Tip created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateSocialTipResponse" + } + } + } + }, + "400": { + "description": "Tips off, targeted tip, or invalid input" + }, + "401": { + "description": "Missing or invalid token" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/tips/social/sent": { + "get": { + "tags": [ + "tips" + ], + "summary": "All tips the caller has sent, newest first.", + "operationId": "get_sent_social_tips", + "responses": { + "200": { + "description": "Sent tips", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SocialTipsResponse" + } + } + } + }, + "400": { + "description": "Tips off" + }, + "401": { + "description": "Missing or invalid token" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/tips/social/{tip_id}/attach-funding": { + "post": { + "tags": [ + "tips" + ], + "summary": "Attach a broadcast funding tx to a draft tip, advancing it into the funding\nlifecycle (`pending_confirmation`; the funding worker later flips it to\n`pending` once the on-chain receipt is confirmed AND covers `amount`).", + "description": "Idempotent: re-attaching the SAME txid is a no-op that returns the row; a\nDIFFERENT txid on a tip that already has one (or a non-draft tip) is a 400;\nan unknown / not-owned tip is a 404. Response mirrors create: the same\n`CreateSocialTipResponse { tip_id, status, share_url }`.", + "operationId": "attach_funding", + "parameters": [ + { + "name": "tip_id", + "in": "path", + "description": "Tip id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AttachFundingRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Funding attached", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateSocialTipResponse" + } + } + } + }, + "400": { + "description": "Tips off, empty/oversized txid, or a conflicting funding tx" + }, + "401": { + "description": "Missing or invalid token" + }, + "404": { + "description": "No such tip owned by the caller" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/tips/social/{tip_id}/cancel": { + "post": { + "tags": [ + "tips" + ], + "summary": "Cancel a still-unfunded draft (owner-only, draft-only). A funded/claimed tip\nis recovered via clawback, not cancel.", + "operationId": "cancel_social_tip", + "parameters": [ + { + "name": "tip_id", + "in": "path", + "description": "Tip id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Draft cancelled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CancelTipResponse" + } + } + } + }, + "400": { + "description": "Tips off" + }, + "401": { + "description": "Missing or invalid token" + }, + "404": { + "description": "No cancellable draft" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/tips/social/{tip_id}/claim": { + "post": { + "tags": [ + "tips" + ], + "summary": "Claim a public tip: lock it into `claiming` and return the encrypted claim\nkey + tip address so the caller can sweep the funds to their own wallet.", + "description": "AUTHED, no body. The `claiming` state is a UX signal, not a cryptographic\nlock — any URL holder may claim while the sweep hasn't confirmed (whoever\nwins the on-chain sweep race wins the tip; the reconciler resolves the\nwinner). A non-claimable tip (missing, underfunded, clawed back, already\nswept) is a 400.", + "operationId": "claim_social_tip", + "parameters": [ + { + "name": "tip_id", + "in": "path", + "description": "Tip id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Tip locked into claiming", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClaimTipResponse" + } + } + } + }, + "400": { + "description": "Tips off or tip not claimable" + }, + "401": { + "description": "Missing or invalid token" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/tips/social/{tip_id}/clawback": { + "post": { + "tags": [ + "tips" + ], + "summary": "Sender reclaims a tip's funds. AUTHED, no body. Succeeds for the sender on a\npending / pending_confirmation / claiming / funding_mismatch / cancelled tip\nwhile its sweep hasn't confirmed; a settled or non-clawable tip is a 400.", + "operationId": "clawback_social_tip", + "parameters": [ + { + "name": "tip_id", + "in": "path", + "description": "Tip id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Tip clawed back", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClawbackTipResponse" + } + } + } + }, + "400": { + "description": "Tips off or tip not clawable" + }, + "401": { + "description": "Missing or invalid token" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/tips/social/{tip_id}/confirm-sweep": { + "post": { + "tags": [ + "tips" + ], + "summary": "Record the claimer's broadcast sweep txid (first-recorder-wins). RECORD not\nsettle: the tip stays `claiming` and the reconciler owns the settle to\n`claimed` once the sweep confirms on-chain. Idempotent; a second, different\ntxid does NOT overwrite the recorded winner. A tip that was never claimed is\na 400.", + "operationId": "confirm_sweep", + "parameters": [ + { + "name": "tip_id", + "in": "path", + "description": "Tip id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfirmSweepRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Sweep txid recorded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfirmSweepResponse" + } + } + } + }, + "400": { + "description": "Tips off, empty/oversized txid, or tip not claiming" + }, + "401": { + "description": "Missing or invalid token" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/tips/social/{tip_id}/public": { + "get": { + "tags": [ + "tips" + ], + "summary": "Public tip metadata for a share-URL holder. UNAUTHENTICATED: the tip id (a\nUUID) is the bearer token. 404s for an unknown or non-public tip.", + "operationId": "get_public_social_tip", + "parameters": [ + { + "name": "tip_id", + "in": "path", + "description": "Tip id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Public tip info", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicTipInfo" + } + } + } + }, + "400": { + "description": "Tips off" + }, + "404": { + "description": "No such public tip" + } + } + } + }, "/users/by-username/{username}": { "get": { "tags": [ @@ -2089,6 +2444,19 @@ } } }, + "AttachFundingRequest": { + "type": "object", + "description": "Attach a broadcast funding transaction to a draft tip.", + "required": [ + "funding_txid" + ], + "properties": { + "funding_txid": { + "type": "string", + "description": "The on-chain funding transaction id (XMR/WOW txid, BTC/LTC txid)." + } + } + }, "AuthResponse": { "type": "object", "description": "Successful session response: a JWT pair plus minimal user info.", @@ -2177,6 +2545,17 @@ } } }, + "CancelTipResponse": { + "type": "object", + "required": [ + "ok" + ], + "properties": { + "ok": { + "type": "boolean" + } + } + }, "CapabilitiesResponse": { "type": "object", "required": [ @@ -2349,6 +2728,74 @@ } } }, + "ClaimTipResponse": { + "type": "object", + "description": "Claim a tip: lock it into `claiming` and receive the encrypted claim key +\ntip address to sweep. Load-bearing INCONSISTENT key: `success` (not `ok`).", + "required": [ + "success" + ], + "properties": { + "encrypted_key": { + "type": [ + "string", + "null" + ], + "description": "Hex-encoded AES-GCM ciphertext of the claim secret; `null` if the tip\nstored none. Useless without the URL-fragment key." + }, + "success": { + "type": "boolean" + }, + "tip_address": { + "type": [ + "string", + "null" + ] + } + } + }, + "ClawbackTipResponse": { + "type": "object", + "description": "Sender clawback of a tip. Load-bearing INCONSISTENT key: `success` (not `ok`).", + "required": [ + "success" + ], + "properties": { + "success": { + "type": "boolean" + } + } + }, + "ConfirmSweepRequest": { + "type": "object", + "description": "Record the claimer's broadcast sweep transaction (first-recorder-wins).", + "required": [ + "sweep_txid" + ], + "properties": { + "sweep_txid": { + "type": "string", + "description": "The broadcast sweep transaction id." + } + } + }, + "ConfirmSweepResponse": { + "type": "object", + "required": [ + "status" + ], + "properties": { + "status": { + "type": "string" + }, + "sweep_txid": { + "type": [ + "string", + "null" + ], + "description": "The WINNING (first-recorded) sweep txid, or `null` if none is recorded." + } + } + }, "ConfirmationsRequest": { "type": "object", "description": "Confirmation-count query for a tx.", @@ -2416,6 +2863,103 @@ } } }, + "CreateSocialTipRequest": { + "type": "object", + "description": "Create a public tip. Targeted-only fields (`platform`, `username`,\n`grin_commitment`, `sender_anonymous`) are accepted for wire-compat but\nignored in this port.", + "required": [ + "asset", + "amount", + "is_public" + ], + "properties": { + "amount": { + "type": "integer", + "format": "int64" + }, + "asset": { + "type": "string" + }, + "claim_key_hash": { + "type": [ + "string", + "null" + ], + "description": "SHA256 of the claim key (hex); the key itself never touches the server." + }, + "encrypted_key": { + "type": [ + "string", + "null" + ], + "description": "Hex-encoded AES-GCM ciphertext of the claim secret; the backend cannot\ndecrypt it." + }, + "funding_txid": { + "type": [ + "string", + "null" + ] + }, + "grin_commitment": { + "type": [ + "string", + "null" + ] + }, + "is_public": { + "type": "boolean" + }, + "platform": { + "type": [ + "string", + "null" + ] + }, + "sender_anonymous": { + "type": "boolean" + }, + "tip_address": { + "type": [ + "string", + "null" + ] + }, + "tip_view_key": { + "type": [ + "string", + "null" + ], + "description": "Private view key for the tip address (XMR/WOW), so the backend can scan\nit for funding + sweep." + }, + "username": { + "type": [ + "string", + "null" + ] + } + } + }, + "CreateSocialTipResponse": { + "type": "object", + "required": [ + "tip_id", + "status" + ], + "properties": { + "share_url": { + "type": [ + "string", + "null" + ], + "description": "`{TIP_SHARE_BASE_URL}/{tip_id}` for a public tip, else null." + }, + "status": { + "type": "string" + }, + "tip_id": { + "type": "string" + } + } + }, "ExtensionRegisterRequest": { "type": "object", "description": "Register a new extension wallet or re-authenticate an existing one.", @@ -3519,6 +4063,65 @@ } } }, + "PublicTipInfo": { + "type": "object", + "required": [ + "id", + "asset", + "amount", + "status", + "created_at", + "is_public", + "funding_confirmations", + "confirmations_required", + "is_claimable" + ], + "properties": { + "amount": { + "type": "integer", + "format": "int64" + }, + "asset": { + "type": "string" + }, + "confirmations_required": { + "type": "integer", + "format": "int32" + }, + "created_at": { + "type": "string" + }, + "encrypted_key": { + "type": [ + "string", + "null" + ], + "description": "Hex-encoded AES-GCM ciphertext; useless without the URL-fragment key." + }, + "funding_confirmations": { + "type": "integer", + "format": "int32" + }, + "id": { + "type": "string" + }, + "is_claimable": { + "type": "boolean" + }, + "is_public": { + "type": "boolean" + }, + "status": { + "type": "string" + }, + "tip_address": { + "type": [ + "string", + "null" + ] + } + } + }, "RandomOutputDto": { "type": "object", "description": "A decoy output.", @@ -3822,6 +4425,81 @@ } } }, + "SentTip": { + "type": "object", + "required": [ + "id", + "sender_user_id", + "asset", + "amount", + "is_public", + "status", + "created_at", + "funding_confirmations", + "confirmations_required", + "is_claimable" + ], + "properties": { + "amount": { + "type": "integer", + "format": "int64" + }, + "asset": { + "type": "string" + }, + "claimed_at": { + "type": [ + "string", + "null" + ] + }, + "clawed_back_at": { + "type": [ + "string", + "null" + ] + }, + "confirmations_required": { + "type": "integer", + "format": "int32" + }, + "created_at": { + "type": "string" + }, + "funding_confirmations": { + "type": "integer", + "format": "int32" + }, + "id": { + "type": "string" + }, + "is_claimable": { + "type": "boolean" + }, + "is_public": { + "type": "boolean" + }, + "recipient_platform": { + "type": [ + "string", + "null" + ], + "description": "Always null in the public-only port (no targeted recipients)." + }, + "recipient_username": { + "type": [ + "string", + "null" + ] + }, + "sender_user_id": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, "SetUsernameRequest": { "type": "object", "required": [ @@ -3858,6 +4536,20 @@ } } }, + "SocialTipsResponse": { + "type": "object", + "required": [ + "tips" + ], + "properties": { + "tips": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SentTip" + } + } + } + }, "SpentOutputDto": { "type": "object", "description": "A candidate spent output (verify with the spend key before trusting).", @@ -4367,6 +5059,10 @@ { "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)." } ] } diff --git a/src/api/capabilities.rs b/src/api/capabilities.rs index 7f90ffe..e9e5ff1 100644 --- a/src/api/capabilities.rs +++ b/src/api/capabilities.rs @@ -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, diff --git a/src/config.rs b/src/config.rs index 9e8c15b..edc1ec5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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(); diff --git a/src/infra/db/mod.rs b/src/infra/db/mod.rs index 4aa52ed..41456ab 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::{NewSocialTip, SocialTipRow}; +pub use social_tips::{GcCancelledTip, NewSocialTip, SocialTipRow, StuckClaimingTip}; use sqlx::PgPool; diff --git a/src/infra/db/social_tips.rs b/src/infra/db/social_tips.rs index bede64d..df48b24 100644 --- a/src/infra/db/social_tips.rs +++ b/src/infra/db/social_tips.rs @@ -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, + pub funding_txid: Option, + pub created_at: DateTime, +} + +/// 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, + pub claimed_by_user_id: Option, + pub claimed_at: Option>, +} + 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, 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, 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, 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) + } } diff --git a/src/main.rs b/src/main.rs index e780f11..207e8fe 100644 --- a/src/main.rs +++ b/src/main.rs @@ -330,6 +330,48 @@ async fn main() -> Result<(), Box> { }); } + // 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?; diff --git a/src/tips/gc.rs b/src/tips/gc.rs new file mode 100644 index 0000000..8bd23cd --- /dev/null +++ b/src/tips/gc.rs @@ -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) { + 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(""), + funding_txid = %row.funding_txid.as_deref().unwrap_or(""), + 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) { + 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(""), + funding_txid = %row.funding_txid.as_deref().unwrap_or(""), + 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(""), + claimed_by_user_id = %row + .claimed_by_user_id + .map(|u| u.to_string()) + .unwrap_or_else(|| "".to_string()), + claimed_at = %row + .claimed_at + .map(|t| t.to_rfc3339()) + .unwrap_or_else(|| "".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"), + } +} diff --git a/src/tips/mod.rs b/src/tips/mod.rs index 9f11311..3d15d5f 100644 --- a/src/tips/mod.rs +++ b/src/tips/mod.rs @@ -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}; diff --git a/tests/tips.rs b/tests/tips.rs index 0699399..35f55ae 100644 --- a/tests/tips.rs +++ b/tests/tips.rs @@ -700,3 +700,103 @@ async fn reconciler_poll_queries_select_the_right_rows() { assert!(reorg.iter().any(|t| t.id == claimed), "a settled tip with a height witness is reorg-checked"); assert!(!reorg.iter().any(|t| t.id == claiming), "a claiming tip is not reorg-checked"); } + +// ── Stage 5: lifecycle + draft garbage-collection DB tests ──────────────────── +// +// The GC cancel passes filter on `created_at < NOW() - INTERVAL '7 days'` (and +// the claiming scan on `claimed_at < NOW() - INTERVAL '15 minutes'`). The normal +// insert stamps NOW(), so a test back-dates the timestamp with a raw UPDATE to +// exercise the age filter. + +/// Back-date a tip's `created_at` by `interval` (e.g. "8 days") via a raw UPDATE. +async fn backdate_created_at(app: &common::TestApp, id: uuid::Uuid, interval: &str) { + sqlx::query(&format!( + "UPDATE social_tips SET created_at = NOW() - INTERVAL '{interval}' WHERE id = $1" + )) + .bind(id) + .execute(app.state.db.pool()) + .await + .expect("backdate created_at"); +} + +/// cancel_old_drafts flips a > 7d `draft` to `cancelled` and leaves a fresh draft +/// (and non-draft rows) untouched. +#[tokio::test] +async fn cancel_old_drafts_flips_stale_draft_only() { + let Some(app) = tips_app().await else { return }; + let uid = app.create_user().await; + + let stale = make_draft(&app, uid, "btc", 100_000, 0, None).await; + backdate_created_at(&app, stale, "8 days").await; + let fresh = make_draft(&app, uid, "btc", 100_000, 0, None).await; + + let cancelled = app.state.db.cancel_old_drafts().await.expect("cancel_old_drafts"); + assert!(cancelled.iter().any(|r| r.id == stale), "a > 7d draft must be cancelled"); + assert!(!cancelled.iter().any(|r| r.id == fresh), "a fresh draft must NOT be cancelled"); + + let stale_row = app.state.db.get_social_tip(stale).await.expect("get").expect("row"); + assert_eq!(stale_row.status, "cancelled"); + let fresh_row = app.state.db.get_social_tip(fresh).await.expect("get").expect("row"); + assert_eq!(fresh_row.status, "draft", "a fresh draft stays draft"); +} + +/// cancel_stuck_pending_confirmation flips a > 7d `pending_confirmation` row to +/// `cancelled` and leaves a fresh one untouched. +#[tokio::test] +async fn cancel_stuck_pending_confirmation_flips_stale_only() { + let Some(app) = tips_app().await else { return }; + let uid = app.create_user().await; + + let stale = make_draft(&app, uid, "btc", 100_000, 0, None).await; + app.state.db.attach_funding_to_tip(stale, uid, "gc-stale-pc").await.expect("attach"); + backdate_created_at(&app, stale, "8 days").await; + + let fresh = make_draft(&app, uid, "btc", 100_000, 0, None).await; + app.state.db.attach_funding_to_tip(fresh, uid, "gc-fresh-pc").await.expect("attach"); + + let cancelled = app + .state + .db + .cancel_stuck_pending_confirmation() + .await + .expect("cancel_stuck_pending_confirmation"); + assert!( + cancelled.iter().any(|r| r.id == stale), + "a > 7d pending_confirmation must be cancelled" + ); + assert!( + !cancelled.iter().any(|r| r.id == fresh), + "a fresh pending_confirmation must NOT be cancelled" + ); + + let stale_row = app.state.db.get_social_tip(stale).await.expect("get").expect("row"); + assert_eq!(stale_row.status, "cancelled"); + let fresh_row = app.state.db.get_social_tip(fresh).await.expect("get").expect("row"); + assert_eq!(fresh_row.status, "pending_confirmation"); +} + +/// log_stuck_claiming returns a > 15m back-dated `claiming` row, excludes a fresh +/// one, and mutates NOTHING (read-only scan). +#[tokio::test] +async fn log_stuck_claiming_returns_backdated_and_mutates_nothing() { + let Some(app) = tips_app().await else { return }; + let sender = app.create_user().await; + let claimant = app.create_user().await; + + let stale = make_claiming(&app, sender, claimant, "gc-stale-claiming").await; + sqlx::query("UPDATE social_tips SET claimed_at = NOW() - INTERVAL '16 minutes' WHERE id = $1") + .bind(stale) + .execute(app.state.db.pool()) + .await + .expect("backdate claimed_at"); + let fresh = make_claiming(&app, sender, claimant, "gc-fresh-claiming").await; + + let stuck = app.state.db.log_stuck_claiming().await.expect("log_stuck_claiming"); + assert!(stuck.iter().any(|r| r.id == stale), "a > 15m claiming row must be surfaced"); + assert!(!stuck.iter().any(|r| r.id == fresh), "a fresh claiming row must NOT be surfaced"); + + // READ-ONLY: the surfaced row is untouched — still 'claiming', no sweep confirm. + let row = app.state.db.get_social_tip(stale).await.expect("get").expect("row"); + assert_eq!(row.status, "claiming", "log_stuck_claiming must not mutate status"); + assert!(row.sweep_confirmed_at.is_none(), "log_stuck_claiming must not settle the sweep"); +}