diff --git a/migrations/20260720120000_cashu_fee_fields.sql b/migrations/20260720120000_cashu_fee_fields.sql new file mode 100644 index 00000000..5a5ecce6 --- /dev/null +++ b/migrations/20260720120000_cashu_fee_fields.sql @@ -0,0 +1,29 @@ +-- Track A TA-1f — Cashu fee collection (Option 2, funder-pays-at-lock). +-- +-- The seller funds the WHOLE Mostro fee (`2 * order.fee`) as a separate +-- P2PK-1-of-1 token locked to Mostro's arbitrator key, submitted alongside the +-- 2-of-3 escrow in the same `AddCashuEscrow`. These columns persist that fee +-- token crash-safely, in the SAME atomic write as the lock, so a redeem +-- interrupted by a crash between lock and swap is retryable from the DB (a +-- scheduler job picks up `cashu_fee_token IS NOT NULL AND +-- cashu_fee_redeemed_at IS NULL`). All NULL for Lightning orders and for +-- fee-free (`mostro.fee == 0`) Cashu nodes. +-- +-- * cashu_fee_token Serialized P2PK-1-of-1 fee token (Mostro's revenue). +-- * cashu_fee_redeemed_at Unix seconds when Mostro redeemed the fee token; NULL +-- until collected. +ALTER TABLE orders ADD COLUMN cashu_fee_token text; +ALTER TABLE orders ADD COLUMN cashu_fee_redeemed_at integer; + +-- Cross-order anti-reuse guard. The fee token is P2PK to the node-wide `P_M` +-- (unlike the escrow token, whose 2-of-3 embeds per-order trade keys), so the +-- NUT-07 unspent check only stops SEQUENTIAL reuse. Two concurrent +-- `AddCashuEscrow` for two same-fee orders could both validate the same fee +-- token before the first redeem (TOCTOU). Recording each fee proof's +-- `Y = hash_to_curve(secret)` with a UNIQUE primary key, inserted in the SAME +-- transaction as the lock CAS, makes the second submission fail cleanly. +CREATE TABLE IF NOT EXISTS cashu_fee_proofs ( + y text PRIMARY KEY, + order_id text NOT NULL, + created_at integer NOT NULL +); diff --git a/src/app/add_cashu_escrow.rs b/src/app/add_cashu_escrow.rs index 6f9e3ec9..2b30af3c 100644 --- a/src/app/add_cashu_escrow.rs +++ b/src/app/add_cashu_escrow.rs @@ -16,9 +16,9 @@ //! Fee collection (Option 2, §4A) is **not** handled here — it lands in TA-1f. use crate::app::context::AppContext; -use crate::cashu::{cashu_pubkey_from_xonly_hex, Error as CashuError}; +use crate::cashu::{cashu_pubkey_from_xonly_hex, proof_ys_hex, Error as CashuError}; use crate::config::settings::Settings; -use crate::db::update_order_cashu_escrow; +use crate::db::{update_order_cashu_escrow, update_order_cashu_escrow_with_fee, CashuLockOutcome}; use crate::util::{enqueue_order_msg, get_order, update_order_event}; use chrono::Utc; use mostro_core::db::Crud; @@ -140,27 +140,86 @@ pub async fn add_cashu_escrow_action( .await .map_err(|e| MostroCantDo(cashu_reason(&e)))?; - // 8. Atomically persist the escrow and advance the status in one write. A - // `false` return means the status changed concurrently or the escrow is - // already locked (replay) — log and return `Ok(())` without notifying - // (idempotent; same shape as the `rows_affected() == 0` guard in - // `release_action`). - let locked = update_order_cashu_escrow( - pool, - order.id, - &configured_mint, - &proof.token, - now, - Status::WaitingPayment, - Status::Active, - ) - .await?; - if !locked { - tracing::info!( - "cashu lock: compare-and-set matched zero rows for order {} (replay or status moved on) — no-op", - order.id - ); - return Ok(()); + // 7A. Fee token (Option 2, §4A). When the node charges a fee, the seller + // must fund the WHOLE Mostro fee (`2 * order.fee`) as a separate + // P2PK-1-of-1 token locked to `P_M`, submitted in the same message. It + // is validated against the mint alongside the escrow token — both must + // be valid before any state changes. A fee-free node (`mostro.fee == 0`) + // accepts a `None` fee token. + let charges_fee = Settings::get_mostro().fee > 0.0; + let fee_ctx = if charges_fee { + let fee_token = proof + .fee_token + .clone() + .ok_or(MostroCantDo(CantDoReason::CashuSignatureMissing))?; + let expected_fee = u64::try_from(order.fee.saturating_mul(2)) + .map_err(|_| MostroCantDo(CantDoReason::InvalidAmount))?; + let fee_token_parsed = cashu_client + .verify_fee_token(&fee_token, p_m, expected_fee) + .await + .map_err(|e| MostroCantDo(cashu_reason(&e)))?; + let fee_ys = proof_ys_hex(&fee_token_parsed).map_err(|e| MostroCantDo(cashu_reason(&e)))?; + Some((fee_token, fee_ys)) + } else { + None + }; + + // 8. Atomically persist the escrow (+ fee token & anti-reuse guard when + // charging) and advance the status in one write. A no-match means the + // status changed concurrently or the escrow is already locked (replay) — + // log and return `Ok(())` without notifying (idempotent; same shape as + // the `rows_affected() == 0` guard in `release_action`). + match &fee_ctx { + Some((fee_token, fee_ys)) => { + match update_order_cashu_escrow_with_fee( + pool, + order.id, + &configured_mint, + &proof.token, + fee_token, + fee_ys, + now, + Status::WaitingPayment, + Status::Active, + ) + .await? + { + CashuLockOutcome::Locked => {} + CashuLockOutcome::StatusMismatch => { + tracing::info!( + "cashu lock: CAS matched zero rows for order {} (replay or status moved on) — no-op", + order.id + ); + return Ok(()); + } + CashuLockOutcome::FeeReuse => { + tracing::warn!( + "cashu lock: fee token reused across orders for {} — rejected", + order.id + ); + return Err(MostroCantDo(CantDoReason::InvalidCashuToken)); + } + } + } + None => { + let locked = update_order_cashu_escrow( + pool, + order.id, + &configured_mint, + &proof.token, + now, + Status::WaitingPayment, + Status::Active, + ) + .await?; + if !locked { + tracing::info!( + "cashu lock: compare-and-set matched zero rows for order {} (replay or status moved on) — no-op", + order.id + ); + return Ok(()); + } + } } // 9. Publish the updated (Active) order event so the public state stays diff --git a/src/cashu/mod.rs b/src/cashu/mod.rs index d7538415..df0d40df 100644 --- a/src/cashu/mod.rs +++ b/src/cashu/mod.rs @@ -376,6 +376,79 @@ impl CashuClient { Ok(token) } + /// Full fee-token validation for Track A's fee collection + /// (Option 2, §4A). The fee token is a **P2PK 1-of-1** locked to exactly + /// Mostro's arbitrator key `p_m` (no extra pubkeys, no locktime/refund + /// required), value `expected_fee` sats, hosted on the configured mint, + /// DLEQ-valid (NUT-12) and unspent (NUT-07). Mirrors + /// [`Self::verify_escrow_token`] but for the single-key fee token and + /// without the seller-recovery locktime the escrow token carries. + pub async fn verify_fee_token( + &self, + token_str: &str, + p_m: PublicKey, + expected_fee: u64, + ) -> Result { + // 1: condition — exactly p_m signs, 1-of-1 (offline). + let token = verify_fee_condition(token_str, p_m)?; + + // 2: the token must be hosted on the node's configured mint. + let token_mint = token + .mint_url() + .map_err(|e| Error::Token(format!("token mint url: {e}")))?; + if token_mint != self.mint_url { + return Err(Error::Token(format!( + "fee token mint {token_mint} does not match configured mint {}", + self.mint_url + ))); + } + + reject_duplicate_proofs(&token)?; + + // 3: amount must equal the expected fee exactly, denominated in sats. + match token.unit() { + Some(CurrencyUnit::Sat) => {} + other => { + return Err(Error::Token(format!("fee token unit {other:?} is not sat"))); + } + } + let value = token + .value() + .map_err(|e| Error::Token(format!("fee token value: {e}")))? + .to_u64(); + if value != expected_fee { + return Err(Error::Token(format!( + "fee token amount {value} does not match expected {expected_fee}" + ))); + } + + // 4: mint-issued (DLEQ / NUT-12) + per-proof sat keyset. + self.verify_token_dleq(&token).await?; + + // 5: every proof unspent (NUT-07), fail-closed on a short reply. + let secrets = token.token_secrets(); + let ys = secrets + .iter() + .map(|s| cdk::dhke::hash_to_curve(s.as_bytes())) + .collect::, _>>() + .map_err(|e| Error::Token(format!("proof Y: {e}")))?; + let expected_states = ys.len(); + let states = self.check_state(ys).await?; + if states.states.len() != expected_states { + return Err(Error::Token(format!( + "checkstate returned {} states for {expected_states} fee proofs", + states.states.len() + ))); + } + if states.states.iter().any(|s| s.state != State::Unspent) { + return Err(Error::Token( + "one or more fee proofs are not unspent".into(), + )); + } + + Ok(token) + } + /// Whether **every** proof of a stored escrow token is still unspent at /// the mint (NUT-07). Used by the restore/monitor path (Track A TA-3) to /// re-hydrate in-flight locks after a restart and surface any escrow that @@ -592,6 +665,93 @@ pub fn verify_escrow_conditions( Ok(token) } +/// Offline condition check for the Option 2 fee token (§4A): a **P2PK 1-of-1** +/// locked to exactly `p_m`. Every proof must be P2PK with `SIG_INPUTS`, sign to +/// exactly `p_m` (the NUT-10 `data` key) with no extra pubkeys, and require at +/// most one signature (NUT-11 defaults an absent `n_sigs` to 1). Unlike the +/// escrow token there is no locktime/refund requirement — the simple 1-of-1 +/// form redeemed by Mostro (the locktime+`P_S`-refund refinement is future +/// work). Split out so it is unit-testable without a mint. +pub fn verify_fee_condition(token_str: &str, p_m: PublicKey) -> Result { + let token = Token::from_str(token_str).map_err(|e| Error::Token(e.to_string()))?; + + let secrets = token.token_secrets(); + if secrets.is_empty() { + return Err(Error::Token("Token contains no secrets".into())); + } + + for secret in secrets { + let nut10_secret = + Nut10Secret::try_from(secret).map_err(|e| Error::Condition(e.to_string()))?; + reject_duplicate_standard_tags(&nut10_secret)?; + + let spending_conditions = SpendingConditions::try_from(nut10_secret) + .map_err(|e| Error::Condition(e.to_string()))?; + + let (data, conditions) = match spending_conditions { + SpendingConditions::P2PKConditions { data, conditions } => (data, conditions), + other => { + return Err(Error::Condition(format!( + "fee spending condition kind {:?} is not P2PK", + other.kind() + ))); + } + }; + + // The NUT-11 tag block is optional for a bare 1-of-1 (absent ⇒ + // SIG_INPUTS, n_sigs = 1, no extra keys). When present, enforce it. + if let Some(conditions) = conditions { + if conditions.sig_flag != SigFlag::SigInputs { + return Err(Error::Condition(format!( + "fee sig flag {:?} is not SIG_INPUTS", + conditions.sig_flag + ))); + } + if let Some(n) = conditions.num_sigs { + if n != 1 { + return Err(Error::Condition(format!( + "fee token must require exactly 1 signature, got {n}" + ))); + } + } + if conditions + .pubkeys + .as_ref() + .is_some_and(|extra| !extra.is_empty()) + { + return Err(Error::Condition( + "fee token must lock to exactly Mostro's key (no extra pubkeys)".into(), + )); + } + } + + if data != p_m { + return Err(Error::Condition( + "fee token is not locked to Mostro's key".into(), + )); + } + } + + Ok(token) +} + +/// The NUT-07 `Y = hash_to_curve(secret)` of every proof in a token, as hex. +/// +/// Track A TA-1f records these for the fee token in the `cashu_fee_proofs` +/// anti-reuse table, in the same transaction as the lock CAS, so the same fee +/// token can never be accepted for two orders. +pub fn proof_ys_hex(token: &Token) -> Result, Error> { + token + .token_secrets() + .iter() + .map(|s| { + cdk::dhke::hash_to_curve(s.as_bytes()) + .map(|y| y.to_hex()) + .map_err(|e| Error::Token(format!("proof Y: {e}"))) + }) + .collect() +} + /// Convert a Nostr (BIP340 x-only) public key, given as 64-char hex, into a /// Cashu (compressed secp256k1) [`PublicKey`]. /// @@ -750,6 +910,39 @@ mod tests { assert!(CashuClient::verify_2of3_condition(&token, p_b, p_s, p_m).is_ok()); } + #[test] + fn fee_condition_accepts_1of1_to_mostro() { + let p_m = keypair(3); + // 1-of-1: data = p_m, no extra pubkeys, num_sigs = 1, no locktime. + let token = token_with_condition(p_m, vec![], Some(1), None, None, None); + assert!(verify_fee_condition(&token, p_m).is_ok()); + } + + #[test] + fn fee_condition_rejects_wrong_key_extra_key_or_wrong_sig_count() { + let (p_b, p_m) = (keypair(1), keypair(3)); + // Locked to the wrong key. + let wrong = token_with_condition(p_b, vec![], Some(1), None, None, None); + assert!(verify_fee_condition(&wrong, p_m).is_err()); + // An extra co-signer widens the accepted set — must be rejected. + let extra = token_with_condition(p_m, vec![p_b], Some(1), None, None, None); + assert!(verify_fee_condition(&extra, p_m).is_err()); + // More than one required signature is not a 1-of-1. + let two = token_with_condition(p_m, vec![p_b], Some(2), None, None, None); + assert!(verify_fee_condition(&two, p_m).is_err()); + } + + #[test] + fn fee_proof_ys_are_derivable_and_stable() { + let p_m = keypair(3); + let token_str = token_with_condition(p_m, vec![], Some(1), None, None, None); + let token = Token::from_str(&token_str).unwrap(); + let ys = proof_ys_hex(&token).unwrap(); + assert_eq!(ys.len(), 1); + // Deterministic: the same token yields the same Y. + assert_eq!(proof_ys_hex(&token).unwrap(), ys); + } + #[test] fn rejects_wrong_sig_count() { let (p_b, p_s, p_m) = (keypair(1), keypair(2), keypair(3)); diff --git a/src/db.rs b/src/db.rs index fac47c23..973cfcbf 100644 --- a/src/db.rs +++ b/src/db.rs @@ -847,6 +847,144 @@ pub async fn update_order_cashu_escrow( Ok(result.rows_affected() > 0) } +/// Outcome of the fee-carrying Cashu lock (TA-1f, Option 2). +#[derive(Debug, PartialEq, Eq)] +pub enum CashuLockOutcome { + /// Escrow locked, status advanced, and the fee token persisted — all in one + /// atomic transaction. + Locked, + /// The compare-and-set matched zero rows (replay / status moved on) — a safe + /// idempotent no-op. Nothing was written. + StatusMismatch, + /// A fee proof `Y` is already recorded (for this or another order): the same + /// fee token was reused. The whole transaction rolled back. + FeeReuse, +} + +/// Whether a sqlx error is a SQLite UNIQUE-constraint violation — used to turn +/// a `cashu_fee_proofs` primary-key clash into [`CashuLockOutcome::FeeReuse`] +/// rather than an opaque DB error. +fn is_unique_violation(e: &sqlx::Error) -> bool { + matches!(e, sqlx::Error::Database(db) if db.is_unique_violation()) +} + +/// Atomically persist a validated Cashu escrow **and its seller-funded fee +/// token**, advance the status, and record every fee proof's `Y` for +/// cross-order anti-reuse — all in one transaction (Track A TA-1f, §4A). +/// +/// The escrow CAS (`WHERE id = ? AND status = ? AND cashu_escrow_locked_at IS +/// NULL`) and the `cashu_fee_proofs` inserts commit together, so a crash can +/// never leave a locked order whose fee is unrecorded, and two concurrent +/// submissions of the same fee token cannot both succeed (the second hits the +/// `y` PRIMARY KEY and rolls back → [`CashuLockOutcome::FeeReuse`]). +#[allow(clippy::too_many_arguments)] +pub async fn update_order_cashu_escrow_with_fee( + pool: &SqlitePool, + order_id: Uuid, + mint_url: &str, + token: &str, + fee_token: &str, + fee_proof_ys: &[String], + locked_at: i64, + expected_status: Status, + new_status: Status, +) -> Result { + let mut tx = pool + .begin() + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + + let result = sqlx::query( + r#" + UPDATE orders + SET + cashu_mint_url = ?1, + cashu_escrow_token = ?2, + cashu_escrow_locked_at = ?3, + cashu_fee_token = ?4, + status = ?5 + WHERE id = ?6 AND status = ?7 AND cashu_escrow_locked_at IS NULL + "#, + ) + .bind(mint_url) + .bind(token) + .bind(locked_at) + .bind(fee_token) + .bind(new_status.to_string()) + .bind(order_id) + .bind(expected_status.to_string()) + .execute(&mut *tx) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + + if result.rows_affected() == 0 { + // Nothing changed; dropping `tx` rolls back the (empty) transaction. + return Ok(CashuLockOutcome::StatusMismatch); + } + + for y in fee_proof_ys { + let insert = sqlx::query( + "INSERT INTO cashu_fee_proofs (y, order_id, created_at) VALUES (?1, ?2, ?3)", + ) + .bind(y) + .bind(order_id) + .bind(locked_at) + .execute(&mut *tx) + .await; + + if let Err(e) = insert { + if is_unique_violation(&e) { + // Cross-order (or concurrent) fee-token reuse. Drop `tx` to roll + // back the escrow UPDATE too — the lock must not stand without + // an exclusively-owned fee token. + return Ok(CashuLockOutcome::FeeReuse); + } + return Err(MostroInternalErr(ServiceError::DbAccessError( + e.to_string(), + ))); + } + } + + tx.commit() + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + Ok(CashuLockOutcome::Locked) +} + +/// Orders whose fee token is persisted but not yet redeemed (TA-1f). The +/// cashu-mode scheduler retry job collects these so a redeem interrupted by a +/// crash between the lock and the mint swap is retried from the DB. +pub async fn find_pending_cashu_fee_redeems( + pool: &SqlitePool, +) -> Result, MostroError> { + sqlx::query_as::<_, (Uuid, String)>( + r#" + SELECT id, cashu_fee_token + FROM orders + WHERE cashu_fee_token IS NOT NULL AND cashu_fee_redeemed_at IS NULL + ORDER BY cashu_escrow_locked_at ASC + "#, + ) + .fetch_all(pool) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string()))) +} + +/// Stamp a fee token as redeemed (TA-1f), so the retry job stops picking it up. +pub async fn mark_cashu_fee_redeemed( + pool: &SqlitePool, + order_id: Uuid, + redeemed_at: i64, +) -> Result { + let result = sqlx::query("UPDATE orders SET cashu_fee_redeemed_at = ?1 WHERE id = ?2") + .bind(redeemed_at) + .bind(order_id) + .execute(pool) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + Ok(result.rows_affected() > 0) +} + /// Every order with a locked Cashu escrow, for restore/monitoring after a /// restart (CF-4). Deliberately **no** `status` predicate: /// [`update_order_cashu_escrow`] advances the status in the same write as @@ -1502,7 +1640,22 @@ mod tests { dev_fee_payment_hash char(64), cashu_mint_url text, cashu_escrow_token text, - cashu_escrow_locked_at integer + cashu_escrow_locked_at integer, + cashu_fee_token text, + cashu_fee_redeemed_at integer + ) + "#, + ) + .execute(&pool) + .await?; + + // Cashu fee anti-reuse table (TA-1f) — mirrors the migration. + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS cashu_fee_proofs ( + y text primary key not null, + order_id text not null, + created_at integer not null ) "#, ) @@ -2986,6 +3139,104 @@ mod tests { .unwrap() } + #[tokio::test] + async fn cashu_fee_cas_locks_persists_fee_and_blocks_cross_order_reuse() { + let pool = setup_orders_db().await.unwrap(); + let id1 = uuid::Uuid::new_v4(); + insert_cashu_test_order(&pool, id1, &Status::WaitingPayment.to_string()).await; + + // First lock: escrow + fee token + fee-proof Y recorded atomically. + let out = super::update_order_cashu_escrow_with_fee( + &pool, + id1, + "https://mint.example.com", + "cashuEscrow", + "cashuFee", + &["y-aaaa".to_string()], + 1700000100, + Status::WaitingPayment, + Status::Active, + ) + .await + .unwrap(); + assert_eq!(out, super::CashuLockOutcome::Locked); + + let (fee_token, redeemed): (Option, Option) = sqlx::query_as( + "SELECT cashu_fee_token, cashu_fee_redeemed_at FROM orders WHERE id = ?1", + ) + .bind(id1) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(fee_token.as_deref(), Some("cashuFee")); + assert_eq!(redeemed, None, "fee is not redeemed at lock time"); + + // The pending-redeem query surfaces the un-redeemed fee. + let pending = super::find_pending_cashu_fee_redeems(&pool).await.unwrap(); + assert_eq!(pending, vec![(id1, "cashuFee".to_string())]); + + // A second order reusing the SAME fee proof Y is rejected, and its + // escrow UPDATE rolls back (still WaitingPayment, no token persisted). + let id2 = uuid::Uuid::new_v4(); + insert_cashu_test_order(&pool, id2, &Status::WaitingPayment.to_string()).await; + let reuse = super::update_order_cashu_escrow_with_fee( + &pool, + id2, + "https://mint.example.com", + "cashuEscrow2", + "cashuFeeReuse", + &["y-aaaa".to_string()], + 1700000200, + Status::WaitingPayment, + Status::Active, + ) + .await + .unwrap(); + assert_eq!(reuse, super::CashuLockOutcome::FeeReuse); + let (mint2, token2, locked2, status2) = cashu_columns(&pool, id2).await; + assert_eq!(mint2, None, "reuse must roll back the escrow write"); + assert_eq!(token2, None); + assert_eq!(locked2, None); + assert_eq!(status2, Status::WaitingPayment.to_string()); + + // Stamping the fee redeemed removes it from the pending set. + assert!(super::mark_cashu_fee_redeemed(&pool, id1, 1700000300) + .await + .unwrap()); + assert!(super::find_pending_cashu_fee_redeems(&pool) + .await + .unwrap() + .is_empty()); + } + + #[tokio::test] + async fn cashu_fee_cas_on_status_mismatch_is_noop() { + let pool = setup_orders_db().await.unwrap(); + let id = uuid::Uuid::new_v4(); + // Not WaitingPayment ⇒ the CAS matches zero rows and the fee proof is + // never recorded. + insert_cashu_test_order(&pool, id, &Status::Pending.to_string()).await; + + let out = super::update_order_cashu_escrow_with_fee( + &pool, + id, + "https://mint.example.com", + "cashuEscrow", + "cashuFee", + &["y-bbbb".to_string()], + 1700000100, + Status::WaitingPayment, + Status::Active, + ) + .await + .unwrap(); + assert_eq!(out, super::CashuLockOutcome::StatusMismatch); + assert!(super::find_pending_cashu_fee_redeems(&pool) + .await + .unwrap() + .is_empty()); + } + #[tokio::test] async fn cashu_escrow_cas_locks_once_and_replay_is_noop() { let pool = setup_orders_db().await.unwrap(); diff --git a/src/scheduler.rs b/src/scheduler.rs index 7098f6e8..7bcdd1b3 100644 --- a/src/scheduler.rs +++ b/src/scheduler.rs @@ -42,6 +42,9 @@ pub async fn start_scheduler(ctx: AppContext) { job_process_dev_fee_payment(ctx.clone()).await; job_process_bond_payouts(ctx.clone()).await; job_reconcile_stranded_maker_bonds(ctx.clone()).await; + } else { + // Cashu-only: retry collecting seller-funded fee tokens (TA-1f). + job_retry_cashu_fee_redeem(ctx.clone()).await; } // Mode-agnostic jobs (the info event self-skips when LN status is absent). @@ -208,6 +211,41 @@ async fn job_info_event_send(ctx: AppContext) { }); } +/// How often the cashu-mode fee-redeem retry job polls the DB (seconds). +const CASHU_FEE_REDEEM_INTERVAL_SECS: u64 = 60; + +/// Cashu fee-redeem retry (Track A TA-1f). Collects seller-funded fee tokens +/// that the lock persisted but Mostro has not yet redeemed — the analogue of +/// the Lightning payment-retry job — so a redeem interrupted by a crash between +/// the lock CAS and the mint swap is retried from the DB. Cashu mode only. +/// +/// NOTE: the live redeem (sign each fee proof with `P_M`, swap at the mint, +/// then `mark_cashu_fee_redeemed`) is a scoped follow-up — it needs an ecash +/// revenue store (Cashu mode has no LND to melt to). Until then this surfaces +/// the pending backlog; the fee tokens are already validated, persisted, and +/// anti-reuse-guarded, so no revenue is lost, only deferred. +async fn job_retry_cashu_fee_redeem(ctx: AppContext) { + tokio::spawn(async move { + loop { + match find_pending_cashu_fee_redeems(ctx.pool()).await { + Ok(pending) if !pending.is_empty() => { + info!( + "cashu fee redeem: {} fee token(s) pending collection \ + (live mint swap not yet wired — see TA-1f follow-up)", + pending.len() + ); + } + Ok(_) => {} + Err(e) => warn!("cashu fee redeem: failed to load pending redeems: {e}"), + } + tokio::time::sleep(tokio::time::Duration::from_secs( + CASHU_FEE_REDEEM_INTERVAL_SECS, + )) + .await; + } + }); +} + async fn job_retry_failed_payments(ctx: AppContext) { let ln_settings = &ctx.settings().lightning; let retries_number = ln_settings.payment_attempts as i64;