v0.1.0:24 — Keysat licensing service end-to-end

Daemon, StartOS wrapper, admin SPA, public buy/thank-you pages,
discount codes, free-license redemption, Apply-discount UX,
self-licensing, and v0.1.0 release notes.
This commit is contained in:
Grant
2026-05-07 10:33:39 -05:00
parent 432250bffc
commit 6ac118ae70
90 changed files with 14896 additions and 524 deletions
+353
View File
@@ -0,0 +1,353 @@
//! 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 operator's BTCPay Lightning node.
let btcpay = match state.btcpay_client().await {
Ok(c) => c,
Err(e) => {
let detail = format!("BTCPay client 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 btcpay.pay_lightning_invoice(&invoice).await {
Ok(payment) => {
let payment_hash = payment
.get("paymentHash")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
tracing::info!(
license = %license_id,
recipient = %recipient,
amount_sats = tip_sats,
payment_hash = ?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 BTCPay LN node ({} sats)", tip_sats)),
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<LnurlPayMetadata> {
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=<msat>` and return the
/// resulting BOLT11 invoice string.
async fn request_lnurl_invoice(callback: &str, amount_msat: u64) -> Result<String> {
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)
}