feat(tips): stage 3 — attach-funding + funding-verification worker (money-in)

- attach_funding_to_tip: atomic guarded UPDATE (COALESCE + CASE), idempotent on
  same txid, 400 on a different txid; non-drafts land pending_confirmation so the
  verifier (not the caller) owns the ->pending transition.
- worker (src/tips/confirmation.rs): XMR/WOW confirmation counting via LWS;
  amount verification for BTC/LTC (Electrum full history, confirmed-only) +
  XMR/WOW (classify_funding_amount, separate-then-subtract net). Verified->pending,
  Short->funding_mismatch; Unfunded/Drained/LwsError never mutate status (outage-
  safe). Spawned behind FEATURE_TIPS on a 60s loop w/ catch_unwind guard.
- POST /tips/social/:id/attach-funding endpoint + openapi.
- 5 DB tests + 6 classify unit tests; build clean.
This commit is contained in:
jwinterm
2026-07-10 14:53:46 -04:00
parent f3d6be95b8
commit b63cac19e2
8 changed files with 1058 additions and 0 deletions
+2
View File
@@ -72,6 +72,7 @@ use utoipa::OpenApi;
crate::api::tips::get_sent_social_tips,
crate::api::tips::get_public_social_tip,
crate::api::tips::cancel_social_tip,
crate::api::tips::attach_funding,
),
components(schemas(
crate::api::health::HealthResponse,
@@ -168,6 +169,7 @@ use utoipa::OpenApi;
crate::api::tips::SocialTipsResponse,
crate::api::tips::PublicTipInfo,
crate::api::tips::CancelTipResponse,
crate::api::tips::AttachFundingRequest,
)),
tags(
(name = "system", description = "Service health and metadata."),
+72
View File
@@ -118,6 +118,13 @@ pub struct CancelTipResponse {
pub ok: bool,
}
/// Attach a broadcast funding transaction to a draft tip.
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct AttachFundingRequest {
/// The on-chain funding transaction id (XMR/WOW txid, BTC/LTC txid).
pub funding_txid: String,
}
// ── helpers ──────────────────────────────────────────────────────────────────
/// Reject when tips are not enabled on this instance.
@@ -408,10 +415,75 @@ pub async fn cancel_social_tip(
}
}
/// Attach a broadcast funding tx to a draft tip, advancing it into the funding
/// lifecycle (`pending_confirmation`; the funding worker later flips it to
/// `pending` once the on-chain receipt is confirmed AND covers `amount`).
///
/// Idempotent: re-attaching the SAME txid is a no-op that returns the row; a
/// DIFFERENT txid on a tip that already has one (or a non-draft tip) is a 400;
/// an unknown / not-owned tip is a 404. Response mirrors create: the same
/// `CreateSocialTipResponse { tip_id, status, share_url }`.
#[utoipa::path(
security(("bearer_auth" = [])),
post,
path = "/tips/social/{tip_id}/attach-funding",
params(("tip_id" = String, Path, description = "Tip id")),
request_body = AttachFundingRequest,
responses(
(status = 200, description = "Funding attached", body = CreateSocialTipResponse),
(status = 400, description = "Tips off, empty/oversized txid, or a conflicting funding tx"),
(status = 401, description = "Missing or invalid token"),
(status = 404, description = "No such tip owned by the caller")
),
tag = "tips"
)]
#[instrument(skip(state, headers, req))]
pub async fn attach_funding(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(tip_id): Path<Uuid>,
Json(req): Json<AttachFundingRequest>,
) -> Result<Json<CreateSocialTipResponse>, AppError> {
let user_id = extract_user_id_from_token(&state, &headers).await?;
ensure_tips_enabled(&state)?;
// Validate the txid shape so a malformed value is a clean 400 rather than a
// DB length-overflow 500 (funding_txid is VARCHAR(128)).
let funding_txid = req.funding_txid.trim();
if funding_txid.is_empty() {
return Err(AppError::ValidationError("funding_txid is required.".into()));
}
if funding_txid.len() > 128 {
return Err(AppError::ValidationError("funding_txid is too long.".into()));
}
// The DB fn returns Ok(row) / NotFound / ValidationError; just surface it.
let tip = state
.db
.attach_funding_to_tip(tip_id, user_id, funding_txid)
.await?;
let share_url = state
.config
.tip_share_base
.as_ref()
.map(|base| format!("{}/{}", base.trim_end_matches('/'), tip.id));
Ok(Json(CreateSocialTipResponse {
tip_id: tip.id.to_string(),
status: tip.status,
share_url,
}))
}
pub fn routes() -> Router<Arc<AppState>> {
Router::new()
.route("/tips/social", post(create_social_tip))
.route("/tips/social/sent", get(get_sent_social_tips))
.route("/tips/social/:tip_id/public", get(get_public_social_tip))
.route("/tips/social/:tip_id/cancel", post(cancel_social_tip))
.route(
"/tips/social/:tip_id/attach-funding",
post(attach_funding),
)
}