feat(tips): stage 4 — claim / clawback / confirm-sweep + sweep reconciler (money-out)

Escrow settlement, audited guards ported verbatim:
- mark_tip_claiming: status IN('pending','claiming') AND confs>=required AND
  funding_amount_verified AND sweep_confirmed_at IS NULL (double-claim guard).
- confirm_tip_sweep: RECORD-not-settle, first-recorder-wins (COALESCE +
  sweep_txid IS NULL OR =$3) — RBF-steal safe; reconciler owns claiming->claimed.
- confirm_sweep_onchain: SOLE writer of 'claimed', one-way status='claiming' AND
  sweep_confirmed_at IS NULL lock; settles only after on-chain sweep confirms to
  per-asset depth (btc/ltc 1, wow 4, xmr 10).
- clawback: 5-status set incl 'cancelled', blocked once sweep_confirmed_at set.
- revert_sweep_on_reorg: only from 'claimed', re-opens to 'claiming' + clears txid.
- sweep_reconciler: address-history-driven (not recorded txid), pick_winner
  (earliest height, lexi-smallest tie), pg advisory locks on a pinned conn for
  single-instance safety; reorg pass reverts only on affirmative vanish, never on
  probe error. DMs stubbed, exactly-once stamps kept.
- POST claim/confirm-sweep/clawback endpoints + openapi.
- 16 integration + 12 reconciler unit tests; build clean; SocialTipRow dead_code
  allow removed (all fields now read).
