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
+696
View File
@@ -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)."
}
]
}
+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};
+100
View File
@@ -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");
}