mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
chore: cargo fmt with current stable rustfmt (repo-wide)
The repo pins channel=stable (no rustfmt version), so CI now pulls rustfmt 1.9.0, which reformats code last touched by an older rustfmt. main itself failed `cargo fmt --check` under the current toolchain; this is a formatting-only sweep across 24 files to make the fmt gate green. No behavior change; tests + clippy -D warnings still pass.
This commit is contained in:
+33
-14
@@ -838,15 +838,18 @@ pub async fn admin_config_put(
|
||||
let mut merged_json =
|
||||
serde_json::to_value(&persisted).map_err(|e| AppError::Internal(e.to_string()))?;
|
||||
json_merge_skip_null(&mut merged_json, &patch_json);
|
||||
let merged: crate::config_overlay::SettingsOverlay = serde_json::from_value(merged_json.clone())
|
||||
.map_err(|e| AppError::ValidationError(format!("bad settings: {e}")))?;
|
||||
let merged: crate::config_overlay::SettingsOverlay =
|
||||
serde_json::from_value(merged_json.clone())
|
||||
.map_err(|e| AppError::ValidationError(format!("bad settings: {e}")))?;
|
||||
let candidate = state.config_base.apply_overlay(&merged)?; // ValidationError -> 400
|
||||
|
||||
// Classify the CHANGED fields: which sections changed + does any need a restart?
|
||||
let mut any_restart = false;
|
||||
let mut changed_sections: Vec<String> = Vec::new();
|
||||
for (section, fields) in &patch_obj {
|
||||
let Some(fields) = fields.as_object() else { continue };
|
||||
let Some(fields) = fields.as_object() else {
|
||||
continue;
|
||||
};
|
||||
let touched: Vec<&String> = fields
|
||||
.iter()
|
||||
.filter(|(_, v)| !v.is_null())
|
||||
@@ -907,7 +910,9 @@ pub async fn admin_config_put(
|
||||
let shutdown = state.shutdown.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(750)).await;
|
||||
tracing::info!("config auto-apply: self-restart to apply a restart-required change");
|
||||
tracing::info!(
|
||||
"config auto-apply: self-restart to apply a restart-required change"
|
||||
);
|
||||
shutdown.notify_one();
|
||||
});
|
||||
}
|
||||
@@ -933,10 +938,7 @@ fn json_merge_skip_null(base: &mut serde_json::Value, patch: &serde_json::Value)
|
||||
if pv.is_null() {
|
||||
continue;
|
||||
}
|
||||
json_merge_skip_null(
|
||||
b.entry(k.clone()).or_insert(serde_json::Value::Null),
|
||||
pv,
|
||||
);
|
||||
json_merge_skip_null(b.entry(k.clone()).or_insert(serde_json::Value::Null), pv);
|
||||
}
|
||||
}
|
||||
(b, p) => *b = p.clone(),
|
||||
@@ -1076,7 +1078,11 @@ pub async fn admin_asset(Path(path): Path<String>) -> Response {
|
||||
} else {
|
||||
"application/octet-stream"
|
||||
};
|
||||
([(axum::http::header::CONTENT_TYPE, mime)], f.data.into_owned()).into_response()
|
||||
(
|
||||
[(axum::http::header::CONTENT_TYPE, mime)],
|
||||
f.data.into_owned(),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
None => StatusCode::NOT_FOUND.into_response(),
|
||||
}
|
||||
@@ -1097,7 +1103,10 @@ pub fn routes() -> Router<Arc<AppState>> {
|
||||
.route("/admin/keys/:id", delete(admin_keys_revoke))
|
||||
.route("/admin/keys/:id/rotate", post(admin_keys_rotate))
|
||||
.route("/admin/config", get(admin_config_get).put(admin_config_put))
|
||||
.route("/admin/invites", post(admin_invites_mint).get(admin_invites_list))
|
||||
.route(
|
||||
"/admin/invites",
|
||||
post(admin_invites_mint).get(admin_invites_list),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1111,9 +1120,15 @@ mod tests {
|
||||
fn admin_console_is_embedded() {
|
||||
let index = AdminAssets::get("index.html").expect("admin-ui/dist/index.html embedded");
|
||||
let html = std::str::from_utf8(&index.data).expect("index.html is utf-8");
|
||||
assert!(html.contains("Smirk Operator Console"), "console title missing");
|
||||
assert!(
|
||||
html.contains("Smirk Operator Console"),
|
||||
"console title missing"
|
||||
);
|
||||
// Vite rewrites asset URLs to the /admin/ base; the served route matches.
|
||||
assert!(html.contains("/admin/assets/"), "asset base not rewritten to /admin/");
|
||||
assert!(
|
||||
html.contains("/admin/assets/"),
|
||||
"asset base not rewritten to /admin/"
|
||||
);
|
||||
assert!(
|
||||
AdminAssets::iter().any(|p| p.starts_with("assets/")),
|
||||
"no built JS/CSS assets embedded",
|
||||
@@ -1145,8 +1160,12 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), axum::http::StatusCode::OK);
|
||||
assert_eq!(res.headers()["content-type"], "text/html; charset=utf-8");
|
||||
let body = axum::body::to_bytes(res.into_body(), 1 << 20).await.unwrap();
|
||||
assert!(std::str::from_utf8(&body).unwrap().contains("Smirk Operator Console"));
|
||||
let body = axum::body::to_bytes(res.into_body(), 1 << 20)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(std::str::from_utf8(&body)
|
||||
.unwrap()
|
||||
.contains("Smirk Operator Console"));
|
||||
|
||||
// GET /admin/assets/<real built asset> -> served with a sane content-type.
|
||||
let asset = AdminAssets::iter()
|
||||
|
||||
+42
-18
@@ -133,9 +133,17 @@ fn parse_asset(asset: &str) -> Result<AssetType, AppError> {
|
||||
|
||||
/// Parse the wire key list into the `(AssetType, public_key, public_spend_key)`
|
||||
/// tuples the atomic-registration DB path binds directly. A bad asset is a 400.
|
||||
fn parse_keys(keys: &[AssetPublicKey]) -> Result<Vec<(AssetType, String, Option<String>)>, AppError> {
|
||||
fn parse_keys(
|
||||
keys: &[AssetPublicKey],
|
||||
) -> Result<Vec<(AssetType, String, Option<String>)>, AppError> {
|
||||
keys.iter()
|
||||
.map(|k| Ok((parse_asset(&k.asset)?, k.public_key.clone(), k.public_spend_key.clone())))
|
||||
.map(|k| {
|
||||
Ok((
|
||||
parse_asset(&k.asset)?,
|
||||
k.public_key.clone(),
|
||||
k.public_spend_key.clone(),
|
||||
))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -178,14 +186,10 @@ fn pow_applies(state: &AppState, pubkey_hash_lc: &str) -> bool {
|
||||
/// request Host. Fail closed when unset (Nostr identity is disabled).
|
||||
fn nip98_url(state: &AppState, path: &str) -> Result<String, AppError> {
|
||||
let cfg = state.cfg();
|
||||
let base = cfg
|
||||
.identity
|
||||
.public_api_url
|
||||
.as_deref()
|
||||
.ok_or_else(|| {
|
||||
warn!("Nostr endpoint reached but PUBLIC_API_URL is unset; refusing");
|
||||
AppError::AuthError("Nostr authentication is not enabled".into())
|
||||
})?;
|
||||
let base = cfg.identity.public_api_url.as_deref().ok_or_else(|| {
|
||||
warn!("Nostr endpoint reached but PUBLIC_API_URL is unset; refusing");
|
||||
AppError::AuthError("Nostr authentication is not enabled".into())
|
||||
})?;
|
||||
Ok(format!("{}{}", base.trim_end_matches('/'), path))
|
||||
}
|
||||
|
||||
@@ -540,7 +544,10 @@ pub async fn extension_register(
|
||||
.db
|
||||
.create_user_consuming_gates(
|
||||
gates.invite_code_hash.as_deref(),
|
||||
gates.payment.as_ref().map(|(id, pkh)| (id.as_str(), pkh.as_str())),
|
||||
gates
|
||||
.payment
|
||||
.as_ref()
|
||||
.map(|(id, pkh)| (id.as_str(), pkh.as_str())),
|
||||
NewUser {
|
||||
username: req.username.clone(),
|
||||
pubkey_hash: Some(pubkey_hash.clone()),
|
||||
@@ -699,9 +706,14 @@ struct GatedRegistration {
|
||||
/// Require an invite code and return its hash, erroring if the gate needs one but
|
||||
/// none was supplied (the presence check + literal the old `enforce_invite` used).
|
||||
fn require_invite_hash(invite_code: Option<&str>) -> Result<String, AppError> {
|
||||
let code = invite_code.map(str::trim).filter(|c| !c.is_empty()).ok_or_else(|| {
|
||||
AppError::ValidationError("An invite code is required to register on this instance.".into())
|
||||
})?;
|
||||
let code = invite_code
|
||||
.map(str::trim)
|
||||
.filter(|c| !c.is_empty())
|
||||
.ok_or_else(|| {
|
||||
AppError::ValidationError(
|
||||
"An invite code is required to register on this instance.".into(),
|
||||
)
|
||||
})?;
|
||||
Ok(crate::core::invite::hash_invite_code(code))
|
||||
}
|
||||
|
||||
@@ -729,7 +741,10 @@ async fn plan_gate_consume(
|
||||
has_invite,
|
||||
has_payment,
|
||||
) {
|
||||
GatePlan::Open => Ok(GatedRegistration { invite_code_hash: None, payment: None }),
|
||||
GatePlan::Open => Ok(GatedRegistration {
|
||||
invite_code_hash: None,
|
||||
payment: None,
|
||||
}),
|
||||
GatePlan::All => {
|
||||
let invite_code_hash = if state.cfg().registration.require_invite {
|
||||
Some(require_invite_hash(invite_code)?)
|
||||
@@ -1711,7 +1726,10 @@ pub async fn nostr_register(
|
||||
.db
|
||||
.create_user_consuming_gates(
|
||||
gates.invite_code_hash.as_deref(),
|
||||
gates.payment.as_ref().map(|(id, pkh)| (id.as_str(), pkh.as_str())),
|
||||
gates
|
||||
.payment
|
||||
.as_ref()
|
||||
.map(|(id, pkh)| (id.as_str(), pkh.as_str())),
|
||||
NewUser {
|
||||
username: req.username.clone(),
|
||||
pubkey_hash: None,
|
||||
@@ -1771,7 +1789,10 @@ pub fn routes() -> Router<Arc<AppState>> {
|
||||
.route("/auth/logout", post(logout))
|
||||
.route("/auth/me", get(get_me))
|
||||
.route("/auth/nostr", post(nostr_login))
|
||||
.route("/auth/nostr/register-challenge", get(nostr_register_challenge))
|
||||
.route(
|
||||
"/auth/nostr/register-challenge",
|
||||
get(nostr_register_challenge),
|
||||
)
|
||||
.route("/auth/nostr/register", post(nostr_register))
|
||||
.route("/auth/nostr/link-challenge", get(nostr_link_challenge))
|
||||
.route("/auth/nostr/link", post(nostr_link))
|
||||
@@ -1832,7 +1853,10 @@ mod tests {
|
||||
#[test]
|
||||
fn any_mode_single_gate_is_just_that_gate() {
|
||||
assert_eq!(plan_gates(Any, true, false, false, false), GatePlan::Invite);
|
||||
assert_eq!(plan_gates(Any, false, true, false, false), GatePlan::Payment);
|
||||
assert_eq!(
|
||||
plan_gates(Any, false, true, false, false),
|
||||
GatePlan::Payment
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+1
-5
@@ -104,11 +104,7 @@ pub async fn root(State(state): State<Arc<AppState>>) -> Response {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
}
|
||||
let cfg = state.cfg();
|
||||
let title = cfg
|
||||
.landing
|
||||
.title
|
||||
.as_deref()
|
||||
.unwrap_or("smirk-backend-core");
|
||||
let title = cfg.landing.title.as_deref().unwrap_or("smirk-backend-core");
|
||||
let t = html_escape(title);
|
||||
let html = format!(
|
||||
"<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
|
||||
|
||||
+10
-7
@@ -321,7 +321,9 @@ pub async fn create_social_tip(
|
||||
let encrypted_key: Option<Vec<u8>> = match req.encrypted_key.as_deref() {
|
||||
Some(h) if !h.is_empty() => {
|
||||
if h.len() > MAX_ENCRYPTED_KEY_HEX {
|
||||
return Err(AppError::ValidationError("encrypted_key is too large.".into()));
|
||||
return Err(AppError::ValidationError(
|
||||
"encrypted_key is too large.".into(),
|
||||
));
|
||||
}
|
||||
Some(
|
||||
hex::decode(h)
|
||||
@@ -560,10 +562,14 @@ pub async fn attach_funding(
|
||||
// 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()));
|
||||
return Err(AppError::ValidationError(
|
||||
"funding_txid is required.".into(),
|
||||
));
|
||||
}
|
||||
if funding_txid.len() > 128 {
|
||||
return Err(AppError::ValidationError("funding_txid is too long.".into()));
|
||||
return Err(AppError::ValidationError(
|
||||
"funding_txid is too long.".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// The DB fn returns Ok(row) / NotFound / ValidationError; just surface it.
|
||||
@@ -716,10 +722,7 @@ pub fn routes() -> Router<Arc<AppState>> {
|
||||
.route("/tips/social/claimable", get(get_claimable_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),
|
||||
)
|
||||
.route("/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))
|
||||
|
||||
+20
-9
@@ -43,7 +43,10 @@ fn looks_placeholder(s: &str) -> bool {
|
||||
/// never leaves the host (e.g. a payment processor reached over an SSH tunnel or
|
||||
/// a local reverse proxy), so the https requirement is relaxed for it.
|
||||
fn is_loopback_url(s: &str) -> bool {
|
||||
match url::Url::parse(s).ok().and_then(|u| u.host().map(|h| h.to_owned())) {
|
||||
match url::Url::parse(s)
|
||||
.ok()
|
||||
.and_then(|u| u.host().map(|h| h.to_owned()))
|
||||
{
|
||||
Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
|
||||
Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
|
||||
Some(url::Host::Domain(d)) => d.eq_ignore_ascii_case("localhost"),
|
||||
@@ -1052,7 +1055,10 @@ impl Config {
|
||||
// booting a feed that can never populate (which would still advertise
|
||||
// prices:true and silently serve nothing).
|
||||
if self.features.prices {
|
||||
if !matches!(self.features.prices_provider.as_str(), "coingecko" | "kraken") {
|
||||
if !matches!(
|
||||
self.features.prices_provider.as_str(),
|
||||
"coingecko" | "kraken"
|
||||
) {
|
||||
return Err(cfg_err(format!(
|
||||
"PRICES_PROVIDER {:?} is not supported; supported: coingecko, kraken",
|
||||
self.features.prices_provider
|
||||
@@ -1300,7 +1306,8 @@ impl Config {
|
||||
if p.provider_url.trim().is_empty() {
|
||||
return Err(cfg_err("PREMIUM_ENABLED needs PAYMENT_PROVIDER_URL"));
|
||||
}
|
||||
if prod && !p.provider_url.starts_with("https://") && !is_loopback_url(&p.provider_url) {
|
||||
if prod && !p.provider_url.starts_with("https://") && !is_loopback_url(&p.provider_url)
|
||||
{
|
||||
return Err(cfg_err(
|
||||
"PAYMENT_PROVIDER_URL must be https:// in production (loopback exempt)",
|
||||
));
|
||||
@@ -1606,11 +1613,12 @@ mod tests {
|
||||
#[test]
|
||||
fn overlay_toggles_registration_gates() {
|
||||
use crate::config_overlay::SettingsOverlay;
|
||||
let ov: SettingsOverlay = serde_json::from_str(
|
||||
r#"{"registration":{"require_invite":true,"gate_mode":"any"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let applied = valid().apply_overlay(&ov).expect("valid registration overlay applies");
|
||||
let ov: SettingsOverlay =
|
||||
serde_json::from_str(r#"{"registration":{"require_invite":true,"gate_mode":"any"}}"#)
|
||||
.unwrap();
|
||||
let applied = valid()
|
||||
.apply_overlay(&ov)
|
||||
.expect("valid registration overlay applies");
|
||||
assert!(applied.registration.require_invite);
|
||||
assert_eq!(applied.registration.gate_mode, GateMode::Any);
|
||||
}
|
||||
@@ -1697,7 +1705,10 @@ mod tests {
|
||||
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");
|
||||
assert!(
|
||||
c.validate().is_err(),
|
||||
"tips on without a share base must fail"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+14
-7
@@ -554,10 +554,9 @@ mod tests {
|
||||
#[test]
|
||||
fn overlay_parses_sparse_json_ignoring_absent_and_unknown_sections() {
|
||||
// Absent sections stay None; unknown sections are ignored (forward-compat).
|
||||
let ov: SettingsOverlay = serde_json::from_str(
|
||||
r#"{"restore":{"policy":"unlimited"},"future_section":{"x":1}}"#,
|
||||
)
|
||||
.expect("sparse overlay parses");
|
||||
let ov: SettingsOverlay =
|
||||
serde_json::from_str(r#"{"restore":{"policy":"unlimited"},"future_section":{"x":1}}"#)
|
||||
.expect("sparse overlay parses");
|
||||
assert!(ov.landing.is_none());
|
||||
assert!(ov.retention.is_none());
|
||||
assert_eq!(ov.restore.unwrap().policy.as_deref(), Some("unlimited"));
|
||||
@@ -565,7 +564,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn runtime_class_classifies_known_and_defaults_restart_for_unknown() {
|
||||
assert_eq!(runtime_class("landing", "enabled"), RuntimeClass::RuntimeSafe);
|
||||
assert_eq!(
|
||||
runtime_class("landing", "enabled"),
|
||||
RuntimeClass::RuntimeSafe
|
||||
);
|
||||
assert_eq!(
|
||||
runtime_class("restore", "max_depth_days"),
|
||||
RuntimeClass::RuntimeSafe
|
||||
@@ -579,7 +581,10 @@ mod tests {
|
||||
runtime_class("console", "restart_apply_mode"),
|
||||
RuntimeClass::RuntimeSafe
|
||||
);
|
||||
assert_eq!(runtime_class("mystery", "field"), RuntimeClass::RestartRequired);
|
||||
assert_eq!(
|
||||
runtime_class("mystery", "field"),
|
||||
RuntimeClass::RestartRequired
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -598,6 +603,8 @@ mod tests {
|
||||
for (section, field) in EDITABLE_FIELDS {
|
||||
let _ = runtime_class(section, field); // no panic / all covered
|
||||
}
|
||||
assert!(EDITABLE_FIELDS.iter().any(|(s, f)| *s == "console" && *f == "restart_apply_mode"));
|
||||
assert!(EDITABLE_FIELDS
|
||||
.iter()
|
||||
.any(|(s, f)| *s == "console" && *f == "restart_apply_mode"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,13 +28,7 @@ fn canonical_json(v: &Value) -> String {
|
||||
keys.sort();
|
||||
let inner: Vec<String> = keys
|
||||
.into_iter()
|
||||
.map(|k| {
|
||||
format!(
|
||||
"{}:{}",
|
||||
Value::String(k.clone()),
|
||||
canonical_json(&map[k])
|
||||
)
|
||||
})
|
||||
.map(|k| format!("{}:{}", Value::String(k.clone()), canonical_json(&map[k])))
|
||||
.collect();
|
||||
format!("{{{}}}", inner.join(","))
|
||||
}
|
||||
|
||||
@@ -169,11 +169,9 @@ impl Database {
|
||||
/// `pending_confirmation` so the funding verifier (not the caller) owns the
|
||||
/// transition to `pending`.
|
||||
#[instrument(skip(self, new))]
|
||||
pub async fn create_social_tip(
|
||||
&self,
|
||||
new: NewSocialTip<'_>,
|
||||
) -> Result<SocialTipRow, AppError> {
|
||||
self.insert_social_tip(new, TipStatus::PendingConfirmation).await
|
||||
pub async fn create_social_tip(&self, new: NewSocialTip<'_>) -> Result<SocialTipRow, AppError> {
|
||||
self.insert_social_tip(new, TipStatus::PendingConfirmation)
|
||||
.await
|
||||
}
|
||||
|
||||
/// All tips this user has sent, newest first (every status).
|
||||
@@ -858,9 +856,7 @@ impl Database {
|
||||
/// 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> {
|
||||
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() \
|
||||
|
||||
@@ -230,10 +230,7 @@ impl Database {
|
||||
/// registered on *this* backend. Used to look up a recipient's linked Nostr
|
||||
/// pubkey so a bare-`grin1…`-addressed send can route over Nostr gift-wrap.
|
||||
#[instrument(skip(self, address))]
|
||||
pub async fn find_user_by_grin_address(
|
||||
&self,
|
||||
address: &str,
|
||||
) -> Result<Option<User>, AppError> {
|
||||
pub async fn find_user_by_grin_address(&self, address: &str) -> Result<Option<User>, AppError> {
|
||||
let sql = format!(
|
||||
"SELECT {USER_COLS} FROM users u \
|
||||
JOIN user_keys k ON k.user_id = u.id \
|
||||
|
||||
@@ -536,7 +536,8 @@ impl ElectrumClient {
|
||||
let count = self.primary_fail_count.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
if count >= PRIMARY_FAIL_THRESHOLD {
|
||||
let until = now_ms.saturating_add(PRIMARY_COOLDOWN.as_millis() as u64);
|
||||
self.primary_cooldown_until_ms.store(until, Ordering::Relaxed);
|
||||
self.primary_cooldown_until_ms
|
||||
.store(until, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -981,7 +982,10 @@ mod tests {
|
||||
let client = ElectrumClient::new(UtxoNetwork::BitcoinMainnet, &cfg).unwrap();
|
||||
let tip = client.get_tip_height().await;
|
||||
assert!(tip.is_ok(), "lenient TLS must connect to {server}: {tip:?}");
|
||||
assert!(tip.unwrap() > 800_000, "sanity: BTC tip height from {server}");
|
||||
assert!(
|
||||
tip.unwrap() > 800_000,
|
||||
"sanity: BTC tip height from {server}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -158,9 +158,7 @@ impl GrinOutputInfo {
|
||||
/// bogus count. Mirrors [`ViewWalletOutputResult::confirmations`].
|
||||
pub fn confirmations(&self, tip_height: u64) -> u64 {
|
||||
match self.block_height {
|
||||
Some(h) if h > 0 && h <= tip_height => {
|
||||
tip_height.saturating_sub(h).saturating_add(1)
|
||||
}
|
||||
Some(h) if h > 0 && h <= tip_height => tip_height.saturating_sub(h).saturating_add(1),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,7 +170,10 @@ impl GrinLwsClient {
|
||||
}
|
||||
|
||||
fn node_err_status(&self, label: &str, status: reqwest::StatusCode) -> AppError {
|
||||
AppError::NodeError(format!("grin-lws {label} failed (HTTP {})", status.as_u16()))
|
||||
AppError::NodeError(format!(
|
||||
"grin-lws {label} failed (HTTP {})",
|
||||
status.as_u16()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -242,8 +242,7 @@ impl LwsClient {
|
||||
Some(_) => Ok(()),
|
||||
None => {
|
||||
self.admin_add_account(address, view_key).await?;
|
||||
let current =
|
||||
self.account_scan_height(address).await?.unwrap_or(u64::MAX);
|
||||
let current = self.account_scan_height(address).await?.unwrap_or(u64::MAX);
|
||||
if start_height < current {
|
||||
// The account is now added at the chain tip. If this backfill
|
||||
// rescan fails, the account is stranded at the tip: it reads a
|
||||
|
||||
+11
-2
@@ -27,7 +27,11 @@ const KRAKEN_URL: &str = "https://api.kraken.com/0/public/Ticker";
|
||||
/// Nonlogs markets — WOW/GRIN, which Kraken doesn't list (priced via BTC/USDT).
|
||||
const NONLOGS_URL: &str = "https://api.nonlogs.io/api/markets";
|
||||
/// Our majors → Kraken USD pair. WOW/GRIN come from nonlogs instead.
|
||||
const KRAKEN_PAIRS: &[(&str, &str)] = &[("btc", "XXBTZUSD"), ("ltc", "XLTCZUSD"), ("xmr", "XXMRZUSD")];
|
||||
const KRAKEN_PAIRS: &[(&str, &str)] = &[
|
||||
("btc", "XXBTZUSD"),
|
||||
("ltc", "XLTCZUSD"),
|
||||
("xmr", "XXMRZUSD"),
|
||||
];
|
||||
/// Lower bound on the refresh interval, to stay within free-tier rate limits.
|
||||
const MIN_INTERVAL_SECS: u64 = 60;
|
||||
/// Upper bound on the price response body. The real payload is a handful of
|
||||
@@ -146,7 +150,12 @@ impl PriceClient {
|
||||
.collect();
|
||||
if !pairs.is_empty() {
|
||||
let joined = pairs.iter().map(|(_, p)| *p).collect::<Vec<_>>().join(",");
|
||||
if let Ok(resp) = self.http.get(KRAKEN_URL).query(&[("pair", joined.as_str())]).send().await
|
||||
if let Ok(resp) = self
|
||||
.http
|
||||
.get(KRAKEN_URL)
|
||||
.query(&[("pair", joined.as_str())])
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
if resp.status().is_success() {
|
||||
let body = read_capped(resp, MAX_PRICE_BODY_BYTES).await?;
|
||||
|
||||
@@ -91,13 +91,12 @@ impl Authorization for AdmissionService {
|
||||
// Premium membership — only the `premium-post` policy consults it, so skip
|
||||
// the (cheap, indexed) lookup entirely under the other policies (and when
|
||||
// the author is already write-allowlisted).
|
||||
let author_premium = if !author_allowlisted
|
||||
&& self.relay.write_policy() == WritePolicy::PremiumPost
|
||||
{
|
||||
self.db.is_premium_npub(&author_hex).await.unwrap_or(false)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let author_premium =
|
||||
if !author_allowlisted && self.relay.write_policy() == WritePolicy::PremiumPost {
|
||||
self.db.is_premium_npub(&author_hex).await.unwrap_or(false)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
// Recipient registration (only when the author isn't already registered /
|
||||
// allowlisted): collect up to MAX_P_TAGS distinct `p` tags and resolve them
|
||||
|
||||
@@ -219,8 +219,26 @@ mod tests {
|
||||
#[test]
|
||||
fn author_allowlist_gates_on_author() {
|
||||
let m = meta(A, 1, FF);
|
||||
assert!(decide(&m, WritePolicy::AuthorAllowlist, 0, true, false, false, false).is_permit());
|
||||
assert!(!decide(&m, WritePolicy::AuthorAllowlist, 0, false, false, false, false).is_permit());
|
||||
assert!(decide(
|
||||
&m,
|
||||
WritePolicy::AuthorAllowlist,
|
||||
0,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
)
|
||||
.is_permit());
|
||||
assert!(!decide(
|
||||
&m,
|
||||
WritePolicy::AuthorAllowlist,
|
||||
0,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
)
|
||||
.is_permit());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -126,7 +126,10 @@ mod tests {
|
||||
#[test]
|
||||
fn wire_strings_match_check_constraint() {
|
||||
assert_eq!(TipStatus::Draft.as_str(), "draft");
|
||||
assert_eq!(TipStatus::PendingConfirmation.as_str(), "pending_confirmation");
|
||||
assert_eq!(
|
||||
TipStatus::PendingConfirmation.as_str(),
|
||||
"pending_confirmation"
|
||||
);
|
||||
assert_eq!(TipStatus::Pending.as_str(), "pending");
|
||||
assert_eq!(TipStatus::Claiming.as_str(), "claiming");
|
||||
assert_eq!(TipStatus::Claimed.as_str(), "claimed");
|
||||
|
||||
@@ -74,7 +74,11 @@ async fn check_asset_confirmations(state: &AppState, asset: &str) -> Result<(),
|
||||
debug!(asset, "no tips awaiting confirmation");
|
||||
return Ok(());
|
||||
}
|
||||
info!(asset, count = tips.len(), "checking tip funding confirmations");
|
||||
info!(
|
||||
asset,
|
||||
count = tips.len(),
|
||||
"checking tip funding confirmations"
|
||||
);
|
||||
|
||||
for tip in &tips {
|
||||
let funding_txid = match tip.funding_txid.as_deref() {
|
||||
@@ -125,7 +129,10 @@ async fn check_grin_confirmations(state: &AppState) -> Result<(), AppError> {
|
||||
debug!("no grin tips awaiting confirmation");
|
||||
return Ok(());
|
||||
}
|
||||
info!(count = tips.len(), "checking grin tip funding confirmations");
|
||||
info!(
|
||||
count = tips.len(),
|
||||
"checking grin tip funding confirmations"
|
||||
);
|
||||
|
||||
// One tip-height read for the whole batch. A node error here aborts the pass
|
||||
// (logged/swallowed by the caller) leaving every row untouched — never a
|
||||
@@ -427,7 +434,11 @@ async fn process_pending_verifications(state: &AppState) -> Result<(), AppError>
|
||||
match verify_funding_amount(state, tip).await {
|
||||
FundingVerification::Verified { observed } => {
|
||||
let observed_i64 = i64::try_from(observed).unwrap_or(i64::MAX);
|
||||
match state.db.mark_tip_funding_verified(tip.id, observed_i64).await {
|
||||
match state
|
||||
.db
|
||||
.mark_tip_funding_verified(tip.id, observed_i64)
|
||||
.await
|
||||
{
|
||||
Ok(Some(_updated)) => {
|
||||
info!(
|
||||
tip_id = %tip.id, asset = %tip.asset,
|
||||
@@ -437,20 +448,32 @@ async fn process_pending_verifications(state: &AppState) -> Result<(), AppError>
|
||||
// Public tips deliver by share URL: no recipient DM /
|
||||
// announcement side-effect to fire here.
|
||||
}
|
||||
Ok(None) => debug!(tip_id = %tip.id, "verified: row no longer pending_confirmation — skipping"),
|
||||
Err(e) => warn!(tip_id = %tip.id, error = %e, "mark_tip_funding_verified DB error"),
|
||||
Ok(None) => {
|
||||
debug!(tip_id = %tip.id, "verified: row no longer pending_confirmation — skipping")
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(tip_id = %tip.id, error = %e, "mark_tip_funding_verified DB error")
|
||||
}
|
||||
}
|
||||
}
|
||||
FundingVerification::Short { observed } => {
|
||||
let observed_i64 = i64::try_from(observed).unwrap_or(i64::MAX);
|
||||
match state.db.mark_tip_funding_mismatch(tip.id, observed_i64).await {
|
||||
match state
|
||||
.db
|
||||
.mark_tip_funding_mismatch(tip.id, observed_i64)
|
||||
.await
|
||||
{
|
||||
Ok(Some(_updated)) => warn!(
|
||||
tip_id = %tip.id, asset = %tip.asset,
|
||||
declared = tip.amount, observed = observed_i64,
|
||||
"tip funding mismatch — sender funded LESS than declared"
|
||||
),
|
||||
Ok(None) => debug!(tip_id = %tip.id, "mismatch: row no longer pending_confirmation — skipping"),
|
||||
Err(e) => warn!(tip_id = %tip.id, error = %e, "mark_tip_funding_mismatch DB error"),
|
||||
Ok(None) => {
|
||||
debug!(tip_id = %tip.id, "mismatch: row no longer pending_confirmation — skipping")
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(tip_id = %tip.id, error = %e, "mark_tip_funding_mismatch DB error")
|
||||
}
|
||||
}
|
||||
}
|
||||
FundingVerification::Unfunded => {
|
||||
@@ -560,7 +583,13 @@ mod tests {
|
||||
&resp(vec![tx(1_000, 0, 100, false), tx(0, 1_000, 110, false)]),
|
||||
1_000,
|
||||
);
|
||||
assert_eq!(r, FundingVerification::Drained { net: 0, total_sent: 1_000 });
|
||||
assert_eq!(
|
||||
r,
|
||||
FundingVerification::Drained {
|
||||
net: 0,
|
||||
total_sent: 1_000
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+4
-1
@@ -50,7 +50,10 @@ pub async fn run_tip_draft_gc_cycle(state: Arc<AppState>) {
|
||||
);
|
||||
}
|
||||
if !cancelled.is_empty() {
|
||||
info!(count = cancelled.len(), "tip-draft GC: cancelled abandoned drafts");
|
||||
info!(
|
||||
count = cancelled.len(),
|
||||
"tip-draft GC: cancelled abandoned drafts"
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => warn!(error = %e, "tip-draft GC cycle failed"),
|
||||
|
||||
@@ -142,10 +142,7 @@ async fn acquire_advisory_lock(
|
||||
/// Release the advisory lock on the SAME connection it was acquired on, then
|
||||
/// drop the connection back to the pool. Best-effort — the lock auto-releases
|
||||
/// when the connection closes.
|
||||
async fn release_advisory_lock(
|
||||
mut conn: sqlx::pool::PoolConnection<sqlx::Postgres>,
|
||||
key: i64,
|
||||
) {
|
||||
async fn release_advisory_lock(mut conn: sqlx::pool::PoolConnection<sqlx::Postgres>, key: i64) {
|
||||
if let Err(e) = sqlx::query("SELECT pg_advisory_unlock($1)")
|
||||
.bind(key)
|
||||
.execute(&mut *conn)
|
||||
@@ -172,7 +169,8 @@ pub async fn run_sweep_reconcile_cycle(state: Arc<AppState>) {
|
||||
|
||||
#[instrument(skip(state))]
|
||||
async fn run_confirm_cycle(state: &AppState) -> Result<(), AppError> {
|
||||
let Some(lock_conn) = acquire_advisory_lock(state, SWEEP_CONFIRM_ADVISORY_LOCK_KEY).await? else {
|
||||
let Some(lock_conn) = acquire_advisory_lock(state, SWEEP_CONFIRM_ADVISORY_LOCK_KEY).await?
|
||||
else {
|
||||
debug!("sweep confirm cycle: advisory lock held by another instance — skipping");
|
||||
return Ok(());
|
||||
};
|
||||
@@ -184,7 +182,10 @@ async fn run_confirm_cycle(state: &AppState) -> Result<(), AppError> {
|
||||
}
|
||||
|
||||
async fn confirm_cycle_inner(state: &AppState) -> Result<(), AppError> {
|
||||
let tips = state.db.get_tips_awaiting_sweep_confirmation(BATCH_SIZE).await?;
|
||||
let tips = state
|
||||
.db
|
||||
.get_tips_awaiting_sweep_confirmation(BATCH_SIZE)
|
||||
.await?;
|
||||
if tips.is_empty() {
|
||||
debug!("no tips awaiting sweep confirmation");
|
||||
return Ok(());
|
||||
@@ -257,7 +258,13 @@ async fn process_awaiting_tip(state: &AppState, tip: &SocialTipRow) -> Result<()
|
||||
let settled = if txid_matches {
|
||||
state
|
||||
.db
|
||||
.confirm_sweep_onchain(tip.id, &winner.txid, winner.block_height as i32, block_hash, None)
|
||||
.confirm_sweep_onchain(
|
||||
tip.id,
|
||||
&winner.txid,
|
||||
winner.block_height as i32,
|
||||
block_hash,
|
||||
None,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
debug!(
|
||||
@@ -267,7 +274,12 @@ async fn process_awaiting_tip(state: &AppState, tip: &SocialTipRow) -> Result<()
|
||||
);
|
||||
state
|
||||
.db
|
||||
.clear_claimed_by_on_settle(tip.id, &winner.txid, winner.block_height as i32, block_hash)
|
||||
.clear_claimed_by_on_settle(
|
||||
tip.id,
|
||||
&winner.txid,
|
||||
winner.block_height as i32,
|
||||
block_hash,
|
||||
)
|
||||
.await?
|
||||
};
|
||||
|
||||
@@ -349,8 +361,9 @@ async fn probe_grin_spends(
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::NodeError("grin client disabled".into()))?;
|
||||
|
||||
let commit = grin_commitment(tip)
|
||||
.ok_or_else(|| AppError::ValidationError("grin commitment missing for sweep probe".into()))?;
|
||||
let commit = grin_commitment(tip).ok_or_else(|| {
|
||||
AppError::ValidationError("grin commitment missing for sweep probe".into())
|
||||
})?;
|
||||
|
||||
let tip_height = grin.get_height().await? as i64;
|
||||
|
||||
@@ -402,10 +415,9 @@ async fn probe_electrum_spends(
|
||||
}
|
||||
.ok_or_else(|| AppError::NodeError(format!("{asset} Electrum client disabled")))?;
|
||||
|
||||
let address = tip
|
||||
.tip_address
|
||||
.as_deref()
|
||||
.ok_or_else(|| AppError::ValidationError("tip_address missing for Electrum probe".into()))?;
|
||||
let address = tip.tip_address.as_deref().ok_or_else(|| {
|
||||
AppError::ValidationError("tip_address missing for Electrum probe".into())
|
||||
})?;
|
||||
|
||||
let history = electrum.get_history(address).await?;
|
||||
// Chain height comes from ELECTRUM, not a local node (the backend runs none).
|
||||
@@ -500,7 +512,10 @@ async fn run_reorg_cycle(state: &AppState) -> Result<(), AppError> {
|
||||
}
|
||||
|
||||
async fn reorg_cycle_inner(state: &AppState) -> Result<(), AppError> {
|
||||
let tips = state.db.get_settled_tips_for_reorg_check(BATCH_SIZE).await?;
|
||||
let tips = state
|
||||
.db
|
||||
.get_settled_tips_for_reorg_check(BATCH_SIZE)
|
||||
.await?;
|
||||
if tips.is_empty() {
|
||||
debug!("no settled tips to reorg-check");
|
||||
return Ok(());
|
||||
@@ -687,10 +702,22 @@ mod tests {
|
||||
// Funding tx: tip address RECEIVES; recv != 0 → not a spend.
|
||||
let tx = VerboseTransaction {
|
||||
txid: "fund".into(),
|
||||
vin: vec![VerboseVin { txid: Some("alice_prev".into()), vout: Some(0), prevout: None }],
|
||||
vin: vec![VerboseVin {
|
||||
txid: Some("alice_prev".into()),
|
||||
vout: Some(0),
|
||||
prevout: None,
|
||||
}],
|
||||
vout: vec![
|
||||
VerboseVout { value: 0.001, n: 0, script_pub_key: spk("ltc1qtip") },
|
||||
VerboseVout { value: 0.296, n: 1, script_pub_key: spk("ltc1qalice") },
|
||||
VerboseVout {
|
||||
value: 0.001,
|
||||
n: 0,
|
||||
script_pub_key: spk("ltc1qtip"),
|
||||
},
|
||||
VerboseVout {
|
||||
value: 0.296,
|
||||
n: 1,
|
||||
script_pub_key: spk("ltc1qalice"),
|
||||
},
|
||||
],
|
||||
};
|
||||
assert!(!is_verbose_tx_spend_of(&tx, "ltc1qtip").unwrap());
|
||||
@@ -701,8 +728,16 @@ mod tests {
|
||||
// Sweep: tip address absent from outputs (recv == 0) → spend.
|
||||
let tx = VerboseTransaction {
|
||||
txid: "sweep".into(),
|
||||
vin: vec![VerboseVin { txid: Some("fund".into()), vout: Some(0), prevout: None }],
|
||||
vout: vec![VerboseVout { value: 0.00099, n: 0, script_pub_key: spk("ltc1qbob") }],
|
||||
vin: vec![VerboseVin {
|
||||
txid: Some("fund".into()),
|
||||
vout: Some(0),
|
||||
prevout: None,
|
||||
}],
|
||||
vout: vec![VerboseVout {
|
||||
value: 0.00099,
|
||||
n: 0,
|
||||
script_pub_key: spk("ltc1qbob"),
|
||||
}],
|
||||
};
|
||||
assert!(is_verbose_tx_spend_of(&tx, "ltc1qtip").unwrap());
|
||||
}
|
||||
@@ -713,10 +748,22 @@ mod tests {
|
||||
// recv != 0 → not a spend (guards the funding-with-change case).
|
||||
let tx = VerboseTransaction {
|
||||
txid: "pay".into(),
|
||||
vin: vec![VerboseVin { txid: Some("payer_prev".into()), vout: Some(0), prevout: None }],
|
||||
vin: vec![VerboseVin {
|
||||
txid: Some("payer_prev".into()),
|
||||
vout: Some(0),
|
||||
prevout: None,
|
||||
}],
|
||||
vout: vec![
|
||||
VerboseVout { value: 0.5, n: 0, script_pub_key: spk("ltc1qtip") },
|
||||
VerboseVout { value: 0.3, n: 1, script_pub_key: spk("ltc1qpayer") },
|
||||
VerboseVout {
|
||||
value: 0.5,
|
||||
n: 0,
|
||||
script_pub_key: spk("ltc1qtip"),
|
||||
},
|
||||
VerboseVout {
|
||||
value: 0.3,
|
||||
n: 1,
|
||||
script_pub_key: spk("ltc1qpayer"),
|
||||
},
|
||||
],
|
||||
};
|
||||
assert!(!is_verbose_tx_spend_of(&tx, "ltc1qtip").unwrap());
|
||||
|
||||
+8
-7
@@ -197,13 +197,14 @@ impl TestApp {
|
||||
extra_headers: &[(&str, &str)],
|
||||
body: Option<Value>,
|
||||
) -> (StatusCode, axum::http::HeaderMap, Value) {
|
||||
let mut builder = Request::builder()
|
||||
.method(method)
|
||||
.uri(uri)
|
||||
.extension(axum::extract::ConnectInfo(std::net::SocketAddr::from((
|
||||
[127, 0, 0, 1],
|
||||
0,
|
||||
))));
|
||||
let mut builder =
|
||||
Request::builder()
|
||||
.method(method)
|
||||
.uri(uri)
|
||||
.extension(axum::extract::ConnectInfo(std::net::SocketAddr::from((
|
||||
[127, 0, 0, 1],
|
||||
0,
|
||||
))));
|
||||
for (k, v) in extra_headers {
|
||||
builder = builder.header(*k, *v);
|
||||
}
|
||||
|
||||
+11
-2
@@ -138,14 +138,23 @@ async fn register_replays_are_rejected() {
|
||||
"keys": [{ "asset": "btc", "public_key": format!("btcpub-{}", &pk[..16]) }],
|
||||
});
|
||||
let (s1, _) = app
|
||||
.request("POST", "/api/v1/auth/nostr/register", None, Some(body.clone()))
|
||||
.request(
|
||||
"POST",
|
||||
"/api/v1/auth/nostr/register",
|
||||
None,
|
||||
Some(body.clone()),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s1, StatusCode::OK);
|
||||
// Same nonce again -> consumed -> rejected (single-use replay guard).
|
||||
let (s2, _) = app
|
||||
.request("POST", "/api/v1/auth/nostr/register", None, Some(body))
|
||||
.await;
|
||||
assert_ne!(s2, StatusCode::OK, "a consumed nonce must not register again");
|
||||
assert_ne!(
|
||||
s2,
|
||||
StatusCode::OK,
|
||||
"a consumed nonce must not register again"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -35,7 +35,9 @@ async fn happy_path_consumes_tokens_creates_user_and_persists_keys() {
|
||||
let db = &app.state.db;
|
||||
|
||||
let hash = hash_invite_code(&generate_invite_code());
|
||||
db.insert_invite_code(&hash, Some("atomic-test")).await.unwrap();
|
||||
db.insert_invite_code(&hash, Some("atomic-test"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let pkh = format!("pay-pkh-{}", Uuid::new_v4());
|
||||
let invoice = format!("inv-{}", Uuid::new_v4());
|
||||
@@ -76,14 +78,19 @@ async fn username_collision_rolls_back_the_invite() {
|
||||
|
||||
// A fresh, claimable invite.
|
||||
let hash = hash_invite_code(&generate_invite_code());
|
||||
db.insert_invite_code(&hash, Some("atomic-test")).await.unwrap();
|
||||
db.insert_invite_code(&hash, Some("atomic-test"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Register a NEW wallet that collides on the taken username.
|
||||
let err = db
|
||||
.create_user_consuming_gates(Some(&hash), None, new_user(Some(&taken)), &keys())
|
||||
.await
|
||||
.expect_err("a colliding username must fail the registration");
|
||||
assert!(matches!(err, AppError::Conflict(_)), "expected 409, got {err:?}");
|
||||
assert!(
|
||||
matches!(err, AppError::Conflict(_)),
|
||||
"expected 409, got {err:?}"
|
||||
);
|
||||
|
||||
// The invite must NOT have been burned — the tx rolled back.
|
||||
assert!(
|
||||
@@ -99,7 +106,9 @@ async fn failed_payment_consume_does_not_burn_the_invite() {
|
||||
|
||||
// A fresh, claimable invite.
|
||||
let hash = hash_invite_code(&generate_invite_code());
|
||||
db.insert_invite_code(&hash, Some("atomic-test")).await.unwrap();
|
||||
db.insert_invite_code(&hash, Some("atomic-test"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// A payment invoice that is ALREADY spent (simulates losing the consume race
|
||||
// in `all` mode after the invite was claimed).
|
||||
|
||||
+462
-88
@@ -42,7 +42,12 @@ async fn public_tip_draft_create_get_cancel_flow() {
|
||||
|
||||
// 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()))
|
||||
.request(
|
||||
"POST",
|
||||
"/api/v1/tips/social",
|
||||
Some(&token),
|
||||
Some(draft_body()),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK, "create: {body}");
|
||||
assert_eq!(body["status"], "draft");
|
||||
@@ -51,7 +56,12 @@ async fn public_tip_draft_create_get_cancel_flow() {
|
||||
|
||||
// 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)
|
||||
.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);
|
||||
@@ -72,14 +82,24 @@ async fn public_tip_draft_create_get_cancel_flow() {
|
||||
|
||||
// 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)
|
||||
.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)
|
||||
.request(
|
||||
"POST",
|
||||
&format!("/api/v1/tips/social/{tip_id}/cancel"),
|
||||
Some(&token),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
|
||||
@@ -89,7 +109,10 @@ async fn public_tip_draft_create_get_cancel_flow() {
|
||||
.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");
|
||||
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);
|
||||
}
|
||||
@@ -113,7 +136,12 @@ async fn db_create_draft_roundtrips_all_columns() {
|
||||
grin_commitment: None,
|
||||
};
|
||||
// Exercises the full 32-column FromRow decode on RETURNING.
|
||||
let row = app.state.db.create_draft_social_tip(new).await.expect("create draft");
|
||||
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);
|
||||
@@ -136,7 +164,11 @@ async fn targeted_tip_is_rejected() {
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST, "targeted must be rejected: {body}");
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::BAD_REQUEST,
|
||||
"targeted must be rejected: {body}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -147,13 +179,17 @@ async fn create_requires_positive_amount_and_claim_hash() {
|
||||
// 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;
|
||||
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;
|
||||
let (status, _) = app
|
||||
.request("POST", "/api/v1/tips/social", Some(&token), Some(b))
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
@@ -166,7 +202,12 @@ async fn tips_disabled_returns_400() {
|
||||
};
|
||||
let (_uid, token, _r) = app.mint_session().await;
|
||||
let (status, _) = app
|
||||
.request("POST", "/api/v1/tips/social", Some(&token), Some(draft_body()))
|
||||
.request(
|
||||
"POST",
|
||||
"/api/v1/tips/social",
|
||||
Some(&token),
|
||||
Some(draft_body()),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST, "tips off must 400");
|
||||
}
|
||||
@@ -253,7 +294,10 @@ async fn attach_funding_conflicting_txid_is_validation_error() {
|
||||
.attach_funding_to_tip(tip_id, uid, "txid-DIFFERENT")
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, AppError::ValidationError(_)), "expected ValidationError, got {err:?}");
|
||||
assert!(
|
||||
matches!(err, AppError::ValidationError(_)),
|
||||
"expected ValidationError, got {err:?}"
|
||||
);
|
||||
|
||||
// Not-owned / unknown tip -> NotFound (404).
|
||||
let other = app.create_user().await;
|
||||
@@ -263,7 +307,10 @@ async fn attach_funding_conflicting_txid_is_validation_error() {
|
||||
.attach_funding_to_tip(tip_id, other, "txid-x")
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, AppError::NotFound(_)), "expected NotFound, got {err:?}");
|
||||
assert!(
|
||||
matches!(err, AppError::NotFound(_)),
|
||||
"expected NotFound, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -344,7 +391,10 @@ async fn mark_mismatch_transitions_to_funding_mismatch() {
|
||||
.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");
|
||||
assert!(
|
||||
verify_after.is_none(),
|
||||
"a funding_mismatch row must not become claimable"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -385,7 +435,10 @@ async fn get_tips_pending_confirmation_filters() {
|
||||
.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_id),
|
||||
"funded xmr tip must be returned"
|
||||
);
|
||||
assert!(
|
||||
!xmr_pending.iter().any(|t| t.id == xmr_draft),
|
||||
"unfunded xmr draft must be excluded"
|
||||
@@ -477,45 +530,96 @@ async fn mark_tip_claiming_double_claim_guard() {
|
||||
|
||||
// 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");
|
||||
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");
|
||||
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");
|
||||
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(),
|
||||
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");
|
||||
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(),
|
||||
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");
|
||||
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(),
|
||||
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(),
|
||||
app.state
|
||||
.db
|
||||
.mark_tip_claiming(swept, claimant)
|
||||
.await
|
||||
.expect("claim swept")
|
||||
.is_none(),
|
||||
"a settled tip must not be re-claimable"
|
||||
);
|
||||
}
|
||||
@@ -531,12 +635,27 @@ async fn confirm_tip_sweep_first_recorder_wins() {
|
||||
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");
|
||||
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");
|
||||
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");
|
||||
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"),
|
||||
@@ -545,13 +664,24 @@ async fn confirm_tip_sweep_first_recorder_wins() {
|
||||
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");
|
||||
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(),
|
||||
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"
|
||||
);
|
||||
}
|
||||
@@ -585,7 +715,12 @@ async fn confirm_sweep_onchain_settles_claiming_only() {
|
||||
|
||||
// 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(),
|
||||
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"
|
||||
);
|
||||
|
||||
@@ -593,11 +728,25 @@ async fn confirm_sweep_onchain_settles_claiming_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(),
|
||||
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");
|
||||
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
|
||||
@@ -612,7 +761,13 @@ async fn revert_sweep_on_reorg_only_from_claimed() {
|
||||
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");
|
||||
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_eq!(
|
||||
@@ -624,14 +779,24 @@ async fn revert_sweep_on_reorg_only_from_claimed() {
|
||||
|
||||
// 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(),
|
||||
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(),
|
||||
app.state
|
||||
.db
|
||||
.revert_sweep_on_reorg(pending)
|
||||
.await
|
||||
.expect("revert pending")
|
||||
.is_none(),
|
||||
"a pending tip cannot be reorg-reverted"
|
||||
);
|
||||
}
|
||||
@@ -647,41 +812,105 @@ async fn clawback_status_matrix_and_settled_block() {
|
||||
|
||||
// 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());
|
||||
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());
|
||||
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());
|
||||
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());
|
||||
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());
|
||||
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(),
|
||||
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());
|
||||
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(),
|
||||
app.state
|
||||
.db
|
||||
.clawback_social_tip(swept, sender)
|
||||
.await
|
||||
.expect("cb swept")
|
||||
.is_none(),
|
||||
"a settled tip must not be clawed back"
|
||||
);
|
||||
}
|
||||
@@ -698,14 +927,39 @@ async fn reconciler_poll_queries_select_the_right_rows() {
|
||||
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 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");
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Stage 5: lifecycle + draft garbage-collection DB tests ────────────────────
|
||||
@@ -737,13 +991,36 @@ async fn cancel_old_drafts_flips_stale_draft_only() {
|
||||
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 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");
|
||||
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");
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -755,11 +1032,19 @@ async fn cancel_stuck_pending_confirmation_flips_stale_only() {
|
||||
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");
|
||||
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");
|
||||
app.state
|
||||
.db
|
||||
.attach_funding_to_tip(fresh, uid, "gc-fresh-pc")
|
||||
.await
|
||||
.expect("attach");
|
||||
|
||||
let cancelled = app
|
||||
.state
|
||||
@@ -776,9 +1061,21 @@ async fn cancel_stuck_pending_confirmation_flips_stale_only() {
|
||||
"a fresh pending_confirmation must NOT be cancelled"
|
||||
);
|
||||
|
||||
let stale_row = app.state.db.get_social_tip(stale).await.expect("get").expect("row");
|
||||
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");
|
||||
let fresh_row = app
|
||||
.state
|
||||
.db
|
||||
.get_social_tip(fresh)
|
||||
.await
|
||||
.expect("get")
|
||||
.expect("row");
|
||||
assert_eq!(fresh_row.status, "pending_confirmation");
|
||||
}
|
||||
|
||||
@@ -798,14 +1095,37 @@ async fn log_stuck_claiming_returns_backdated_and_mutates_nothing() {
|
||||
.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");
|
||||
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");
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Grin (voucher asset) tests ────────────────────────────────────────────────
|
||||
@@ -865,7 +1185,13 @@ async fn grin_public_tip_create_returns_draft_and_share_url() {
|
||||
// Migration applied: the grin_commitment column round-trips, tip_address
|
||||
// mirrors it, and the grin funding threshold is 10.
|
||||
let uuid = tip_id.parse::<uuid::Uuid>().unwrap();
|
||||
let row = app.state.db.get_social_tip(uuid).await.expect("get").expect("row");
|
||||
let row = app
|
||||
.state
|
||||
.db
|
||||
.get_social_tip(uuid)
|
||||
.await
|
||||
.expect("get")
|
||||
.expect("row");
|
||||
assert_eq!(row.asset, "grin");
|
||||
assert_eq!(row.grin_commitment.as_deref(), Some(commit.as_str()));
|
||||
assert_eq!(row.tip_address.as_deref(), Some(commit.as_str()));
|
||||
@@ -874,7 +1200,9 @@ async fn grin_public_tip_create_returns_draft_and_share_url() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn grin_create_falls_back_to_tip_address_for_commitment() {
|
||||
let Some(app) = grin_tips_app().await else { return };
|
||||
let Some(app) = grin_tips_app().await else {
|
||||
return;
|
||||
};
|
||||
let (_uid, token, _refresh) = app.mint_session().await;
|
||||
let commit = grin_commit();
|
||||
|
||||
@@ -890,22 +1218,38 @@ async fn grin_create_falls_back_to_tip_address_for_commitment() {
|
||||
let (status, resp) = app
|
||||
.request("POST", "/api/v1/tips/social", Some(&token), Some(body))
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK, "create grin (no commit field): {resp}");
|
||||
let uuid = resp["tip_id"].as_str().unwrap().parse::<uuid::Uuid>().unwrap();
|
||||
let row = app.state.db.get_social_tip(uuid).await.expect("get").expect("row");
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::OK,
|
||||
"create grin (no commit field): {resp}"
|
||||
);
|
||||
let uuid = resp["tip_id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.parse::<uuid::Uuid>()
|
||||
.unwrap();
|
||||
let row = app
|
||||
.state
|
||||
.db
|
||||
.get_social_tip(uuid)
|
||||
.await
|
||||
.expect("get")
|
||||
.expect("row");
|
||||
assert_eq!(row.grin_commitment.as_deref(), Some(commit.as_str()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grin_create_rejects_junk_commitment() {
|
||||
let Some(app) = grin_tips_app().await else { return };
|
||||
let Some(app) = grin_tips_app().await else {
|
||||
return;
|
||||
};
|
||||
let (_uid, token, _refresh) = app.mint_session().await;
|
||||
|
||||
// tip_address that isn't a 66-hex commitment -> clean 400.
|
||||
for bad in [
|
||||
"deadbeef".to_string(), // too short
|
||||
"z".repeat(66), // right length, not hex
|
||||
format!("09{}", "a1".repeat(33)), // 68 chars, too long
|
||||
"deadbeef".to_string(), // too short
|
||||
"z".repeat(66), // right length, not hex
|
||||
format!("09{}", "a1".repeat(33)), // 68 chars, too long
|
||||
] {
|
||||
let body = json!({
|
||||
"asset": "grin",
|
||||
@@ -917,7 +1261,11 @@ async fn grin_create_rejects_junk_commitment() {
|
||||
let (status, resp) = app
|
||||
.request("POST", "/api/v1/tips/social", Some(&token), Some(body))
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST, "junk grin address must 400: {resp}");
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::BAD_REQUEST,
|
||||
"junk grin address must 400: {resp}"
|
||||
);
|
||||
}
|
||||
|
||||
// A valid tip_address but a junk explicit grin_commitment is also rejected
|
||||
@@ -933,7 +1281,11 @@ async fn grin_create_rejects_junk_commitment() {
|
||||
let (status, resp) = app
|
||||
.request("POST", "/api/v1/tips/social", Some(&token), Some(body))
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST, "junk grin_commitment must 400: {resp}");
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::BAD_REQUEST,
|
||||
"junk grin_commitment must 400: {resp}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -945,7 +1297,8 @@ async fn grin_create_rejected_when_chain_disabled() {
|
||||
c.features.chains.grin = false;
|
||||
c.tip_share_base = Some("https://tips.example".into());
|
||||
})
|
||||
.await else {
|
||||
.await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let (_uid, token, _refresh) = app.mint_session().await;
|
||||
@@ -961,7 +1314,11 @@ async fn grin_create_rejected_when_chain_disabled() {
|
||||
let (status, resp) = app
|
||||
.request("POST", "/api/v1/tips/social", Some(&token), Some(body))
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST, "grin disabled must 400: {resp}");
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::BAD_REQUEST,
|
||||
"grin disabled must 400: {resp}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The grin voucher flows through the SAME draft -> attach-funding -> claiming
|
||||
@@ -971,7 +1328,9 @@ async fn grin_create_rejected_when_chain_disabled() {
|
||||
/// the grin worker arms depend on.
|
||||
#[tokio::test]
|
||||
async fn grin_tip_db_lifecycle_columns() {
|
||||
let Some(app) = grin_tips_app().await else { return };
|
||||
let Some(app) = grin_tips_app().await else {
|
||||
return;
|
||||
};
|
||||
let uid = app.create_user().await;
|
||||
let commit = grin_commit();
|
||||
|
||||
@@ -988,7 +1347,13 @@ async fn grin_tip_db_lifecycle_columns() {
|
||||
confirmations_required: 10,
|
||||
grin_commitment: Some(&commit),
|
||||
};
|
||||
let tip_id = app.state.db.create_draft_social_tip(new).await.expect("create draft").id;
|
||||
let tip_id = app
|
||||
.state
|
||||
.db
|
||||
.create_draft_social_tip(new)
|
||||
.await
|
||||
.expect("create draft")
|
||||
.id;
|
||||
|
||||
// attach-funding with the client's bookkeeping id (grin has no real txid).
|
||||
let r = app
|
||||
@@ -1001,7 +1366,11 @@ async fn grin_tip_db_lifecycle_columns() {
|
||||
assert_eq!(r.grin_commitment.as_deref(), Some(commit.as_str()));
|
||||
|
||||
// Simulate the funding worker: 10 confs reached + amount auto-verified.
|
||||
app.state.db.update_tip_confirmations(tip_id, 10).await.expect("confs");
|
||||
app.state
|
||||
.db
|
||||
.update_tip_confirmations(tip_id, 10)
|
||||
.await
|
||||
.expect("confs");
|
||||
let verified = app
|
||||
.state
|
||||
.db
|
||||
@@ -1014,7 +1383,12 @@ async fn grin_tip_db_lifecycle_columns() {
|
||||
|
||||
// Claim, then record the sweep's kernel excess as sweep_txid.
|
||||
let claimant = app.create_user().await;
|
||||
app.state.db.mark_tip_claiming(tip_id, claimant).await.expect("claim").expect("row");
|
||||
app.state
|
||||
.db
|
||||
.mark_tip_claiming(tip_id, claimant)
|
||||
.await
|
||||
.expect("claim")
|
||||
.expect("row");
|
||||
let excess = format!("08{}", "bc".repeat(32)); // kernel excess (66 hex)
|
||||
let swept = app
|
||||
.state
|
||||
|
||||
Reference in New Issue
Block a user