This commit is contained in:
jwinterm
2026-07-10 15:34:14 -04:00
parent b63cac19e2
commit b7e3510553
7 changed files with 1510 additions and 3 deletions
+7
View File
@@ -73,6 +73,9 @@ use utoipa::OpenApi;
crate::api::tips::get_public_social_tip,
crate::api::tips::cancel_social_tip,
crate::api::tips::attach_funding,
crate::api::tips::claim_social_tip,
crate::api::tips::confirm_sweep,
crate::api::tips::clawback_social_tip,
),
components(schemas(
crate::api::health::HealthResponse,
@@ -170,6 +173,10 @@ use utoipa::OpenApi;
crate::api::tips::PublicTipInfo,
crate::api::tips::CancelTipResponse,
crate::api::tips::AttachFundingRequest,
crate::api::tips::ClaimTipResponse,
crate::api::tips::ConfirmSweepRequest,
crate::api::tips::ConfirmSweepResponse,
crate::api::tips::ClawbackTipResponse,
)),
tags(
(name = "system", description = "Service health and metadata."),
+157
View File
@@ -125,6 +125,37 @@ pub struct AttachFundingRequest {
pub funding_txid: String,
}
/// Claim a tip: lock it into `claiming` and receive the encrypted claim key +
/// tip address to sweep. Load-bearing INCONSISTENT key: `success` (not `ok`).
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct ClaimTipResponse {
pub success: bool,
/// Hex-encoded AES-GCM ciphertext of the claim secret; `null` if the tip
/// stored none. Useless without the URL-fragment key.
pub encrypted_key: Option<String>,
pub tip_address: Option<String>,
}
/// Record the claimer's broadcast sweep transaction (first-recorder-wins).
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct ConfirmSweepRequest {
/// The broadcast sweep transaction id.
pub sweep_txid: String,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct ConfirmSweepResponse {
/// The WINNING (first-recorded) sweep txid, or `null` if none is recorded.
pub sweep_txid: Option<String>,
pub status: String,
}
/// Sender clawback of a tip. Load-bearing INCONSISTENT key: `success` (not `ok`).
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct ClawbackTipResponse {
pub success: bool,
}
// ── helpers ──────────────────────────────────────────────────────────────────
/// Reject when tips are not enabled on this instance.
@@ -476,6 +507,129 @@ pub async fn attach_funding(
}))
}
/// Claim a public tip: lock it into `claiming` and return the encrypted claim
/// key + tip address so the caller can sweep the funds to their own wallet.
///
/// AUTHED, no body. The `claiming` state is a UX signal, not a cryptographic
/// lock — any URL holder may claim while the sweep hasn't confirmed (whoever
/// wins the on-chain sweep race wins the tip; the reconciler resolves the
/// winner). A non-claimable tip (missing, underfunded, clawed back, already
/// swept) is a 400.
#[utoipa::path(
security(("bearer_auth" = [])),
post,
path = "/tips/social/{tip_id}/claim",
params(("tip_id" = String, Path, description = "Tip id")),
responses(
(status = 200, description = "Tip locked into claiming", body = ClaimTipResponse),
(status = 400, description = "Tips off or tip not claimable"),
(status = 401, description = "Missing or invalid token")
),
tag = "tips"
)]
#[instrument(skip(state, headers))]
pub async fn claim_social_tip(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(tip_id): Path<Uuid>,
) -> Result<Json<ClaimTipResponse>, AppError> {
let user_id = extract_user_id_from_token(&state, &headers).await?;
ensure_tips_enabled(&state)?;
let row = state
.db
.mark_tip_claiming(tip_id, user_id)
.await?
.ok_or_else(|| AppError::ValidationError("tip not claimable".into()))?;
Ok(Json(ClaimTipResponse {
success: true,
encrypted_key: row.encrypted_key.map(hex::encode),
tip_address: row.tip_address,
}))
}
/// Record the claimer's broadcast sweep txid (first-recorder-wins). RECORD not
/// settle: the tip stays `claiming` and the reconciler owns the settle to
/// `claimed` once the sweep confirms on-chain. Idempotent; a second, different
/// txid does NOT overwrite the recorded winner. A tip that was never claimed is
/// a 400.
#[utoipa::path(
security(("bearer_auth" = [])),
post,
path = "/tips/social/{tip_id}/confirm-sweep",
params(("tip_id" = String, Path, description = "Tip id")),
request_body = ConfirmSweepRequest,
responses(
(status = 200, description = "Sweep txid recorded", body = ConfirmSweepResponse),
(status = 400, description = "Tips off, empty/oversized txid, or tip not claiming"),
(status = 401, description = "Missing or invalid token")
),
tag = "tips"
)]
#[instrument(skip(state, headers, req))]
pub async fn confirm_sweep(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(tip_id): Path<Uuid>,
Json(req): Json<ConfirmSweepRequest>,
) -> Result<Json<ConfirmSweepResponse>, AppError> {
let user_id = extract_user_id_from_token(&state, &headers).await?;
ensure_tips_enabled(&state)?;
let sweep_txid = req.sweep_txid.trim();
if sweep_txid.is_empty() {
return Err(AppError::ValidationError("sweep_txid is required.".into()));
}
if sweep_txid.len() > 128 {
return Err(AppError::ValidationError("sweep_txid is too long.".into()));
}
let row = state
.db
.confirm_tip_sweep(tip_id, user_id, sweep_txid)
.await?
.ok_or_else(|| AppError::ValidationError("tip not claiming".into()))?;
Ok(Json(ConfirmSweepResponse {
sweep_txid: row.sweep_txid,
status: row.status,
}))
}
/// Sender reclaims a tip's funds. AUTHED, no body. Succeeds for the sender on a
/// pending / pending_confirmation / claiming / funding_mismatch / cancelled tip
/// while its sweep hasn't confirmed; a settled or non-clawable tip is a 400.
#[utoipa::path(
security(("bearer_auth" = [])),
post,
path = "/tips/social/{tip_id}/clawback",
params(("tip_id" = String, Path, description = "Tip id")),
responses(
(status = 200, description = "Tip clawed back", body = ClawbackTipResponse),
(status = 400, description = "Tips off or tip not clawable"),
(status = 401, description = "Missing or invalid token")
),
tag = "tips"
)]
#[instrument(skip(state, headers))]
pub async fn clawback_social_tip(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(tip_id): Path<Uuid>,
) -> Result<Json<ClawbackTipResponse>, AppError> {
let user_id = extract_user_id_from_token(&state, &headers).await?;
ensure_tips_enabled(&state)?;
state
.db
.clawback_social_tip(tip_id, user_id)
.await?
.ok_or_else(|| AppError::ValidationError("tip not clawable".into()))?;
Ok(Json(ClawbackTipResponse { success: true }))
}
pub fn routes() -> Router<Arc<AppState>> {
Router::new()
.route("/tips/social", post(create_social_tip))
@@ -486,4 +640,7 @@ pub fn routes() -> Router<Arc<AppState>> {
"/tips/social/:tip_id/attach-funding",
post(attach_funding),
)
.route("/tips/social/:tip_id/claim", post(claim_social_tip))
.route("/tips/social/:tip_id/confirm-sweep", post(confirm_sweep))
.route("/tips/social/:tip_id/clawback", post(clawback_social_tip))
}