Files
smirk-backend-core/tests/tips.rs
T
jwinterm b7e3510553 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).
2026-07-10 15:34:14 -04:00

703 lines
26 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Public social-tips route tests (Stage 2: create / get-public / get-sent /
//! cancel). Self-skips when `TEST_DATABASE_URL` is unset (see `tests/common`).
mod common;
use axum::http::StatusCode;
use serde_json::json;
/// Boot the app with tips ENABLED + a share base configured (BTC is on by
/// default in the test config). Returns None (skip) when no test DB.
async fn tips_app() -> Option<common::TestApp> {
common::try_app_with(|c| {
c.features.tips = true;
c.tip_share_base = Some("https://tips.example".into());
})
.await
}
/// A valid SHA256 hash shape: exactly 64 hex chars (4 × 16).
const CLAIM_HASH: &str = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef";
const ENC_KEY_HEX: &str = "cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd";
const BTC_ADDR: &str = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4";
fn draft_body() -> serde_json::Value {
json!({
"asset": "btc",
"amount": 100_000,
"is_public": true,
"claim_key_hash": CLAIM_HASH,
"encrypted_key": ENC_KEY_HEX,
"tip_address": BTC_ADDR,
})
}
#[tokio::test]
async fn public_tip_draft_create_get_cancel_flow() {
let Some(app) = tips_app().await else {
eprintln!("skipping: TEST_DATABASE_URL not set");
return;
};
let (_uid, token, _refresh) = app.mint_session().await;
// 1. Create a draft (no funding_txid) -> draft + share_url.
let (status, body) = app
.request("POST", "/api/v1/tips/social", Some(&token), Some(draft_body()))
.await;
assert_eq!(status, StatusCode::OK, "create: {body}");
assert_eq!(body["status"], "draft");
let tip_id = body["tip_id"].as_str().expect("tip_id").to_string();
assert_eq!(body["share_url"], format!("https://tips.example/{tip_id}"));
// 2. Public read (UNAUTH) surfaces it and is not yet claimable.
let (status, pub_body) = app
.request("GET", &format!("/api/v1/tips/social/{tip_id}/public"), None, None)
.await;
assert_eq!(status, StatusCode::OK, "get_public: {pub_body}");
assert_eq!(pub_body["is_public"], true);
assert_eq!(pub_body["is_claimable"], false);
assert_eq!(pub_body["encrypted_key"], ENC_KEY_HEX);
assert_eq!(pub_body["tip_address"], BTC_ADDR);
// 3. Unknown id -> 404 (not 200-with-nulls).
let (status, _) = app
.request(
"GET",
"/api/v1/tips/social/00000000-0000-0000-0000-000000000000/public",
None,
None,
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
// 4. Cancel the draft (owner) -> {ok:true}.
let (status, cancel_body) = app
.request("POST", &format!("/api/v1/tips/social/{tip_id}/cancel"), Some(&token), None)
.await;
assert_eq!(status, StatusCode::OK, "cancel: {cancel_body}");
assert_eq!(cancel_body["ok"], true);
// 5. Cancelling again -> 404 (no longer a draft).
let (status, _) = app
.request("POST", &format!("/api/v1/tips/social/{tip_id}/cancel"), Some(&token), None)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
// 6. Sent list shows the tip as cancelled.
let (status, sent) = app
.request("GET", "/api/v1/tips/social/sent", Some(&token), None)
.await;
assert_eq!(status, StatusCode::OK, "sent: {sent}");
let tips = sent["tips"].as_array().expect("tips array");
let found = tips.iter().find(|t| t["id"] == tip_id).expect("tip in sent list");
assert_eq!(found["status"], "cancelled");
assert_eq!(found["is_public"], true);
}
#[tokio::test]
async fn db_create_draft_roundtrips_all_columns() {
let Some(app) = tips_app().await else { return };
let uid = app.create_user().await;
use smirk_backend_core::infra::db::NewSocialTip;
let enc = hex::decode("cdcd").unwrap();
let new = NewSocialTip {
sender_user_id: uid,
asset: "btc",
amount: 100_000,
claim_key_hash: Some(CLAIM_HASH),
encrypted_key: Some(&enc),
tip_address: Some(BTC_ADDR),
funding_txid: None,
tip_view_key: None,
confirmations_required: 0,
};
// Exercises the full 31-column FromRow decode on RETURNING.
let row = app.state.db.create_draft_social_tip(new).await.expect("create draft");
assert_eq!(row.status, "draft");
assert_eq!(row.amount, 100_000);
assert!(row.is_public);
assert_eq!(row.encrypted_key.as_deref(), Some(&[0xcd, 0xcd][..]));
}
#[tokio::test]
async fn targeted_tip_is_rejected() {
let Some(app) = tips_app().await else { return };
let (_uid, token, _r) = app.mint_session().await;
let (status, body) = app
.request(
"POST",
"/api/v1/tips/social",
Some(&token),
Some(json!({
"asset": "btc", "amount": 100, "is_public": false,
"platform": "telegram", "username": "bob",
})),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST, "targeted must be rejected: {body}");
}
#[tokio::test]
async fn create_requires_positive_amount_and_claim_hash() {
let Some(app) = tips_app().await else { return };
let (_uid, token, _r) = app.mint_session().await;
// Non-positive amount.
let mut b = draft_body();
b["amount"] = json!(0);
let (status, _) = app.request("POST", "/api/v1/tips/social", Some(&token), Some(b)).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
// Missing claim_key_hash on a public tip.
let mut b = draft_body();
b.as_object_mut().unwrap().remove("claim_key_hash");
let (status, _) = app.request("POST", "/api/v1/tips/social", Some(&token), Some(b)).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn tips_disabled_returns_400() {
// Default config: FEATURE_TIPS off.
let Some(app) = common::try_app().await else {
eprintln!("skipping: TEST_DATABASE_URL not set");
return;
};
let (_uid, token, _r) = app.mint_session().await;
let (status, _) = app
.request("POST", "/api/v1/tips/social", Some(&token), Some(draft_body()))
.await;
assert_eq!(status, StatusCode::BAD_REQUEST, "tips off must 400");
}
// ── Stage 3: money-in (attach-funding + funding verifier) DB-level tests ──────
use smirk_backend_core::error::AppError;
use smirk_backend_core::infra::db::NewSocialTip;
/// Create a draft tip for `uid` and return its id. `conf` = confirmations_required.
async fn make_draft(
app: &common::TestApp,
uid: uuid::Uuid,
asset: &str,
amount: i64,
conf: i32,
view_key: Option<&str>,
) -> uuid::Uuid {
let new = NewSocialTip {
sender_user_id: uid,
asset,
amount,
claim_key_hash: Some(CLAIM_HASH),
encrypted_key: None,
tip_address: Some(BTC_ADDR),
funding_txid: None,
tip_view_key: view_key,
confirmations_required: conf,
};
app.state
.db
.create_draft_social_tip(new)
.await
.expect("create draft")
.id
}
#[tokio::test]
async fn attach_funding_is_idempotent_and_advances_draft() {
let Some(app) = tips_app().await else { return };
let uid = app.create_user().await;
let tip_id = make_draft(&app, uid, "btc", 100_000, 0, None).await;
// First attach: draft -> pending_confirmation, funding_txid recorded.
let r1 = app
.state
.db
.attach_funding_to_tip(tip_id, uid, "txid-aaa")
.await
.expect("attach");
assert_eq!(r1.id, tip_id);
assert_eq!(r1.status, "pending_confirmation");
assert_eq!(r1.funding_txid.as_deref(), Some("txid-aaa"));
assert!(!r1.funding_amount_verified);
// Second attach, SAME txid: idempotent no-op returning the same row.
let r2 = app
.state
.db
.attach_funding_to_tip(tip_id, uid, "txid-aaa")
.await
.expect("attach idempotent");
assert_eq!(r2.id, tip_id);
assert_eq!(r2.status, "pending_confirmation");
assert_eq!(r2.funding_txid.as_deref(), Some("txid-aaa"));
}
#[tokio::test]
async fn attach_funding_conflicting_txid_is_validation_error() {
let Some(app) = tips_app().await else { return };
let uid = app.create_user().await;
let tip_id = make_draft(&app, uid, "btc", 100_000, 0, None).await;
app.state
.db
.attach_funding_to_tip(tip_id, uid, "txid-first")
.await
.expect("attach");
// A DIFFERENT txid on the same tip -> ValidationError (400).
let err = app
.state
.db
.attach_funding_to_tip(tip_id, uid, "txid-DIFFERENT")
.await
.unwrap_err();
assert!(matches!(err, AppError::ValidationError(_)), "expected ValidationError, got {err:?}");
// Not-owned / unknown tip -> NotFound (404).
let other = app.create_user().await;
let err = app
.state
.db
.attach_funding_to_tip(tip_id, other, "txid-x")
.await
.unwrap_err();
assert!(matches!(err, AppError::NotFound(_)), "expected NotFound, got {err:?}");
}
#[tokio::test]
async fn mark_verified_only_from_pending_confirmation() {
let Some(app) = tips_app().await else { return };
let uid = app.create_user().await;
// A draft is NOT pending_confirmation -> mark returns None (guard misses).
let draft_id = make_draft(&app, uid, "btc", 100_000, 0, None).await;
let none = app
.state
.db
.mark_tip_funding_verified(draft_id, 100_000)
.await
.expect("mark on draft");
assert!(none.is_none(), "mark_verified on a draft must return None");
// Attach -> pending_confirmation; verify flips it to pending + records observed.
app.state
.db
.attach_funding_to_tip(draft_id, uid, "txid-v")
.await
.expect("attach");
let verified = app
.state
.db
.mark_tip_funding_verified(draft_id, 250_000)
.await
.expect("verify")
.expect("verify returns row");
assert_eq!(verified.status, "pending");
assert!(verified.funding_amount_verified);
assert_eq!(verified.funding_amount_observed, Some(250_000));
// Re-verify -> None (funding_amount_verified guard no longer matches).
let again = app
.state
.db
.mark_tip_funding_verified(draft_id, 250_000)
.await
.expect("verify idempotent");
assert!(again.is_none(), "re-verify must return None");
}
#[tokio::test]
async fn mark_mismatch_transitions_to_funding_mismatch() {
let Some(app) = tips_app().await else { return };
let uid = app.create_user().await;
let tip_id = make_draft(&app, uid, "btc", 1_000_000, 0, None).await;
app.state
.db
.attach_funding_to_tip(tip_id, uid, "txid-m")
.await
.expect("attach");
let mismatch = app
.state
.db
.mark_tip_funding_mismatch(tip_id, 10)
.await
.expect("mismatch")
.expect("mismatch returns row");
assert_eq!(mismatch.status, "funding_mismatch");
assert!(mismatch.funding_amount_verified);
assert_eq!(mismatch.funding_amount_observed, Some(10));
// Idempotent, and a funding_mismatch row can never be flipped claimable.
let again = app
.state
.db
.mark_tip_funding_mismatch(tip_id, 10)
.await
.expect("mismatch idempotent");
assert!(again.is_none());
let verify_after = app
.state
.db
.mark_tip_funding_verified(tip_id, 10)
.await
.expect("verify after mismatch");
assert!(verify_after.is_none(), "a funding_mismatch row must not become claimable");
}
#[tokio::test]
async fn get_tips_pending_confirmation_filters() {
let Some(app) = tips_app().await else { return };
// Deterministic: this is the only test that creates xmr rows. Clear leftovers
// so the query's LIMIT 50 / ORDER BY created_at window can't hide our row.
sqlx::query("DELETE FROM social_tips WHERE asset = 'xmr'")
.execute(app.state.db.pool())
.await
.expect("clear xmr rows");
let uid = app.create_user().await;
let vk = "0".repeat(64);
// XMR tip: threshold 10 + funding attached -> INCLUDED.
let xmr_id = make_draft(&app, uid, "xmr", 500, 10, Some(vk.as_str())).await;
app.state
.db
.attach_funding_to_tip(xmr_id, uid, "xmrtxid")
.await
.expect("attach xmr");
// XMR draft with NO funding -> EXCLUDED (funding_txid IS NULL, status draft).
let xmr_draft = make_draft(&app, uid, "xmr", 500, 10, Some(vk.as_str())).await;
// BTC tip: threshold 0 -> EXCLUDED (confirmations_required > 0 guard).
let btc_id = make_draft(&app, uid, "btc", 100_000, 0, None).await;
app.state
.db
.attach_funding_to_tip(btc_id, uid, "btctxid")
.await
.expect("attach btc");
let xmr_pending = app
.state
.db
.get_tips_pending_confirmation("xmr")
.await
.expect("xmr pending");
assert!(xmr_pending.iter().any(|t| t.id == xmr_id), "funded xmr tip must be returned");
assert!(
!xmr_pending.iter().any(|t| t.id == xmr_draft),
"unfunded xmr draft must be excluded"
);
let btc_pending = app
.state
.db
.get_tips_pending_confirmation("btc")
.await
.expect("btc pending");
assert!(
!btc_pending.iter().any(|t| t.id == btc_id),
"btc tip (threshold 0) must be excluded from the confirmation query"
);
}
// ── Stage 4: money-out (claim / confirm-sweep / clawback + reconciler) DB tests ──
//
// Exercises the audited money-safety guards directly on the DB layer. BTC tips
// (confirmations_required = 0) manufacture a claimable row with no chain access.
/// Manufacture a claimable (pending, amount-verified) BTC tip owned by `uid`.
async fn make_pending(app: &common::TestApp, uid: uuid::Uuid, txid: &str) -> uuid::Uuid {
let id = make_draft(app, uid, "btc", 100_000, 0, None).await;
app.state
.db
.attach_funding_to_tip(id, uid, txid)
.await
.expect("attach funding");
let row = app
.state
.db
.mark_tip_funding_verified(id, 100_000)
.await
.expect("verify")
.expect("verify returns row");
assert_eq!(row.status, "pending", "make_pending must land in 'pending'");
id
}
/// Manufacture a `claiming` tip (pending -> claiming) locked by `claimant`.
async fn make_claiming(
app: &common::TestApp,
uid: uuid::Uuid,
claimant: uuid::Uuid,
txid: &str,
) -> uuid::Uuid {
let id = make_pending(app, uid, txid).await;
let row = app
.state
.db
.mark_tip_claiming(id, claimant)
.await
.expect("claim")
.expect("claiming row");
assert_eq!(row.status, "claiming");
id
}
/// Manufacture a settled `claimed` tip (with a sweep_block_height witness).
async fn make_claimed(
app: &common::TestApp,
uid: uuid::Uuid,
claimant: uuid::Uuid,
txid: &str,
) -> uuid::Uuid {
let id = make_claiming(app, uid, claimant, txid).await;
let row = app
.state
.db
.confirm_sweep_onchain(id, "onchain-swp", 100, None, None)
.await
.expect("settle")
.expect("claimed row");
assert_eq!(row.status, "claimed");
id
}
/// (a) THE DOUBLE-CLAIM GUARD: a fresh claimable tip claims once; a
/// funding_mismatch / unverified / clawed_back / already-swept row is never
/// claimable. Re-claiming a `claiming` row IS intentionally allowed (verbatim
/// `status IN ('pending','claiming')` — public-tip retry semantics).
#[tokio::test]
async fn mark_tip_claiming_double_claim_guard() {
let Some(app) = tips_app().await else { return };
let sender = app.create_user().await;
let claimant = app.create_user().await;
// Fresh claimable tip: first claim locks it into 'claiming'.
let id = make_pending(&app, sender, "fund-claim-1").await;
let first = app.state.db.mark_tip_claiming(id, claimant).await.expect("claim");
assert!(first.is_some(), "first claim on a pending tip must succeed");
assert_eq!(first.unwrap().status, "claiming");
// Re-claim on a 'claiming' public tip is INTENTIONALLY allowed: the claim
// state is a UX signal, not a lock — the reconciler resolves the on-chain
// sweep-race winner. (Verbatim audit-T1 retry semantics.)
let reclaim = app.state.db.mark_tip_claiming(id, claimant).await.expect("reclaim");
assert!(reclaim.is_some(), "re-claim on a claiming public tip is allowed");
// None on funding_mismatch (audit-T1 fund-loss guard).
let mm = make_draft(&app, sender, "btc", 1_000_000, 0, None).await;
app.state.db.attach_funding_to_tip(mm, sender, "fund-mm").await.expect("attach");
app.state.db.mark_tip_funding_mismatch(mm, 10).await.expect("mismatch").expect("row");
assert!(
app.state.db.mark_tip_claiming(mm, claimant).await.expect("claim mm").is_none(),
"a funding_mismatch tip must never be claimable"
);
// None on an unverified (pending_confirmation) tip.
let pc = make_draft(&app, sender, "btc", 100_000, 0, None).await;
app.state.db.attach_funding_to_tip(pc, sender, "fund-pc").await.expect("attach");
assert!(
app.state.db.mark_tip_claiming(pc, claimant).await.expect("claim pc").is_none(),
"an unverified tip must not be claimable"
);
// None on a clawed_back tip.
let cb = make_pending(&app, sender, "fund-cb").await;
app.state.db.clawback_social_tip(cb, sender).await.expect("clawback").expect("row");
assert!(
app.state.db.mark_tip_claiming(cb, claimant).await.expect("claim cb").is_none(),
"a clawed_back tip must not be claimable"
);
// None on an already-swept (settled) tip.
let swept = make_claimed(&app, sender, claimant, "fund-swept").await;
assert!(
app.state.db.mark_tip_claiming(swept, claimant).await.expect("claim swept").is_none(),
"a settled tip must not be re-claimable"
);
}
/// (b) confirm_tip_sweep is first-recorder-wins: a second, DIFFERENT txid must
/// NOT overwrite the recorded winner (both calls return the live row with the
/// FIRST txid). A never-claimed tip returns None.
#[tokio::test]
async fn confirm_tip_sweep_first_recorder_wins() {
let Some(app) = tips_app().await else { return };
let sender = app.create_user().await;
let claimant = app.create_user().await;
let id = make_claiming(&app, sender, claimant, "fund-sweep").await;
// First recorder writes txid-A; the row STAYS 'claiming' (record, not settle).
let r1 = app.state.db.confirm_tip_sweep(id, claimant, "txid-A").await.expect("record").expect("row");
assert_eq!(r1.sweep_txid.as_deref(), Some("txid-A"));
assert_eq!(r1.status, "claiming", "confirm_tip_sweep records, never settles");
// A DIFFERENT txid does not overwrite; fallthrough returns the live row (A).
let r2 = app.state.db.confirm_tip_sweep(id, claimant, "txid-B").await.expect("second").expect("live row");
assert_eq!(
r2.sweep_txid.as_deref(),
Some("txid-A"),
"first-recorder wins: a different txid must not overwrite"
);
assert_eq!(r2.status, "claiming");
// Same txid again is an idempotent no-op returning A.
let r3 = app.state.db.confirm_tip_sweep(id, claimant, "txid-A").await.expect("idem").expect("row");
assert_eq!(r3.sweep_txid.as_deref(), Some("txid-A"));
// A never-claimed (pending) tip → None.
let pending = make_pending(&app, sender, "fund-cs-pending").await;
assert!(
app.state.db.confirm_tip_sweep(pending, claimant, "txid-x").await.expect("cs pending").is_none(),
"confirm-sweep on a non-claiming tip returns None"
);
}
/// (c) confirm_sweep_onchain settles 'claiming' -> 'claimed' ONLY, and it is the
/// only path to 'claimed'. It records the height witness and preserves
/// claimed_by when passed None; a second call is a no-op.
#[tokio::test]
async fn confirm_sweep_onchain_settles_claiming_only() {
let Some(app) = tips_app().await else { return };
let sender = app.create_user().await;
let claimant = app.create_user().await;
let id = make_claiming(&app, sender, claimant, "fund-settle").await;
let settled = app
.state
.db
.confirm_sweep_onchain(id, "swp-1", 500, Some("blockhash"), None)
.await
.expect("settle")
.expect("claimed row");
assert_eq!(settled.status, "claimed");
assert!(settled.sweep_confirmed_at.is_some());
assert_eq!(settled.sweep_block_height, Some(500));
assert_eq!(settled.sweep_txid.as_deref(), Some("swp-1"));
assert_eq!(
settled.claimed_by_user_id,
Some(claimant),
"claimed_by is preserved when claimed_by=None"
);
// Second settle is a no-op (one-way lock).
assert!(
app.state.db.confirm_sweep_onchain(id, "swp-2", 501, None, None).await.expect("resettle").is_none(),
"an already-claimed row cannot be re-settled"
);
// A pending (non-claiming) tip cannot be settled — 'claiming' is the only
// entry into 'claimed'.
let pending = make_pending(&app, sender, "fund-settle-pending").await;
assert!(
app.state.db.confirm_sweep_onchain(pending, "swp", 1, None, None).await.expect("settle pending").is_none(),
"confirm_sweep_onchain requires status='claiming'"
);
let still = app.state.db.get_social_tip(pending).await.expect("get").expect("row");
assert_eq!(still.status, "pending", "a pending tip must not be flipped to claimed");
}
/// (d) revert_sweep_on_reorg reverts 'claimed' -> 'claiming' ONLY, clearing the
/// sweep witness (incl. sweep_txid so the next cycle records the new winner).
#[tokio::test]
async fn revert_sweep_on_reorg_only_from_claimed() {
let Some(app) = tips_app().await else { return };
let sender = app.create_user().await;
let claimant = app.create_user().await;
let id = make_claimed(&app, sender, claimant, "fund-reorg").await;
let reverted = app.state.db.revert_sweep_on_reorg(id).await.expect("revert").expect("reverted row");
assert_eq!(reverted.status, "claiming");
assert!(reverted.sweep_confirmed_at.is_none());
assert!(
reverted.sweep_txid.is_none(),
"reorg clears sweep_txid so the next confirm cycle records the new on-chain winner"
);
assert!(reverted.sweep_block_height.is_none());
// Second revert is a no-op (now 'claiming', not 'claimed').
assert!(
app.state.db.revert_sweep_on_reorg(id).await.expect("re-revert").is_none(),
"revert requires status='claimed'"
);
// Revert on a plain pending tip → None.
let pending = make_pending(&app, sender, "fund-reorg-pending").await;
assert!(
app.state.db.revert_sweep_on_reorg(pending).await.expect("revert pending").is_none(),
"a pending tip cannot be reorg-reverted"
);
}
/// (e) clawback succeeds for the SENDER on pending / pending_confirmation /
/// claiming / funding_mismatch / cancelled while sweep_confirmed_at IS NULL,
/// is owner-scoped, and is BLOCKED once the sweep confirmed.
#[tokio::test]
async fn clawback_status_matrix_and_settled_block() {
let Some(app) = tips_app().await else { return };
let sender = app.create_user().await;
let claimant = app.create_user().await;
// pending → clawable.
let pending = make_pending(&app, sender, "cb-pending").await;
assert!(app.state.db.clawback_social_tip(pending, sender).await.expect("cb").is_some());
// pending_confirmation → clawable.
let pc = make_draft(&app, sender, "btc", 100_000, 0, None).await;
app.state.db.attach_funding_to_tip(pc, sender, "cb-pc").await.expect("attach");
assert!(app.state.db.clawback_social_tip(pc, sender).await.expect("cb").is_some());
// claiming → clawable (sender racing a claimer).
let claiming = make_claiming(&app, sender, claimant, "cb-claiming").await;
assert!(app.state.db.clawback_social_tip(claiming, sender).await.expect("cb").is_some());
// funding_mismatch → clawable.
let mm = make_draft(&app, sender, "btc", 1_000_000, 0, None).await;
app.state.db.attach_funding_to_tip(mm, sender, "cb-mm").await.expect("attach");
app.state.db.mark_tip_funding_mismatch(mm, 10).await.expect("mismatch").expect("row");
assert!(app.state.db.clawback_social_tip(mm, sender).await.expect("cb").is_some());
// cancelled (GC'd but still-funded draft) → clawable.
let cancelled = make_draft(&app, sender, "btc", 100_000, 0, None).await;
app.state.db.cancel_draft_social_tip(cancelled, sender).await.expect("cancel").expect("id");
assert!(app.state.db.clawback_social_tip(cancelled, sender).await.expect("cb").is_some());
// Owner-scoped: a stranger cannot claw back; the real sender can.
let owned = make_pending(&app, sender, "cb-owner").await;
let stranger = app.create_user().await;
assert!(
app.state.db.clawback_social_tip(owned, stranger).await.expect("cb stranger").is_none(),
"only the sender can claw back"
);
assert!(app.state.db.clawback_social_tip(owned, sender).await.expect("cb owner").is_some());
// BLOCKED once the sweep confirmed (sweep_confirmed_at IS NOT NULL).
let swept = make_claimed(&app, sender, claimant, "cb-swept").await;
assert!(
app.state.db.clawback_social_tip(swept, sender).await.expect("cb swept").is_none(),
"a settled tip must not be clawed back"
);
}
/// Reconciler poll queries select the right rows. A huge limit avoids the
/// ordering / rate-limit windows hiding our rows amid other tests' rows.
#[tokio::test]
async fn reconciler_poll_queries_select_the_right_rows() {
let Some(app) = tips_app().await else { return };
let sender = app.create_user().await;
let claimant = app.create_user().await;
let claiming = make_claiming(&app, sender, claimant, "poll-claiming").await;
let pending = make_pending(&app, sender, "poll-pending").await;
let claimed = make_claimed(&app, sender, claimant, "poll-claimed").await;
let awaiting = app.state.db.get_tips_awaiting_sweep_confirmation(1_000_000).await.expect("awaiting");
assert!(awaiting.iter().any(|t| t.id == claiming), "a claiming tip awaits sweep confirmation");
assert!(!awaiting.iter().any(|t| t.id == pending), "a pending tip does not await sweep confirmation");
assert!(!awaiting.iter().any(|t| t.id == claimed), "a settled tip does not await sweep confirmation");
let reorg = app.state.db.get_settled_tips_for_reorg_check(1_000_000).await.expect("reorg");
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");
}