//! Tip-recipient-on-policy: fire a Lightning tip after every successful //! license issuance under a tip-enabled policy. //! //! Flow: //! 1. License is issued (existing path; this module is called from the //! reconcile/webhook layer once that completes). //! 2. Look up the policy. If `tip_recipient` is set and `tip_pct_bps > 0`, //! compute `amount_sats = paid_sats * tip_pct_bps / 10000`. //! 3. Resolve the Lightning Address. We support exactly the Lightning //! Address scheme `user@domain`, which maps to //! `https://domain/.well-known/lnurlp/user`. Plain LNURL-pay bech32 //! strings are not supported in v0.1; can add later. //! 4. Fetch the LNURL-pay metadata, verify the amount fits in //! `[minSendable, maxSendable]`, request a BOLT11 invoice for our //! amount via the `callback` URL. //! 5. Pay the BOLT11 via the operator's BTCPay Lightning node. //! 6. Record success/failure in the `tip_attempts` audit table. //! //! Failure semantics: this module **never** propagates errors back to the //! issuance path. A tip failing is a logged + audited concern, not a reason //! to fail a customer's purchase. Operators set up tipping voluntarily; //! they accept the trade-off that an occasional tip will fail and can be //! retried manually. use crate::api::AppState; use crate::db::repo; use crate::models::Policy; use anyhow::{anyhow, bail, Context, Result}; use serde::Deserialize; /// Maximum amount in millisats we'll send via a single tip. Defense in /// depth — a misconfigured `tip_pct_bps` shouldn't be able to drain the /// wallet on a single sale. const MAX_TIP_MSAT: u64 = 5_000_000_000; // 50,000,000 sats; 0.5 BTC #[derive(Debug, Deserialize)] struct LnurlPayMetadata { callback: String, #[serde(rename = "minSendable")] min_sendable: u64, #[serde(rename = "maxSendable")] max_sendable: u64, #[serde(default)] tag: String, } #[derive(Debug, Deserialize)] struct LnurlPayInvoice { pr: String, // BOLT11 } /// Spawn a tip in the background. Caller fires this after issuance and /// returns immediately — the customer's purchase response doesn't wait for /// the tip to complete. pub fn spawn_tip( state: AppState, license_id: String, policy: Policy, paid_sats: i64, ) { tokio::spawn(async move { if let Err(e) = run_tip(&state, &license_id, &policy, paid_sats).await { tracing::warn!( license = %license_id, policy = %policy.id, "tip flow ended with error: {e:#}" ); // run_tip records its own audit entries; this is just the catch-all log. } }); } async fn run_tip( state: &AppState, license_id: &str, policy: &Policy, paid_sats: i64, ) -> Result<()> { let recipient = match &policy.tip_recipient { Some(r) if !r.trim().is_empty() => r.trim().to_string(), _ => return Ok(()), // no tip configured; not an error }; let pct = policy.tip_pct_bps; if pct <= 0 { return Ok(()); } let label = policy.tip_label.clone(); // Compute tip amount. Round down (floor); we never tip more than the // configured percentage of what the buyer paid. let tip_sats = paid_sats.saturating_mul(pct) / 10_000; if tip_sats <= 0 { repo::record_tip_attempt( &state.db, license_id, &policy.id, &recipient, 0, pct, label.as_deref(), "skipped", Some("tip_sats <= 0 after percentage applied"), None, ) .await .ok(); return Ok(()); } let tip_msat = (tip_sats as u64).saturating_mul(1000); if tip_msat > MAX_TIP_MSAT { repo::record_tip_attempt( &state.db, license_id, &policy.id, &recipient, tip_sats, pct, label.as_deref(), "skipped", Some(&format!( "tip exceeds safety cap ({} msat > {} msat)", tip_msat, MAX_TIP_MSAT )), None, ) .await .ok(); return Ok(()); } // Resolve Lightning Address → LNURL-pay metadata. let metadata = match resolve_lightning_address(&recipient).await { Ok(m) => m, Err(e) => { let detail = format!("address resolution failed: {e:#}"); tracing::warn!(license = %license_id, recipient = %recipient, "{detail}"); repo::record_tip_attempt( &state.db, license_id, &policy.id, &recipient, tip_sats, pct, label.as_deref(), "failed", Some(&detail), None, ) .await .ok(); return Ok(()); } }; if tip_msat < metadata.min_sendable || tip_msat > metadata.max_sendable { let detail = format!( "tip amount {tip_msat} msat outside recipient bounds [{}, {}]", metadata.min_sendable, metadata.max_sendable ); repo::record_tip_attempt( &state.db, license_id, &policy.id, &recipient, tip_sats, pct, label.as_deref(), "failed", Some(&detail), None, ) .await .ok(); return Ok(()); } // Request a BOLT11 invoice from the recipient for our amount. let invoice = match request_lnurl_invoice(&metadata.callback, tip_msat).await { Ok(b) => b, Err(e) => { let detail = format!("invoice request failed: {e:#}"); tracing::warn!(license = %license_id, "{detail}"); repo::record_tip_attempt( &state.db, license_id, &policy.id, &recipient, tip_sats, pct, label.as_deref(), "failed", Some(&detail), None, ) .await .ok(); return Ok(()); } }; // Pay it via the provider's LN node — same provider that settled // this license's purchase invoice (so the tip draws from the right // Bitcoin balance). Provider-agnostic; BTCPay implements // `pay_lightning_invoice` today, future providers either implement // it (Zaprite via Strike?) or fall through to the trait default // which returns a "not supported" error that we record as a failed // tip. Falls back to the legacy active-provider accessor if the // license's invoice has no payment_provider_id set (pre-0021). let invoice_provider_id: Option = sqlx::query_scalar( "SELECT i.payment_provider_id FROM invoices i \ JOIN licenses l ON l.invoice_id = i.id \ WHERE l.id = ?", ) .bind(license_id) .fetch_optional(&state.db) .await .ok() .flatten(); let provider_result = match invoice_provider_id.as_deref() { Some(pid) => state.payment_provider_by_id(pid).await, None => state.payment_provider().await, }; let provider = match provider_result { Ok(p) => p, Err(e) => { let detail = format!("payment provider unavailable: {e:?}"); repo::record_tip_attempt( &state.db, license_id, &policy.id, &recipient, tip_sats, pct, label.as_deref(), "failed", Some(&detail), None, ) .await .ok(); return Ok(()); } }; match provider.pay_lightning_invoice(&invoice).await { Ok(receipt) => { tracing::info!( license = %license_id, recipient = %recipient, amount_sats = tip_sats, payment_hash = ?receipt.payment_hash, "tip sent" ); repo::record_tip_attempt( &state.db, license_id, &policy.id, &recipient, tip_sats, pct, label.as_deref(), "sent", Some(&format!( "paid via {} LN node ({} sats)", provider.kind().as_str(), tip_sats )), receipt.payment_hash.as_deref(), ) .await .ok(); } Err(e) => { let detail = format!("BTCPay pay-LN-invoice failed: {e:#}"); tracing::warn!(license = %license_id, "{detail}"); repo::record_tip_attempt( &state.db, license_id, &policy.id, &recipient, tip_sats, pct, label.as_deref(), "failed", Some(&detail), None, ) .await .ok(); } } Ok(()) } /// Parse `user@domain` and fetch the LNURL-pay metadata document at /// `https://domain/.well-known/lnurlp/user`. Returns the parsed metadata. async fn resolve_lightning_address(addr: &str) -> Result { let (user, domain) = addr .split_once('@') .ok_or_else(|| anyhow!("not a Lightning Address (expected user@domain)"))?; if user.is_empty() || domain.is_empty() { bail!("Lightning Address has empty user or domain"); } // Reasonable charset check — LN addresses are user-input-safe alphanum + dash + underscore + dot. let charset_ok = |c: char| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'); if !user.chars().all(charset_ok) || !domain.chars().all(charset_ok) { bail!("Lightning Address contains disallowed characters"); } let url = format!("https://{domain}/.well-known/lnurlp/{user}"); let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(10)) .build() .context("building HTTP client")?; let resp = client.get(&url).send().await.context("LNURL-pay GET")?; if !resp.status().is_success() { bail!("LNURL-pay endpoint returned {}", resp.status()); } let metadata: LnurlPayMetadata = resp .json() .await .context("parsing LNURL-pay metadata response")?; if !metadata.tag.is_empty() && metadata.tag != "payRequest" { bail!( "expected LNURL-pay metadata tag='payRequest', got '{}'", metadata.tag ); } if !metadata.callback.starts_with("https://") { bail!( "LNURL-pay callback must be HTTPS, got: {}", metadata.callback ); } Ok(metadata) } /// Hit the recipient's `callback` URL with `?amount=` and return the /// resulting BOLT11 invoice string. async fn request_lnurl_invoice(callback: &str, amount_msat: u64) -> Result { let sep = if callback.contains('?') { '&' } else { '?' }; let url = format!("{callback}{sep}amount={amount_msat}"); let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(10)) .build() .context("building HTTP client")?; let resp = client.get(&url).send().await.context("LNURL-pay invoice GET")?; if !resp.status().is_success() { bail!( "LNURL-pay invoice endpoint returned {}", resp.status() ); } // The response can be either { pr, ... } on success or // { status: "ERROR", reason: "..." } on failure. let body: serde_json::Value = resp .json() .await .context("parsing LNURL-pay invoice response")?; if let Some("ERROR") = body.get("status").and_then(|s| s.as_str()) { let reason = body .get("reason") .and_then(|s| s.as_str()) .unwrap_or("unknown"); bail!("LNURL-pay invoice error: {reason}"); } let parsed: LnurlPayInvoice = serde_json::from_value(body) .context("LNURL-pay response missing 'pr' field")?; Ok(parsed.pr) }