diff --git a/crates/mobee-core/src/authorize_pay.rs b/crates/mobee-core/src/authorize_pay.rs index 8c18b906..31d0f5e6 100644 --- a/crates/mobee-core/src/authorize_pay.rs +++ b/crates/mobee-core/src/authorize_pay.rs @@ -7,7 +7,7 @@ use std::fmt; use std::str::FromStr; -use cashu::{Amount, CurrencyUnit, MintUrl, PublicKey as CashuPublicKey}; +use cashu::{Amount, CurrencyUnit, PublicKey as CashuPublicKey}; use nostr_sdk::secp256k1::{Message, Secp256k1}; use nostr_sdk::Keys; use nostr_sdk::PublicKey as NostrPublicKey; @@ -15,6 +15,7 @@ use nostr_sdk::Timestamp; use crate::budget::{BudgetGate, BudgetRefuse}; use crate::buyer_fund::{self, FundError}; +use crate::crossmint_hop::{self, CdkHopEffects, FsHopJournal, HopError}; use crate::delivery::{CommitOid, DeliveryError, GitDelivery}; use crate::delivery_git::PayPathDeliveryVerifier; use crate::gateway; @@ -108,7 +109,12 @@ pub struct ContributionPayBinds { pub struct AuthorizePayOutcome { pub state: PaymentState, pub attempt_id: String, + /// What the seller received — the buyer-signed offer amount. pub amount_sats: u64, + /// What the budget cap was charged. Equals `amount_sats` on the direct path; a cross-mint hop + /// costs the buyer the Lightning fee reserve and the source mint's input fee on top, so the two + /// differ and the gap is the hop's cost rather than anything the seller was paid. + pub charged_sats: u64, pub spent_total_sats: u64, pub remaining_sats: u64, } @@ -119,6 +125,9 @@ pub enum AuthorizePayError { Budget(BudgetRefuse), Fund(FundError), Delivery(DeliveryError), + /// A cross-mint hop refused. Every variant is fail-closed; the pays-once cases name the quote + /// ids so an operator can ask the mints directly. + Hop(HopError), Payment(PaymentError), Wallet(PaymentWalletError), Home(String), @@ -135,6 +144,7 @@ impl fmt::Display for AuthorizePayError { Self::Budget(refuse) => write!(formatter, "{refuse}"), Self::Fund(error) => write!(formatter, "{error}"), Self::Delivery(error) => write!(formatter, "authorize_pay delivery: {error}"), + Self::Hop(error) => write!(formatter, "authorize_pay: {error}"), Self::Payment(error) => write!(formatter, "authorize_pay payment: {error}"), Self::Wallet(error) => write!(formatter, "authorize_pay wallet: {error}"), Self::CosigRefused(message) => write!(formatter, "authorize_pay payment: {message}"), @@ -176,6 +186,12 @@ impl From for AuthorizePayError { } } +impl From for AuthorizePayError { + fn from(value: HopError) -> Self { + Self::Hop(value) + } +} + /// Authorize spend under [`BudgetGate`], then pay only through [`PaymentService::run`] with a /// [`PayPathDeliveryVerifier`]. Async — every caller is already on a Tokio runtime (MCP dispatch). /// @@ -245,13 +261,18 @@ pub async fn authorize_pay_async( .realized_mint .as_deref() .unwrap_or_else(|| home.config.default_mint()); - let mint = resolve_realized_mint( + // A buyer funded only at mints the seller does not accept still has a way through: melt at a + // funded mint to pay a mint quote raised at one the seller does accept, and pay from there. The + // plan decides which of those two shapes this payment is; the realized mint is where the ecash + // the seller receives ends up, so for a hop it is the TARGET, and the terms, the attempt id and + // the receipt all bind that one mint exactly as they do on the direct path. + let plan = crate::crossmint::plan_payment( buyer_selected_mint, &request.accepted_mints, home.config.allow_real_mints, )?; let terms = PaymentTerms::new( - mint, + plan.realized_mint().clone(), Amount::from(request.amount_sats), CurrencyUnit::Sat, seller_nostr, @@ -378,6 +399,32 @@ pub async fn authorize_pay_async( crate::payment::verify_pay_path_delivery(&mut verifier, &delivery, &key) .map_err(AuthorizePayError::Payment)?; + let amount = request.amount_sats; + // Cross-mint hop planning. Pre-budget and pre-spend: raising quotes moves no money, so a hop + // that cannot be priced refuses having spent nothing. Delivery is PINNED here — the quote at the + // seller's mint is raised for exactly `amount`, the buyer-signed offer amount — and the buyer's + // COST is what floats, which is why no fee reading can reduce what the seller receives. + let mut hop = match plan.hop_source() { + None => None, + Some(source) => { + let store = FsHopJournal::new(crossmint_hop::hop_journal_dir(home)); + let effects = CdkHopEffects::open( + home, + &source.to_string(), + &wallet_open_mint_url(home, &terms), + ) + .await?; + // A pairing already on disk WINS over freshly raised quotes. This attempt may have + // melted on an earlier run, and a second melt quote for one attempt id is precisely the + // double-pay the hop journal exists to prevent. + let pairing = match crossmint_hop::journalled_pairing(&store, attempt_id.as_str())? { + Some(existing) => existing, + None => effects.plan_quotes(attempt_id.as_str(), amount).await?, + }; + Some((store, effects, pairing)) + } + }; + let wallet = buyer_fund::open_wallet_at_mint_async(home, &wallet_open_mint_url(home, &terms)) .await?; // Dust guard (live keyset N=1 floor, fail-closed). lock_or_reconcile re-checks @@ -409,17 +456,33 @@ pub async fn authorize_pay_async( .map_err(|error| AuthorizePayError::Home(format!("payment journal dir: {error}")))?; let journal = FsPaymentJournal::new(journal_dir.join(format!("{}.jsonl", attempt_id.as_str()))); - let amount = request.amount_sats; + // What the cap is asked to cover. On the direct path that is the amount, exactly as before — + // general fee-dust behaviour is a separate question and stays untouched here. A hop costs the + // buyer more than it delivers, and every sat of that difference must pass the cap BEFORE the + // melt, so the hop is charged its planned cost: melt amount + the source mint's Lightning fee + // reserve + its input fee. + // + // interim: the fee reserve is charged in full rather than reconciled against the fee actually + // paid, so a hop can leave the cap reporting less remaining budget than the buyer really spent. + // That is the safe direction; reconciling reserve-versus-actual would reshape the spend ledger, + // which is money-gate machinery and its own slice — see MakePrisms/mobee#186. + let charged = cap_charge(hop.as_ref().map(|(_, _, pairing)| pairing), amount); // Delivery already verified + bind-checked above (pre-budget). The budget append happens here, - // before the wallet send inside `run_verified`. - let state = gate.authorize_then_attempt(attempt_id.as_str(), amount, || { - PaymentService::new(&journal).run_verified(&key, &terms, &authority, &mut effects) + // before any melt and before the wallet send inside `run_verified`. + let state = gate.authorize_then_attempt(attempt_id.as_str(), charged, || { + if let Some((store, hop_effects, pairing)) = hop.as_mut() { + crossmint_hop::run_hop(store, hop_effects, pairing)?; + } + let state = + PaymentService::new(&journal).run_verified(&key, &terms, &authority, &mut effects)?; + Ok::<_, AuthorizePayError>(state) })??; Ok(AuthorizePayOutcome { state, attempt_id: attempt_id.as_str().to_owned(), amount_sats: amount, + charged_sats: charged, spent_total_sats: gate.spent(), remaining_sats: gate.remaining(), }) @@ -438,27 +501,15 @@ fn contribution_policy(home: &MobeeHome) -> crate::contribution::ContentPolicy { } } -/// Buyer mint selection for the pay path, config-driven. -/// -/// `buyer_mint_url` is the mint the buyer's wallet spends from — the home config's default mint -/// ([`crate::home::MobeeConfig::default_mint`]), the SAME source `buyer_fund` opens the wallet at. -/// The buyer pays from a mint it holds balance at that the seller listed in its `creq` `m` array; -/// since the buyer wallet is single-mint, that reduces to: is the buyer's configured mint listed? -/// -/// - **empty creq list (no creq):** pay from the buyer's configured mint. -/// - **configured mint listed:** pay directly from it (the direct path). -/// - **configured mint NOT listed:** the single-mint buyer wallet holds no balance at any mint the -/// seller listed, so it cannot pay this claim and refuses `mint_unreachable_pay`; no funds move, -/// no binding is committed. /// The mint URL the pay path opens the buyer wallet at: the FROZEN realized mint sealed into the /// payment terms (`terms.mint`), NEVER `home.config.default_mint()`. The realized mint is already -/// resolved from the sealed accept-bind ([`resolve_realized_mint`]) and bound into `terms`; opening -/// the wallet at the live config default instead would, after accept seals mint A and the buyer -/// flips its config default to B, bind the wallet to B while the attempt id + send target A — the -/// budget is appended, then the send refuses on mint mismatch and strands the reservation. Taking -/// the mint from the sealed terms keeps the wallet, the attempt id, and the send all on one mint. -/// `home` is passed so the already-fenced invariant is asserted at this seam (the realized mint was -/// fenced by `resolve_realized_mint`; `open_wallet_at_mint_async` re-checks, redundant-safe). +/// planned from the sealed accept-bind ([`crate::crossmint::plan_payment`]) and bound into `terms`; +/// opening the wallet at the live config default instead would, after accept seals mint A and the +/// buyer flips its config default to B, bind the wallet to B while the attempt id + send target A — +/// the budget is appended, then the send refuses on mint mismatch and strands the reservation. +/// Taking the mint from the sealed terms keeps the wallet, the attempt id, and the send all on one +/// mint. `home` is passed so the already-fenced invariant is asserted at this seam (the realized +/// mint was fenced while planning; `open_wallet_at_mint_async` re-checks, redundant-safe). pub(crate) fn wallet_open_mint_url(home: &MobeeHome, terms: &PaymentTerms) -> String { let mint_url = terms.mint.to_string(); debug_assert!( @@ -468,39 +519,18 @@ pub(crate) fn wallet_open_mint_url(home: &MobeeHome, terms: &PaymentTerms) -> St mint_url } -pub(crate) fn resolve_realized_mint( - buyer_mint_url: &str, - accepted_mints: &[String], - allow_real_mints: bool, -) -> Result { - // Real-mint fence: the buyer's own paying mint must be admissible under the flag. - // Default (`allow_real_mints=false`) admits only the testnut/dev allow-list; a real mint is - // refused fail-closed before any spend unless the operator opts in. - if !crate::home::mint_allowed(buyer_mint_url, allow_real_mints) { - return Err(AuthorizePayError::Input(format!( - "real-mint fence: buyer mint {buyer_mint_url} is not an allow-listed testnut/dev mint; \ - set allow_real_mints=true to pay at a real mint" - ))); - } - let buyer_mint = MintUrl::from_str(buyer_mint_url) - .map_err(|error| AuthorizePayError::Input(format!("buyer mint url: {error}")))?; - if accepted_mints.is_empty() { - return Ok(buyer_mint); - } - let listed = accepted_mints - .iter() - .map(|entry| MintUrl::from_str(entry)) - .collect::, _>>() - .map_err(|error| { - AuthorizePayError::Input(format!("creq accepted mint url: {error}")) - })?; - if listed.contains(&buyer_mint) { - return Ok(buyer_mint); +/// What the budget cap is asked to cover for one payment. +/// +/// The direct path is charged the amount, exactly as it always has been — general fee-dust behaviour +/// is a separate question and is untouched here. A hop costs the buyer more than it delivers, and +/// every sat of that difference has to pass the cap BEFORE the melt, so a hop is charged its planned +/// cost. A hop charged the delivered amount would put the Lightning fee reserve and the source +/// mint's input fee on the wire without the cap ever seeing them. +fn cap_charge(hop: Option<&crate::crossmint::HopJournal>, amount: u64) -> u64 { + match hop { + Some(pairing) => pairing.planned_cost, + None => amount, } - Err(AuthorizePayError::Wallet(PaymentWalletError::Wallet(format!( - "mint_unreachable_pay: buyer mint {buyer_mint} is not in the creq mint list {listed:?}; \ - the single-mint buyer wallet holds no balance at any accepted mint" - )))) } /// Render a co-signed [`ReceiptPreimage`] as a single-line diagnostic: the digest plus every @@ -766,6 +796,8 @@ fn publish_receipt_event( #[cfg(test)] mod tests { use super::*; + use cashu::MintUrl; + use crate::budget::BudgetGate; use crate::home::{self, DEFAULT_MINT_URL}; @@ -773,17 +805,21 @@ mod tests { const REAL_MINT: &str = "https://minibits.example"; // Empty creq list → pay from the buyer's configured mint (config-driven). - // Default flag (false): the configured testnut/dev mint resolves. + // Default flag (false): the configured testnut/dev mint plans a direct payment. #[test] - fn resolve_realized_mint_empty_creq_uses_configured_mint() { - let mint = resolve_realized_mint(DEFAULT_MINT_URL, &[], false).unwrap(); - assert_eq!(mint, MintUrl::from_str(DEFAULT_MINT_URL).unwrap()); + fn pay_plan_empty_creq_uses_configured_mint() { + let plan = crate::crossmint::plan_payment(DEFAULT_MINT_URL, &[], false).unwrap(); + assert!(!plan.is_hop()); + assert_eq!( + plan.realized_mint(), + &MintUrl::from_str(DEFAULT_MINT_URL).unwrap() + ); } // Direct path: the buyer's configured mint is one the seller listed → pay from it directly. #[test] - fn resolve_realized_mint_direct_when_configured_mint_is_listed() { - let mint = resolve_realized_mint( + fn pay_plan_is_direct_when_configured_mint_is_listed() { + let plan = crate::crossmint::plan_payment( DEFAULT_MINT_URL, &[ "https://other.example".to_string(), @@ -792,41 +828,102 @@ mod tests { false, ) .unwrap(); - assert_eq!(mint, MintUrl::from_str(DEFAULT_MINT_URL).unwrap()); + assert!(!plan.is_hop(), "overlap must not hop"); + assert_eq!( + plan.realized_mint(), + &MintUrl::from_str(DEFAULT_MINT_URL).unwrap() + ); + } + + // The boundary, half one. A configured mint outside the creq list used to be the end of the + // road for this claim; it is now a hop to a mint the seller does accept. + // + // `allow_real_mints` is on because it has to be: with the flag off the fence admits exactly one + // mint (the testnut default), so a buyer and a seller can never be at two DIFFERENT admissible + // mints and a hop is structurally unreachable in the default posture. The flag is what makes + // two distinct admissible mints possible at all. + #[test] + fn pay_plan_hops_when_the_configured_mint_is_not_listed_but_the_target_is_admissible() { + let plan = crate::crossmint::plan_payment( + "https://buyer-only.example", + &[DEFAULT_MINT_URL.to_string()], + true, + ) + .unwrap(); + assert!(plan.is_hop(), "no overlap must plan a hop, not refuse"); + assert_eq!( + plan.hop_source(), + Some(&MintUrl::from_str("https://buyer-only.example").unwrap()) + ); + assert_eq!( + plan.realized_mint(), + &MintUrl::from_str(DEFAULT_MINT_URL).unwrap() + ); } - // Configured mint NOT in the creq list → refuse `mint_unreachable_pay` fail-closed (no spend). + // The same no-overlap shape under the DEFAULT posture refuses, because the buyer's own mint is + // not admissible there — the fence stops it before a target is even considered. Together with + // the two tests around it this pins the whole boundary: a hop needs the operator's opt-in AND an + // admissible landing, and the fence refuses first when either is missing. #[test] - fn resolve_realized_mint_refuses_when_configured_mint_not_listed() { - let error = resolve_realized_mint( - DEFAULT_MINT_URL, - &["https://other.example".to_string()], + fn pay_plan_refuses_a_no_overlap_hop_under_the_default_posture() { + let error = crate::crossmint::plan_payment( + "https://buyer-only.example", + &[DEFAULT_MINT_URL.to_string()], false, ) .unwrap_err(); - assert!(matches!(error, AuthorizePayError::Wallet(_))); - assert!(error.to_string().contains("mint_unreachable_pay")); + assert!( + error.to_string().contains("real-mint fence"), + "got: {error}" + ); + } + + // The boundary, half two. No overlap AND no admissible landing still refuses fail-closed — the + // target fence is the refusal that now covers what the old membership check covered. This pair + // pins exactly where "hop" ends and "refuse" begins. + #[test] + fn pay_plan_refuses_when_no_overlap_and_no_accepted_mint_is_admissible() { + let error = + crate::crossmint::plan_payment(DEFAULT_MINT_URL, &[REAL_MINT.to_string()], false) + .unwrap_err(); + assert!(matches!(error, AuthorizePayError::Input(_))); + let rendered = error.to_string(); + assert!(rendered.contains("real-mint fence"), "got: {rendered}"); + assert!( + rendered.contains("nowhere permitted to land"), + "got: {rendered}" + ); } // Real-mint switch: a buyer configured at a real mint X is REFUSED by the fence when // `allow_real_mints` is false (default safety posture)... #[test] - fn resolve_realized_mint_real_mint_refused_by_default() { - let error = resolve_realized_mint(REAL_MINT, &[REAL_MINT.to_string()], false).unwrap_err(); + fn pay_plan_real_mint_refused_by_default() { + let error = + crate::crossmint::plan_payment(REAL_MINT, &[REAL_MINT.to_string()], false).unwrap_err(); assert!(matches!(error, AuthorizePayError::Input(_))); assert!(error.to_string().contains("real-mint fence")); } // ...and ADMITTED (pays at X when the creq lists X) once the operator opts in with the flag. #[test] - fn resolve_realized_mint_real_mint_admitted_when_flag_true() { - let paid = resolve_realized_mint(REAL_MINT, &[REAL_MINT.to_string()], true).unwrap(); - assert_eq!(paid, MintUrl::from_str(REAL_MINT).unwrap()); - - // Even with the flag on, a mint the creq did NOT list still refuses (membership unchanged). - let refused = - resolve_realized_mint(REAL_MINT, &[DEFAULT_MINT_URL.to_string()], true).unwrap_err(); - assert!(refused.to_string().contains("mint_unreachable_pay")); + fn pay_plan_real_mint_admitted_when_flag_true() { + let plan = + crate::crossmint::plan_payment(REAL_MINT, &[REAL_MINT.to_string()], true).unwrap(); + assert!(!plan.is_hop()); + assert_eq!(plan.realized_mint(), &MintUrl::from_str(REAL_MINT).unwrap()); + + // With the flag on, a creq that lists a DIFFERENT admissible mint is now reachable by hop + // rather than refused for non-membership. + let hopped = + crate::crossmint::plan_payment(REAL_MINT, &[DEFAULT_MINT_URL.to_string()], true) + .unwrap(); + assert!(hopped.is_hop()); + assert_eq!( + hopped.realized_mint(), + &MintUrl::from_str(DEFAULT_MINT_URL).unwrap() + ); } // Build a current-thread runtime and block on `authorize_pay_async` — the pattern the MCP @@ -885,6 +982,89 @@ mod tests { let _ = std::fs::remove_dir_all(&root); } + // Invariant 3 at the seam that chooses the number. The direct path charges what it always did; + // a hop charges what it costs, not what it delivers, because the fee reserve and the input fee + // reach the wire and must therefore pass the cap. + #[test] + fn the_cap_is_charged_the_hop_cost_and_the_direct_amount() { + let pairing = crate::crossmint::HopJournal { + attempt_id: "attempt-1".to_owned(), + source_mint: "https://a.example".to_owned(), + melt_quote_id: "melt-1".to_owned(), + target_mint: "https://b.example".to_owned(), + mint_quote_id: "mint-1".to_owned(), + delivered_sats: 100, + planned_cost: 109, + }; + assert_eq!(cap_charge(None, 100), 100, "the direct path is unchanged"); + assert_eq!( + cap_charge(Some(&pairing), 100), + 109, + "a hop must be charged its cost, not the amount it delivers" + ); + assert_ne!( + cap_charge(Some(&pairing), pairing.delivered_sats), + pairing.delivered_sats, + "charging the delivered amount would let the hop's fees past the cap" + ); + } + + // The replacement invariant, at the pay entry point. `mint_unreachable_pay` no longer refuses a + // buyer that cannot settle at the seller's mint — the hop does, or the fence does. This pins the + // fence half END TO END: a hop with nowhere admissible to land refuses through the real pay + // path, burns zero budget, and leaves no pairing on disk for a later run to resume. + #[test] + fn authorize_pay_refuses_an_inadmissible_hop_with_zero_spend_and_no_pairing() { + let root = std::env::temp_dir().join(format!( + "mobee-authorize-pay-hop-fence-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let _ = std::fs::remove_dir_all(&root); + let home = home::bootstrap(&root).expect("home"); + assert!( + !home.config.allow_real_mints, + "the default posture is what makes this refuse" + ); + let mut gate = BudgetGate::from_home(&home).expect("gate"); + let request = AuthorizePayRequest { + job_id: "job-hop-fence".into(), + result_id: "result-hop-fence".into(), + job_class: JobClass::FromScratch, + delivery_integrity_hash: "aa".repeat(20), + job_hash: "bb".repeat(32), + seller_pubkey: home::public_key_hex(&home).expect("pubkey"), + amount_sats: 1, + repo: "https://github.com/bitcoin/bips.git".into(), + branch: "master".into(), + commit_oid: "aa".repeat(20), + seller_signature: String::new(), + creq_hash: None, + // The buyer sits on the one fenced mint; the seller accepts only an unfenced one, so + // there is nowhere the hop is permitted to land. + accepted_mints: vec![REAL_MINT.to_string()], + realized_mint: Some(DEFAULT_MINT_URL.to_string()), + contribution: None, + }; + let error = authorize_pay_blocking(&home, &mut gate, request) + .expect_err("an inadmissible hop target must refuse"); + let message = error.to_string(); + assert!(message.contains("real-mint fence"), "unexpected: {message}"); + assert!( + message.contains("nowhere permitted to land"), + "the refusal must say the hop had no permitted target: {message}" + ); + assert_eq!(gate.spent(), 0, "a refused hop must not burn budget"); + assert!( + !crate::crossmint_hop::hop_journal_dir(&home).exists(), + "a refused hop must leave no pairing for a later run to resume" + ); + let _ = std::fs::remove_dir_all(&root); + } + #[test] fn authorize_pay_refuses_buyer_hash_mismatch_vs_advertised_commit() { let root = std::env::temp_dir().join(format!( @@ -1116,7 +1296,10 @@ mod tests { // a legacy bind (`None`) falls back to the live config default. let select = |sealed: Option<&str>, config_default: &str| { let chosen = sealed.unwrap_or(config_default); - resolve_realized_mint(chosen, &accepted, true).expect("resolve") + crate::crossmint::plan_payment(chosen, &accepted, true) + .expect("plans") + .realized_mint() + .clone() }; // SEALED bind (realized_mint = Some(m1)): the first attempt runs with config default m1, then diff --git a/crates/mobee-core/src/buyer/lifecycle.rs b/crates/mobee-core/src/buyer/lifecycle.rs index d766dcd9..549bc0bc 100644 --- a/crates/mobee-core/src/buyer/lifecycle.rs +++ b/crates/mobee-core/src/buyer/lifecycle.rs @@ -17,7 +17,7 @@ use std::future::Future; use cashu::{Amount, CurrencyUnit}; -use crate::authorize_pay::resolve_realized_mint; +use crate::crossmint::plan_payment; use crate::job_lifecycle::{AwardClaimOutcome, JobLifecycleError, JobView}; use super::reservations::{Converted, JobDisposition, ReservationState, ReserveRefused}; @@ -139,11 +139,12 @@ fn claim_is_payable(job_id: &str, creq: Option<&str>, filters: &AwardFilters) -> if filters.offer_amount_sats > filters.max_sats { return false; } - // Mint compatibility: the buyer's single-mint wallet must be able to settle at a mint the - // seller listed. This is the SAME resolution the pay path performs, so a claim that passes - // here is one the buyer can actually pay. + // Mint compatibility: the buyer must have a route to a mint the seller listed — paying from its + // own mint when the seller accepts it, otherwise hopping to one that the seller accepts and the + // fence admits. This is the SAME planning the pay path performs, so a claim that passes here is + // one the buyer can actually pay, by whichever of those two routes. let listed: Vec = request.mints.iter().map(|mint| mint.to_string()).collect(); - resolve_realized_mint(filters.buyer_mint, &listed, filters.allow_real_mints).is_ok() + plan_payment(filters.buyer_mint, &listed, filters.allow_real_mints).is_ok() } /// Failure of [`award_with_reservation`]. diff --git a/crates/mobee-core/src/buyer/mod.rs b/crates/mobee-core/src/buyer/mod.rs index 17da0ec6..a2d3f396 100644 --- a/crates/mobee-core/src/buyer/mod.rs +++ b/crates/mobee-core/src/buyer/mod.rs @@ -227,6 +227,12 @@ pub async fn run(home: MobeeHome) -> Result<(), BuyerError> { // must not keep the daemon from coming up (the stale reservation is conservative until the next // reconcile). run_reconcile_pass(&context).await; + // Finish any cross-mint hop a prior run left mid-flight, before serving. A hop whose melt landed + // but whose ecash was never issued is money sitting in neither wallet, and nothing else in the + // daemon goes looking for it — the pay attempt that started it may never be retried. Logged, not + // fatal, for the same reason as the reconcile above: a daemon that refuses to start is a daemon + // the operator cannot use to fix anything. An unrecoverable hop prints its own loud line. + run_hop_sweep(&context).await; // Re-arm pending auto-awards left by a prior run: a job posted before a crash still gets its // award with zero manual commands. Each task re-checks the relay for an existing award first // (invariant A), so re-arming never double-awards. @@ -1067,6 +1073,41 @@ async fn run_reconcile_pass(context: &Arc) { } } +/// Complete cross-mint hops a prior run left in flight, reporting UNCONDITIONALLY. +/// +/// Silence here would be indistinguishable from a sweep that has stopped running, and this is the +/// one path that notices a buyer's sats melted at one mint with no ecash at the other — so the pass +/// that found nothing says so too. +async fn run_hop_sweep(context: &Arc) { + match crate::crossmint_hop::sweep_hops(&context.home).await { + Ok(swept) if swept.is_empty() => { + eprintln!("buyer: cross-mint hop sweep found no hop in flight") + } + Ok(swept) => { + let recovered = swept + .iter() + .filter(|hop| { + hop.result + .as_ref() + .is_ok_and(|settled| settled.recovered_strand) + }) + .count(); + let failed = swept.iter().filter(|hop| hop.result.is_err()).count(); + eprintln!( + "buyer: cross-mint hop sweep resumed {} hop(s): {recovered} stranded and recovered, \ + {failed} still unfinished", + swept.len() + ); + } + // A journal we cannot read is not a reason to refuse to serve, but it IS a reason to say so: + // an unreadable journal means any hop it holds is invisible until someone looks. + Err(error) => eprintln!( + "buyer: cross-mint hop sweep could not read its journal ({error}); a hop left in flight \ + by a prior run would not have been noticed" + ), + } +} + /// Report a reconcile pass UNCONDITIONALLY — including the pass that changed nothing. /// /// Releasing a reservation is a money-visible decision: it returns funds to `available`. A path that diff --git a/crates/mobee-core/src/crossmint.rs b/crates/mobee-core/src/crossmint.rs new file mode 100644 index 00000000..aa5b1a96 --- /dev/null +++ b/crates/mobee-core/src/crossmint.rs @@ -0,0 +1,454 @@ +//! Cross-mint payment planning: which mint the buyer pays at, and whether reaching it needs a hop. +//! +//! A buyer funded only at mint A can pay a seller who accepts only mint B by hopping through +//! Lightning *between the mints*: request a NUT-04 mint quote at B (yields a bolt11), NUT-05 melt from +//! A to pay that invoice, receive fresh ecash at B, then hand it to the ordinary send path. Lightning +//! is connective tissue between mints only — the wire keeps exactly one settlement shape, so +//! pays-once, the co-signed receipt, and amount-from-the-buyer-signed-offer are untouched by it. +//! +//! This module holds only the DECISION (a pure function of the buyer's selected mint and the seller's +//! accepted set). It moves no money and touches no network, so the decision is testable on its own — +//! which matters, because "did we hop when we shouldn't have" is a question about the decision, not +//! about the outcome. + +use std::str::FromStr; + +use cdk::mint_url::MintUrl; + +use crate::authorize_pay::AuthorizePayError; +use crate::home; + +/// How the buyer reaches a mint the seller accepts. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PayPlan { + /// The buyer's own mint is already in the seller's accepted set — pay from it directly. No hop, + /// no Lightning, nothing added to the settlement path. + Direct { + /// The mint the buyer pays at. + mint: MintUrl, + }, + /// No overlap: melt at the buyer's funded mint to pay a mint quote raised at the seller's mint, + /// leaving the buyer holding fresh ecash at `target`. + Hop { + /// The buyer's funded mint, whose proofs are melted. + source: MintUrl, + /// A mint from the seller's accepted set, where the fresh ecash lands and the send happens. + target: MintUrl, + }, +} + +impl PayPlan { + /// The mint the payment is realized at — where the ecash the seller receives lives, and therefore + /// the mint sealed into the payment terms, the attempt id, and the co-signed receipt. + /// + /// For a hop this is the TARGET, not the buyer's funded mint: after the hop the buyer holds ecash + /// at the target, and the send, the attempt id, and the receipt must all agree on that one mint. + pub fn realized_mint(&self) -> &MintUrl { + match self { + Self::Direct { mint } => mint, + Self::Hop { target, .. } => target, + } + } + + /// The buyer's own funded mint — where the proofs being spent live, on either path. + /// + /// This is the SELECTION the accept-bind freezes. Freezing the selection rather than the + /// realized mint is what keeps the hop reachable: the realized mint of a hop is already in the + /// seller's accepted set, so a bind that sealed it would re-plan at pay time as a direct payment + /// from a mint the buyer holds nothing at. + pub fn source_mint(&self) -> &MintUrl { + match self { + Self::Direct { mint } => mint, + Self::Hop { source, .. } => source, + } + } + + /// The hop's source mint, or `None` on the direct path. + pub fn hop_source(&self) -> Option<&MintUrl> { + match self { + Self::Direct { .. } => None, + Self::Hop { source, .. } => Some(source), + } + } + + /// Whether reaching the realized mint requires a hop. + pub fn is_hop(&self) -> bool { + matches!(self, Self::Hop { .. }) + } +} + +/// Decide how to pay: directly from the buyer's selected mint, or via a hop to one the seller accepts. +/// +/// `buyer_selected_mint` is the mint frozen into the accept-bind (see `authorize_pay`), never the live +/// config default — a config change between attempts must not shift the mint and mint a second attempt +/// id. +/// +/// Both the source and the target pass the real-mint fence. Fencing the target matters as much as the +/// source: a hop ends with the buyer holding ecash at the target, so an unfenced target would let a +/// real-sats mint in through the back door while `allow_real_mints` is off. +/// +/// Target selection is the FIRST admissible entry of `accepted_mints` — the seller's list order is +/// their preference. It must stay deterministic: the attempt id is derived from the realized mint, so +/// a retry that re-derived a different target would compute a different attempt id and defeat +/// pays-once. +pub fn plan_payment( + buyer_selected_mint: &str, + accepted_mints: &[String], + allow_real_mints: bool, +) -> Result { + if !home::mint_allowed(buyer_selected_mint, allow_real_mints) { + return Err(AuthorizePayError::Input(format!( + "real-mint fence: buyer mint {buyer_selected_mint} is not an allow-listed testnut/dev \ + mint; set allow_real_mints=true to pay at a real mint" + ))); + } + let buyer_mint = MintUrl::from_str(buyer_selected_mint) + .map_err(|error| AuthorizePayError::Input(format!("buyer mint url: {error}")))?; + + // No accepted set to satisfy (legacy binds carry none) — the buyer's own mint stands. + if accepted_mints.is_empty() { + return Ok(PayPlan::Direct { mint: buyer_mint }); + } + + let listed = accepted_mints + .iter() + .map(|entry| MintUrl::from_str(entry)) + .collect::, _>>() + .map_err(|error| AuthorizePayError::Input(format!("creq accepted mint url: {error}")))?; + + if listed.contains(&buyer_mint) { + return Ok(PayPlan::Direct { mint: buyer_mint }); + } + + // No overlap. Hop to the first accepted mint that the fence admits; refuse fail-closed if none + // does, rather than hopping to a mint we are not permitted to hold ecash at. + let target = accepted_mints + .iter() + .zip(listed) + .find(|(raw, _)| home::mint_allowed(raw, allow_real_mints)) + .map(|(_, parsed)| parsed); + + match target { + Some(target) => Ok(PayPlan::Hop { + source: buyer_mint, + target, + }), + None => Err(AuthorizePayError::Input(format!( + "real-mint fence: buyer mint {buyer_mint} is not in the creq mint list \ + {accepted_mints:?} and no accepted mint is an allow-listed testnut/dev mint, so the \ + cross-mint hop has nowhere permitted to land; set allow_real_mints=true to pay at a \ + real mint" + ))), + } +} + +/// What the buyer spends to deliver `offer.amount` across a hop. +/// +/// Three components, all of which the buyer pays and none of which reduce what the seller receives: +/// the melt amount (which equals the invoice raised at the target), the Lightning fee reserve the +/// source mint holds back, and the source mint's input fee for spending the proofs. All three must be +/// covered by the budget cap BEFORE the melt fires — a fee that reaches the wire without passing the +/// cap is the #185 class of defect. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct HopCost { + /// Amount the source mint melts, per its melt quote. + pub melt_amount: u64, + /// Lightning fee reserve held back by the source mint (`MeltQuote::fee_reserve`). + pub fee_reserve: u64, + /// Source-mint input fee for spending the proofs that fund the melt. + pub input_fee: u64, +} + +impl HopCost { + /// Total the cap must cover before any melt. + /// + /// Checked, and refuses on overflow rather than saturating: a saturated total would still refuse + /// at the cap today, but it would do so by arriving at a number nobody computed. A cost we cannot + /// add up is a cost we decline to spend. + pub fn planned_cost(&self) -> Result { + self.melt_amount + .checked_add(self.fee_reserve) + .and_then(|subtotal| subtotal.checked_add(self.input_fee)) + .ok_or_else(|| { + AuthorizePayError::Input(format!( + "cross-mint hop cost overflows u64: melt_amount={} fee_reserve={} input_fee={}", + self.melt_amount, self.fee_reserve, self.input_fee + )) + }) + } +} + +/// The pairing record written before the melt fires. +/// +/// cdk already journals each half of the hop on its own (a `WalletSaga` carries the quote id, and +/// `check_melt_quote_status` resumes off the persisted store from a cold process). The one thing +/// nothing in cdk knows is that these two quotes are **one logical hop** — so that, and nothing more, +/// is what we persist. Written before the melt (write-before-effect), which is the same discipline the +/// budget ledger already uses, and stronger than marking the melt "initiated" reactively once the mint +/// reports it pending — that ordering leaves a window where money is in flight and the flag says +/// otherwise. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct HopJournal { + /// The pay attempt this hop funds; ties the hop to the budget ledger's idempotence key. + pub attempt_id: String, + /// Mint whose proofs are melted. + pub source_mint: String, + /// Melt quote id at the source mint — the handle recovery uses to ask "did this melt land?". + pub melt_quote_id: String, + /// Mint where the fresh ecash lands. + pub target_mint: String, + /// Mint quote id at the target mint — the handle recovery uses to detect a paid-but-unissued strand. + pub mint_quote_id: String, + /// What the seller receives: the amount pinned by the buyer-signed offer. Journalled so a + /// recovering process knows what to expect at the target without re-reading it from anywhere + /// that could have changed since the offer was signed. + pub delivered_sats: u64, + /// Cost charged against the cap before the melt. + pub planned_cost: u64, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::home::DEFAULT_MINT_URL; + + fn mint(url: &str) -> MintUrl { + MintUrl::from_str(url).expect("test mint url parses") + } + + // Invariant 2 — the decision tooth. Overlap must NOT hop: the existing direct path stays exactly + // as it was. Asserted on the decision, not on a downstream outcome, because a hop that happened + // and then coincidentally produced the right amount would still be a bug. + #[test] + fn buyer_mint_in_accepted_set_pays_direct_without_hopping() { + let plan = plan_payment(DEFAULT_MINT_URL, &[DEFAULT_MINT_URL.to_owned()], false) + .expect("an accepted, fenced buyer mint plans"); + assert_eq!( + plan, + PayPlan::Direct { + mint: mint(DEFAULT_MINT_URL) + } + ); + assert!(!plan.is_hop(), "overlap must not hop"); + assert_eq!(plan.hop_source(), None); + assert_eq!(plan.realized_mint(), &mint(DEFAULT_MINT_URL)); + } + + // Overlap anywhere in the list is still overlap, even when the buyer's mint is not first: the + // seller's preference order decides hop TARGETS, never whether to hop at all. + #[test] + fn buyer_mint_listed_but_not_first_still_pays_direct() { + let accepted = vec![ + "https://b.example".to_owned(), + "https://a.example".to_owned(), + ]; + let plan = plan_payment("https://a.example", &accepted, true).expect("listed mint plans"); + assert!(!plan.is_hop(), "a listed buyer mint never hops"); + assert_eq!(plan.realized_mint(), &mint("https://a.example")); + } + + #[test] + fn no_overlap_plans_a_hop_from_the_buyer_mint_to_an_accepted_mint() { + let accepted = vec!["https://seller.example".to_owned()]; + let plan = + plan_payment("https://buyer.example", &accepted, true).expect("a hop is plannable"); + assert_eq!( + plan, + PayPlan::Hop { + source: mint("https://buyer.example"), + target: mint("https://seller.example"), + } + ); + // The realized mint is the TARGET — that is where the seller's ecash ends up, so the terms, + // the attempt id and the receipt must bind it rather than the buyer's funded mint. + assert_eq!(plan.realized_mint(), &mint("https://seller.example")); + assert_eq!(plan.hop_source(), Some(&mint("https://buyer.example"))); + // The source is the buyer's own mint on either path — that is what the accept-bind seals. + assert_eq!(plan.source_mint(), &mint("https://buyer.example")); + } + + // What the accept-bind seals must re-plan into the SAME payment. Sealing the realized mint + // instead would make a hop re-plan as a direct payment from a mint the buyer holds nothing at, + // which is how a hop ships dead. + #[test] + fn the_sealed_source_replans_into_the_same_plan() { + let accepted = vec![ + "https://first.example".to_owned(), + "https://second.example".to_owned(), + ]; + let planned = plan_payment("https://buyer.example", &accepted, true).expect("plans"); + let sealed = planned.source_mint().to_string(); + + let replanned = plan_payment(&sealed, &accepted, true).expect("the sealed source re-plans"); + assert_eq!(replanned, planned, "the seal must re-derive the same plan"); + assert!(replanned.is_hop(), "re-planning must not collapse the hop"); + assert_eq!(replanned.realized_mint(), planned.realized_mint()); + } + + // The direct path's seal is unchanged: the buyer's own mint, which is also what it realizes at. + #[test] + fn the_direct_path_seals_the_mint_it_realizes_at() { + let plan = plan_payment(DEFAULT_MINT_URL, &[DEFAULT_MINT_URL.to_owned()], false) + .expect("direct plans"); + assert_eq!(plan.source_mint(), plan.realized_mint()); + assert_eq!(plan.source_mint(), &mint(DEFAULT_MINT_URL)); + } + + // The attempt id is derived from the realized mint, so a retry must re-derive the SAME target or + // pays-once breaks. Pin determinism against a multi-entry list. + #[test] + fn hop_target_selection_is_deterministic_first_admissible() { + let accepted = vec![ + "https://first.example".to_owned(), + "https://second.example".to_owned(), + "https://third.example".to_owned(), + ]; + let first = plan_payment("https://buyer.example", &accepted, true).expect("plans"); + for _ in 0..5 { + let again = plan_payment("https://buyer.example", &accepted, true).expect("plans"); + assert_eq!( + again, first, + "target selection must not vary between attempts" + ); + } + assert_eq!(first.realized_mint(), &mint("https://first.example")); + } + + #[test] + fn empty_accepted_set_pays_direct_at_the_buyer_mint() { + let plan = plan_payment(DEFAULT_MINT_URL, &[], false).expect("legacy bind plans"); + assert_eq!( + plan, + PayPlan::Direct { + mint: mint(DEFAULT_MINT_URL) + } + ); + } + + // The fence still refuses the buyer's own mint first, before any hop is considered. + #[test] + fn unfenced_buyer_mint_is_refused_before_planning_a_hop() { + let accepted = vec![DEFAULT_MINT_URL.to_owned()]; + let error = plan_payment("https://real-mint.example", &accepted, false) + .expect_err("an unfenced buyer mint must refuse"); + let rendered = error.to_string(); + assert!( + rendered.contains("real-mint fence"), + "expected a fence refusal, got: {rendered}" + ); + } + + // A hop must not become a back door around the fence: with the switch off, an accepted mint that + // is not allow-listed is not a permitted place to land, so the plan refuses fail-closed. + #[test] + fn hop_refuses_when_no_accepted_mint_passes_the_fence() { + // Buyer sits on the one fenced mint; the seller accepts only an unfenced real mint. + let accepted = vec!["https://real-mint.example".to_owned()]; + let error = plan_payment(DEFAULT_MINT_URL, &accepted, false) + .expect_err("an unfenced hop target must refuse"); + let rendered = error.to_string(); + assert!( + rendered.contains("real-mint fence"), + "expected a fence refusal, got: {rendered}" + ); + assert!( + rendered.contains("nowhere permitted to land"), + "the refusal should say the hop had no permitted target, got: {rendered}" + ); + } + + // With the switch on, the same shape plans a hop — proving the previous refusal came from the + // fence and not from some unrelated rejection of the list. + #[test] + fn hop_to_a_real_mint_plans_once_the_operator_opts_in() { + let accepted = vec!["https://real-mint.example".to_owned()]; + let plan = plan_payment(DEFAULT_MINT_URL, &accepted, true) + .expect("opted-in real mint is a permitted hop target"); + assert_eq!( + plan, + PayPlan::Hop { + source: mint(DEFAULT_MINT_URL), + target: mint("https://real-mint.example"), + } + ); + } + + // Invariant 3 — the cap must see the WHOLE hop. The failure this guards is #185's shape: a fee + // that reaches the wire without passing the cap. Asserted as "all three components are in the + // total", so dropping any one of them bites. + #[test] + fn planned_cost_counts_melt_amount_fee_reserve_and_input_fee() { + let cost = HopCost { + melt_amount: 100, + fee_reserve: 7, + input_fee: 2, + }; + assert_eq!(cost.planned_cost().expect("sums"), 109); + + // Each component individually moves the total — none is silently dropped. + let no_reserve = HopCost { + fee_reserve: 0, + ..cost + }; + let no_input_fee = HopCost { + input_fee: 0, + ..cost + }; + assert_eq!(no_reserve.planned_cost().expect("sums"), 102); + assert_eq!(no_input_fee.planned_cost().expect("sums"), 107); + } + + // A cost we cannot add up is a cost we decline to spend: overflow refuses rather than saturating + // into a number nobody computed. + #[test] + fn planned_cost_refuses_on_overflow_instead_of_saturating() { + let cost = HopCost { + melt_amount: u64::MAX, + fee_reserve: 1, + input_fee: 0, + }; + let error = cost + .planned_cost() + .expect_err("an unrepresentable total must refuse"); + assert!( + error.to_string().contains("overflows"), + "expected an overflow refusal, got: {error}" + ); + } + + // The journal's whole job is to record that two quotes are ONE hop, so both ids must survive a + // round trip — recovery reads this to ask each mint about its half. + #[test] + fn hop_journal_round_trips_both_quote_ids() { + let journal = HopJournal { + attempt_id: "attempt-1".to_owned(), + source_mint: "https://a.example".to_owned(), + melt_quote_id: "melt-quote-1".to_owned(), + target_mint: "https://b.example".to_owned(), + mint_quote_id: "mint-quote-1".to_owned(), + delivered_sats: 100, + planned_cost: 109, + }; + let encoded = serde_json::to_string(&journal).expect("journal serializes"); + let decoded: HopJournal = serde_json::from_str(&encoded).expect("journal deserializes"); + assert_eq!(decoded, journal); + assert_eq!(decoded.melt_quote_id, "melt-quote-1"); + assert_eq!(decoded.mint_quote_id, "mint-quote-1"); + // The delivered amount survives the round trip separately from the cost: recovery must be + // able to issue exactly the offer amount without consulting anything mutable. + assert_eq!(decoded.delivered_sats, 100); + assert_ne!(decoded.delivered_sats, decoded.planned_cost); + } + + // Selection skips an unfenced entry rather than refusing outright when a later entry is fine. + #[test] + fn hop_skips_unfenced_entries_and_lands_on_the_first_admissible_one() { + let accepted = vec![ + "http://not-https.example".to_owned(), + DEFAULT_MINT_URL.to_owned(), + ]; + let plan = plan_payment("https://buyer.example", &accepted, true) + .expect("a later admissible entry is usable"); + assert_eq!(plan.realized_mint(), &mint(DEFAULT_MINT_URL)); + } +} diff --git a/crates/mobee-core/src/crossmint_hop.rs b/crates/mobee-core/src/crossmint_hop.rs new file mode 100644 index 00000000..f23b5ed3 --- /dev/null +++ b/crates/mobee-core/src/crossmint_hop.rs @@ -0,0 +1,1416 @@ +//! Carrying out a cross-mint hop, and resuming one that was interrupted mid-flight. +//! +//! [`crate::crossmint`] decides *whether* to hop; this module performs one. The two legs are a NUT-05 +//! melt at the buyer's funded mint, paying a NUT-04 mint quote raised at the seller's, after which the +//! buyer holds fresh ecash at the target and the ordinary send path takes over unchanged. +//! +//! Everything here answers one question: after a crash, did the buyer pay twice? cdk persists each leg +//! on its own — a melt quote's state is recoverable from a cold process by quote id, and a mint quote +//! that was paid but never issued can still be issued later — but nothing in cdk knows that the two +//! quotes are ONE hop. That pairing is what this module journals, before the melt, and it is what makes +//! the melt leg safe to re-enter: on a resumed attempt the persisted quote ids WIN over anything freshly +//! planned, because raising a second melt for one attempt id is exactly the double-pay the journal +//! exists to prevent. +//! +//! The resume decision is taken from what the MINTS say, never from what we infer: +//! +//! | melt at source | mint at target | action | +//! |---|---|---| +//! | `Unpaid` | — | nothing left the source; melt (the source mint's own answer, not a guess) | +//! | `Pending` | — | money in flight; refuse, stay retryable, never melt again | +//! | `Paid` | not issued | the strand: issue the ecash at the target, and say so LOUDLY | +//! | `Paid` | issued | both legs already landed; complete without touching either mint | +//! +//! The strand row is the one that must never pass in silence. A buyer whose sats left the source mint +//! but whose ecash never appeared at the target has money that is neither spent nor held, and the only +//! thing standing between that and a silent loss is an operator who can see it. + +use std::collections::HashMap; +use std::fmt; +use std::fs::{File, OpenOptions}; +use std::future::Future; +use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use cdk::nuts::{MeltQuoteState, MintQuoteState, PaymentMethod}; +use cdk::wallet::Wallet; + +use crate::buyer_fund; +use crate::crossmint::{HopCost, HopJournal}; +use crate::home::MobeeHome; +use crate::payment_wallet::MINT_TOUCH_TIMEOUT; + +/// What the source mint says about the melt leg. +/// +/// A narrowing of cdk's melt quote state to the four answers the hop acts on. A state that is not a +/// statement about where the money is — cdk's `Unknown` — maps to [`Self::Pending`], the row that +/// refuses rather than re-melting, because "I don't know" and "it might be in flight" call for the +/// same caution. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MeltLeg { + /// The source mint has not paid the invoice. Nothing has left the wallet. + Unpaid, + /// The source mint is paying, or will not say. Money may be in flight. + Pending, + /// The source mint paid the invoice. The sats have left. + Paid, + /// The source mint tried and failed. Nothing left the wallet and nothing will through this + /// quote. + Failed, +} + +/// What the target mint says about the mint leg. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MintLeg { + /// The invoice raised at the target has not been paid yet. + Unpaid, + /// The invoice is paid but the ecash has not been issued to the buyer. + Paid, + /// The ecash has been issued; the buyer holds it. + Issued, +} + +/// Both legs of one hop, as the two mints report them. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct HopSettled { + /// Ecash issued at the target mint. Equals the pinned delivery amount, or the hop refuses. + pub minted_sats: u64, + /// Whether this run found the melt already paid and the ecash not yet issued — a hop that an + /// earlier run left stranded and this one recovered. + pub recovered_strand: bool, +} + +/// A hop failure. Every variant refuses fail-closed; none of them can be reached with the seller's +/// delivered amount already reduced, because the delivery amount is pinned before any of this runs. +#[derive(Debug)] +pub enum HopError { + /// The journal could not be read, or could not be durably appended. Fail-closed on purpose: + /// without a durable pairing there is no way to tell a crashed hop from a fresh one, so no melt + /// may fire. + Journal(String), + /// A mint refused, or could not be reached, while the hop was under way. + Mint(String), + /// Two different pairings claim one attempt id. Refusing is the whole point: acting on either + /// pairing risks a second melt against an attempt that already has one. + PairingConflict { + /// The attempt both pairings claim. + attempt_id: String, + /// The pairing already on disk. + persisted: Box, + /// The pairing this run arrived with. + planned: Box, + }, + /// The melt is settling at the source mint. Money is in flight, so this attempt must not melt + /// again; it refuses and stays retryable once the source mint reaches a terminal answer. + MeltInFlight { + /// The attempt whose melt is settling. + attempt_id: String, + /// Melt quote to re-ask about on the next run. + melt_quote_id: String, + }, + /// The source mint reports the melt as failed: the Lightning payment did not go through and the + /// proofs are back in the wallet. No money left, but this pairing is dead — the target's invoice + /// can no longer be paid through this melt quote. + /// + /// interim: the attempt stops here rather than re-planning the melt leg against the (still + /// unpaid) invoice at the target. Re-planning means a superseding-pairing record and a fresh + /// argument about pays-once, which is its own reviewed change — see MakePrisms/mobee#194. + MeltFailed { + /// The attempt whose melt failed. + attempt_id: String, + /// Melt quote the source mint reports as failed. + melt_quote_id: String, + }, + /// The source wallet cannot fund the hop. Raised while planning, so the cap is never charged + /// for a melt that could not have run. + InsufficientSource { + /// The buyer's funded mint. + mint: String, + /// What the buyer holds there. + balance: u64, + /// What the hop would cost. + planned_cost: u64, + }, + /// The target mint issued an amount other than the pinned delivery amount. Refused rather than + /// carried forward: the send that follows must hand the seller exactly the offer amount. + MintedAmountMismatch { + /// Amount pinned by the buyer-signed offer. + expected: u64, + /// Amount the target mint actually issued. + minted: u64, + }, +} + +impl fmt::Display for HopError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Journal(detail) => write!(formatter, "cross-mint hop journal: {detail}"), + Self::Mint(detail) => write!(formatter, "cross-mint hop: {detail}"), + Self::PairingConflict { + attempt_id, + persisted, + planned, + } => write!( + formatter, + "cross-mint hop: attempt {attempt_id} already journals melt quote {} at {} paired \ + with mint quote {} at {}, but this run arrived with melt quote {} at {} paired \ + with mint quote {} at {}; refusing rather than risking a second melt", + persisted.melt_quote_id, + persisted.source_mint, + persisted.mint_quote_id, + persisted.target_mint, + planned.melt_quote_id, + planned.source_mint, + planned.mint_quote_id, + planned.target_mint, + ), + Self::MeltInFlight { + attempt_id, + melt_quote_id, + } => write!( + formatter, + "cross-mint hop: melt quote {melt_quote_id} for attempt {attempt_id} is still \ + settling at the source mint; refusing to melt again while the payment is in \ + flight (retry once the mint reports paid or unpaid)" + ), + Self::MeltFailed { + attempt_id, + melt_quote_id, + } => write!( + formatter, + "cross-mint hop: the source mint reports melt quote {melt_quote_id} for attempt \ + {attempt_id} as failed; no sats left the wallet, but this attempt cannot reach \ + the seller's mint through a failed melt quote" + ), + Self::InsufficientSource { + mint, + balance, + planned_cost, + } => write!( + formatter, + "cross-mint hop: the buyer holds {balance} sats at {mint} but the hop costs \ + {planned_cost} sats (delivery plus the source mint's fee reserve and input fee); \ + refusing before any budget is charged" + ), + Self::MintedAmountMismatch { expected, minted } => write!( + formatter, + "cross-mint hop: target mint issued {minted} sats but the buyer-signed offer pins \ + delivery at {expected} sats" + ), + } + } +} + +impl std::error::Error for HopError {} + +/// The two effects a hop has on the world, plus the two questions it asks before applying them. +/// +/// A trait rather than direct wallet calls so the crash windows can be toothed hermetically: a fake +/// that pays the melt and then dies before the mint reproduces the exact strand the journal exists to +/// survive, which no amount of testing against a live mint pair could produce on demand. +pub(crate) trait HopEffects { + /// Ask the SOURCE mint what became of the melt. Asking is also cdk's recovery trigger — a melt + /// interrupted mid-saga resumes on this call rather than needing a separate sweep. + fn melt_leg(&mut self, melt_quote_id: &str) -> Result; + + /// Ask the TARGET mint whether the ecash has been issued. + fn mint_leg(&mut self, mint_quote_id: &str) -> Result; + + /// Melt at the source mint, paying the invoice the target raised. + fn melt(&mut self, melt_quote_id: &str) -> Result<(), HopError>; + + /// Issue the ecash at the target mint. Returns what was actually issued, which the caller checks + /// against the pinned delivery amount rather than trusting. + fn mint(&mut self, mint_quote_id: &str, expected_sats: u64) -> Result; +} + +/// One line of the hop journal. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(tag = "record", rename_all = "snake_case")] +pub enum HopRecord { + /// The pairing, written and synced BEFORE the melt. + Planned(HopJournal), + /// Both legs landed and the buyer holds the ecash at the target. + Settled { + /// Attempt this hop funded. + attempt_id: String, + /// Ecash issued at the target mint. + minted_sats: u64, + }, +} + +/// Durable record of which two quotes form one hop. +pub(crate) trait HopJournalStore { + /// Every record already written for one attempt, oldest first. + fn replay(&self, attempt_id: &str) -> Result, HopError>; + + /// Append one record and durably sync it before the caller applies any effect. + fn append_sync(&self, record: &HopRecord) -> Result<(), HopError>; +} + +/// Append-only JSONL hop journal, one file per attempt id under a single directory. +/// +/// Shares the payment journal's durability discipline — exclusive file lock, `sync_all` on the file +/// and on its parent directory — because the guarantee is the same one: a record the caller acted on +/// must still be there after the power goes out. +#[derive(Clone, Debug)] +pub struct FsHopJournal { + dir: PathBuf, +} + +impl FsHopJournal { + /// A journal rooted at `dir`. The directory is created on first append. + pub fn new(dir: impl Into) -> Self { + Self { dir: dir.into() } + } + + fn path_for(&self, attempt_id: &str) -> PathBuf { + self.dir.join(format!("{attempt_id}.jsonl")) + } + + /// Every attempt this journal holds records for. An absent directory means no hop has ever run + /// here, which is not an error. + pub fn attempt_ids(&self) -> Result, HopError> { + let entries = match std::fs::read_dir(&self.dir) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(journal_error("read dir", error)), + }; + let mut ids = Vec::new(); + for entry in entries { + let path = entry + .map_err(|error| journal_error("read dir entry", error))? + .path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") { + continue; + } + if let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) { + ids.push(stem.to_owned()); + } + } + // Deterministic order so a sweep's output reads the same way twice. + ids.sort(); + Ok(ids) + } +} + +fn journal_error(context: &str, error: impl fmt::Display) -> HopError { + HopError::Journal(format!("{context}: {error}")) +} + +fn sync_parent_directory(path: &Path) -> Result<(), HopError> { + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + File::open(parent) + .and_then(|dir| dir.sync_all()) + .map_err(|error| journal_error("parent directory sync", error)) +} + +impl HopJournalStore for FsHopJournal { + fn replay(&self, attempt_id: &str) -> Result, HopError> { + let path = self.path_for(attempt_id); + let mut file = match File::open(&path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(journal_error("open", error)), + }; + file.lock().map_err(|error| journal_error("lock", error))?; + file.seek(SeekFrom::Start(0)) + .map_err(|error| journal_error("seek", error))?; + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes) + .map_err(|error| journal_error("read", error))?; + // A record without its commit newline is a torn write. Refuse it rather than parse around + // it: a half-written pairing is exactly the state where a wrong answer melts twice. + if !bytes.is_empty() && !bytes.ends_with(b"\n") { + return Err(HopError::Journal(format!( + "attempt {attempt_id}: last record is missing its commit newline (torn write)" + ))); + } + let mut records = Vec::new(); + for (index, line) in BufReader::new(bytes.as_slice()).lines().enumerate() { + let line = line.map_err(|error| journal_error("read line", error))?; + let record = serde_json::from_str::(&line).map_err(|error| { + HopError::Journal(format!( + "attempt {attempt_id} line {}: {error}", + index.saturating_add(1) + )) + })?; + records.push(record); + } + Ok(records) + } + + fn append_sync(&self, record: &HopRecord) -> Result<(), HopError> { + let attempt_id = match record { + HopRecord::Planned(journal) => journal.attempt_id.as_str(), + HopRecord::Settled { attempt_id, .. } => attempt_id.as_str(), + }; + std::fs::create_dir_all(&self.dir).map_err(|error| journal_error("create dir", error))?; + let path = self.path_for(attempt_id); + let mut file = OpenOptions::new() + .create(true) + .read(true) + .append(true) + .open(&path) + .map_err(|error| journal_error("open for append", error))?; + file.lock().map_err(|error| journal_error("lock", error))?; + let mut line = + serde_json::to_vec(record).map_err(|error| journal_error("encode", error))?; + line.push(b'\n'); + file.write_all(&line) + .map_err(|error| journal_error("append", error))?; + file.sync_all() + .map_err(|error| journal_error("sync", error))?; + sync_parent_directory(&path) + } +} + +/// The pairing already on disk for an attempt, if any. +fn planned_of(records: &[HopRecord]) -> Option<&HopJournal> { + records.iter().find_map(|record| match record { + HopRecord::Planned(journal) => Some(journal), + HopRecord::Settled { .. } => None, + }) +} + +/// The pairing already journalled for an attempt, if this attempt has planned a hop before. +/// +/// Read BEFORE raising fresh quotes: an attempt that already has a pairing must reuse it, and quotes +/// raised only to be discarded are quotes the mints have to expire. +pub(crate) fn journalled_pairing( + store: &S, + attempt_id: &str, +) -> Result, HopError> { + Ok(planned_of(&store.replay(attempt_id)?).cloned()) +} + +/// The completion record for an attempt, if the hop already finished. +fn settled_of(records: &[HopRecord]) -> Option { + records.iter().find_map(|record| match record { + HopRecord::Settled { minted_sats, .. } => Some(*minted_sats), + HopRecord::Planned(_) => None, + }) +} + +/// The operator-visible line for a hop whose sats left the source but whose ecash never arrived. +/// +/// Written to stderr on its own line and prefixed with a fixed, greppable marker: the point is that +/// somebody watching the buyer's output sees it without having to know what a mint quote is. +fn strand_line(journal: &HopJournal) -> String { + format!( + "CROSSMINT STRAND attempt={} melted at {} (melt quote {}) but the ecash at {} was never \ + issued (mint quote {}); {} sats are paid and unissued — completing the mint leg now", + journal.attempt_id, + journal.source_mint, + journal.melt_quote_id, + journal.target_mint, + journal.mint_quote_id, + journal.delivered_sats, + ) +} + +/// Perform the hop described by `journal`, resuming instead of repeating whatever already happened. +/// +/// `journal` is the freshly planned pairing. If a pairing is already on disk for this attempt it WINS: +/// the fresh quote ids are discarded, because a second melt quote for one attempt id is the double-pay +/// this journal exists to prevent. A pairing that disagrees with the persisted one is refused outright +/// rather than reconciled. +/// +/// Ordering is write-before-effect throughout: the pairing is durable before the melt, and the +/// completion record is durable before the caller is told the ecash is in hand. +pub(crate) fn run_hop( + store: &S, + effects: &mut E, + journal: &HopJournal, +) -> Result { + let records = store.replay(&journal.attempt_id)?; + if let Some(minted_sats) = settled_of(&records) { + // Already complete. Neither mint is touched — this is the pays-once row. + return Ok(HopSettled { + minted_sats, + recovered_strand: false, + }); + } + + let pairing = match planned_of(&records) { + None => { + store.append_sync(&HopRecord::Planned(journal.clone()))?; + journal.clone() + } + Some(persisted) if persisted == journal => persisted.clone(), + Some(persisted) => { + return Err(HopError::PairingConflict { + attempt_id: journal.attempt_id.clone(), + persisted: Box::new(persisted.clone()), + planned: Box::new(journal.clone()), + }); + } + }; + + // Melt leg. The source mint's answer decides; we never infer from our own records whether money + // moved, because the record was written before the melt precisely so it could not know. + let melted_earlier = match effects.melt_leg(&pairing.melt_quote_id)? { + MeltLeg::Unpaid => { + effects.melt(&pairing.melt_quote_id)?; + false + } + MeltLeg::Pending => { + return Err(HopError::MeltInFlight { + attempt_id: pairing.attempt_id.clone(), + melt_quote_id: pairing.melt_quote_id.clone(), + }); + } + MeltLeg::Failed => { + return Err(HopError::MeltFailed { + attempt_id: pairing.attempt_id.clone(), + melt_quote_id: pairing.melt_quote_id.clone(), + }); + } + MeltLeg::Paid => true, + }; + + // Mint leg. A melt that landed on an EARLIER run with no ecash to show for it is the strand. + let mint_leg = effects.mint_leg(&pairing.mint_quote_id)?; + let recovered_strand = melted_earlier && mint_leg != MintLeg::Issued; + if recovered_strand { + eprintln!("{}", strand_line(&pairing)); + } + let minted_sats = match mint_leg { + MintLeg::Issued => pairing.delivered_sats, + MintLeg::Unpaid | MintLeg::Paid => { + effects.mint(&pairing.mint_quote_id, pairing.delivered_sats)? + } + }; + if minted_sats != pairing.delivered_sats { + return Err(HopError::MintedAmountMismatch { + expected: pairing.delivered_sats, + minted: minted_sats, + }); + } + + store.append_sync(&HopRecord::Settled { + attempt_id: pairing.attempt_id.clone(), + minted_sats, + })?; + Ok(HopSettled { + minted_sats, + recovered_strand, + }) +} + +/// Bound on one hop leg that moves money. +/// +/// Longer than [`MINT_TOUCH_TIMEOUT`], which bounds mint *reads*: a melt is a Lightning payment +/// between two mints, not a keyset lookup. Shorter than forever, because a leg that never returns is +/// invariant 5's failure — an await with no timer is a park nothing can end. +const HOP_LEG_TIMEOUT: Duration = Duration::from_secs(90); + +/// Run one async hop step on its own thread and its own runtime, returning synchronously. +/// +/// The budget gate's effect boundary is synchronous — `authorize_then_attempt` takes an `FnOnce` — +/// and the hop is async, so the two need a bridge. Blocking the caller's runtime thread is not on the +/// table (`block_on` inside a runtime panics), and the crate already answers this exact question for +/// the wallet effects the same way: own thread, own current-thread runtime, synchronous handoff. +fn block_on_leg(label: &str, future: F) -> Result +where + F: Future + Send + 'static, + T: Send + 'static, +{ + let label = label.to_owned(); + std::thread::Builder::new() + .name("mobee-crossmint-hop".into()) + .spawn(move || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map(|runtime| runtime.block_on(future)) + }) + .map_err(|error| HopError::Mint(format!("{label}: hop thread: {error}")))? + .join() + .map_err(|_| HopError::Mint(format!("{label}: hop thread panicked")))? + .map_err(|error| HopError::Mint(format!("{label}: hop runtime: {error}"))) +} + +/// Bound an async mint touch, turning a hang into a refusal. +async fn bounded( + label: &str, + timeout: Duration, + future: impl Future>, +) -> Result { + match tokio::time::timeout(timeout, future).await { + Err(_elapsed) => Err(HopError::Mint(format!("{label} exceeded {timeout:?}"))), + Ok(Ok(value)) => Ok(value), + Ok(Err(error)) => Err(HopError::Mint(format!("{label}: {error}"))), + } +} + +/// The two mint wallets one hop runs against. +/// +/// Both are opened through [`buyer_fund::open_wallet_at_mint_async`], which fences the mint it is +/// asked for. That is why the hop cannot become a back door around `allow_real_mints`: the fence is +/// inside the opener, so the TARGET is fenced by the same code that fences the source, without this +/// module having to remember to do it. +pub(crate) struct CdkHopEffects { + source: Wallet, + target: Wallet, +} + +impl CdkHopEffects { + /// Open the buyer's wallet at both mints. One sqlite store, two bound mints. + pub(crate) async fn open( + home: &MobeeHome, + source_mint: &str, + target_mint: &str, + ) -> Result { + let source = buyer_fund::open_wallet_at_mint_async(home, source_mint) + .await + .map_err(|error| HopError::Mint(format!("source mint {source_mint}: {error}")))?; + let target = buyer_fund::open_wallet_at_mint_async(home, target_mint) + .await + .map_err(|error| HopError::Mint(format!("target mint {target_mint}: {error}")))?; + Ok(Self { source, target }) + } + + /// Price the hop and raise both quotes, moving no money. + /// + /// Delivery is PINNED: the mint quote at the target is raised for exactly `delivered_sats`, the + /// amount from the buyer-signed offer. The buyer's cost is what floats — melt amount, the source + /// mint's Lightning fee reserve, and its input fee — so no fee reading can ever reduce what the + /// seller receives. + /// + /// Runs before the budget gate, so every failure here refuses with zero spend. + pub(crate) async fn plan_quotes( + &self, + attempt_id: &str, + delivered_sats: u64, + ) -> Result { + if delivered_sats == 0 { + return Err(HopError::Mint( + "cross-mint hop: delivery amount is 0 (refusing to raise a quote for nothing)" + .into(), + )); + } + let mint_quote = bounded( + "target mint quote", + MINT_TOUCH_TIMEOUT, + self.target.mint_quote( + PaymentMethod::BOLT11, + Some(cdk::Amount::from(delivered_sats)), + None, + None, + ), + ) + .await?; + if mint_quote.request.trim().is_empty() { + return Err(HopError::Mint(format!( + "target mint {} returned an empty bolt11 for quote {} (refusing a hop with nothing \ + to pay)", + self.target.mint_url, mint_quote.id + ))); + } + let melt_quote = bounded( + "source melt quote", + MINT_TOUCH_TIMEOUT, + self.source.melt_quote( + PaymentMethod::BOLT11, + mint_quote.request.clone(), + None, + None, + ), + ) + .await?; + let cost = HopCost { + melt_amount: melt_quote.amount.to_u64(), + fee_reserve: melt_quote.fee_reserve.to_u64(), + input_fee: self.source_input_fee_ceiling().await?, + }; + let planned_cost = cost.planned_cost().map_err(|error| { + // `planned_cost` reports overflow through the pay error type; the hop states it in its + // own words rather than smuggling a foreign error across the boundary. + HopError::Mint(error.to_string()) + })?; + self.require_source_covers(planned_cost).await?; + Ok(HopJournal { + attempt_id: attempt_id.to_owned(), + source_mint: self.source.mint_url.to_string(), + melt_quote_id: mint_quote_id(&melt_quote.id), + target_mint: self.target.mint_url.to_string(), + mint_quote_id: mint_quote_id(&mint_quote.id), + delivered_sats, + planned_cost, + }) + } + + /// Refuse a hop the source wallet cannot fund, BEFORE the cap is charged. + /// + /// Without this the buyer would charge its budget the full planned cost and only then discover + /// it has nothing to melt — spending cap on a payment that never happens. Before this slice a + /// buyer with no route to the seller's mint simply declined at zero cost; a hop must not turn + /// that into a charge. + async fn require_source_covers(&self, planned_cost: u64) -> Result<(), HopError> { + let balance = bounded( + "source balance", + MINT_TOUCH_TIMEOUT, + self.source.total_balance(), + ) + .await? + .to_u64(); + if balance < planned_cost { + return Err(HopError::InsufficientSource { + mint: self.source.mint_url.to_string(), + balance, + planned_cost, + }); + } + Ok(()) + } + + /// Upper bound on the source mint's input fee for this melt. + /// + /// The melt selects its inputs when it runs, so the exact input count is not knowable before the + /// cap check. Priced at the most inputs the melt could possibly select — every unspent proof at + /// the source — which over-states the fee rather than under-stating it. Under-stating would put + /// a fee on the wire that the cap never saw, which is the defect class the cap exists to stop. + /// + /// interim: the reserve-versus-actual reconciliation that would make this exact reshapes the + /// spend ledger, which is money-gate machinery and its own slice — see MakePrisms/mobee#186. + async fn source_input_fee_ceiling(&self) -> Result { + let proofs = bounded( + "source proof count", + MINT_TOUCH_TIMEOUT, + self.source.get_unspent_proofs(), + ) + .await?; + let inputs = u64::try_from(proofs.len()).unwrap_or(u64::MAX).max(1); + crate::payment_wallet::bounded_input_fee(&self.source, inputs) + .await + .map(|fee| fee.to_u64()) + .map_err(|error| HopError::Mint(format!("source input fee: {error}"))) + } +} + +/// cdk quote ids render as their string form; the journal stores that, since it is what +/// `check_melt_quote_status` / `check_mint_quote` are asked for on recovery. +fn mint_quote_id(id: &impl fmt::Display) -> String { + id.to_string() +} + +impl HopEffects for CdkHopEffects { + fn melt_leg(&mut self, melt_quote_id: &str) -> Result { + let wallet = self.source.clone(); + let quote_id = melt_quote_id.to_owned(); + // Asking is also cdk's own recovery trigger: a melt interrupted mid-saga resumes on this + // call, so the answer reflects a settled world rather than the moment we crashed. + let state = block_on_leg("melt state", async move { + bounded( + "melt quote status", + HOP_LEG_TIMEOUT, + wallet.check_melt_quote_status("e_id), + ) + .await + .map(|quote| quote.state) + })??; + Ok(match state { + MeltQuoteState::Unpaid => MeltLeg::Unpaid, + MeltQuoteState::Paid => MeltLeg::Paid, + MeltQuoteState::Failed => MeltLeg::Failed, + // Pending is money in flight; Unknown is the mint declining to say. Neither is a + // statement that the sats are still ours, so neither may lead to a second melt. + MeltQuoteState::Pending | MeltQuoteState::Unknown => MeltLeg::Pending, + }) + } + + fn mint_leg(&mut self, mint_quote_id: &str) -> Result { + let wallet = self.target.clone(); + let quote_id = mint_quote_id.to_owned(); + let state = block_on_leg("mint state", async move { + bounded( + "mint quote status", + HOP_LEG_TIMEOUT, + wallet.check_mint_quote("e_id), + ) + .await + .map(|quote| quote.state) + })??; + Ok(match state { + MintQuoteState::Unpaid => MintLeg::Unpaid, + MintQuoteState::Paid => MintLeg::Paid, + MintQuoteState::Issued => MintLeg::Issued, + }) + } + + fn melt(&mut self, melt_quote_id: &str) -> Result<(), HopError> { + let wallet = self.source.clone(); + let quote_id = melt_quote_id.to_owned(); + block_on_leg("melt", async move { + let prepared = bounded( + "prepare melt", + MINT_TOUCH_TIMEOUT, + wallet.prepare_melt("e_id, HashMap::new()), + ) + .await?; + bounded("melt confirm", HOP_LEG_TIMEOUT, prepared.confirm()).await?; + Ok::<(), HopError>(()) + })? + } + + fn mint(&mut self, mint_quote_id: &str, expected_sats: u64) -> Result { + let wallet = self.target.clone(); + let quote_id = mint_quote_id.to_owned(); + block_on_leg("mint", async move { + // The shared poll-then-issue path, which already refuses a phantom credit and an + // under- or over-funded issue. Nothing about a hop makes those checks weaker. + crate::wallet_ops::poll_and_mint(&wallet, "e_id, expected_sats) + .await + .map_err(|error| HopError::Mint(format!("target mint issue: {error}"))) + })? + } +} + +/// One hop the sweep found unfinished and tried to complete. +#[derive(Debug)] +pub struct SweptHop { + /// The attempt whose hop was resumed. + pub attempt_id: String, + /// What resuming it did, or why it could not be finished. + pub result: Result, +} + +/// The directory a home keeps its hop pairings in. +pub fn hop_journal_dir(home: &MobeeHome) -> PathBuf { + home.root.join("crossmint-journal") +} + +/// Finish every hop this home left in flight. +/// +/// A hop interrupted by a crash is not something the next pay attempt necessarily re-drives — that +/// attempt may never be retried — so without a sweep the sats could sit melted at the source with no +/// ecash anywhere and nothing looking for them. +/// +/// Both wallets are opened and both are recovered before any pairing is resumed, and that is not +/// belt-and-braces: cdk's `recover_incomplete_sagas` filters to its own wallet's mint, so a two-mint +/// operation recovered on one wallet SILENTLY SKIPS the other mint's saga. One wallet is not half a +/// recovery here; it is a recovery that reports success having looked at half the problem. +/// +/// Every hop is attempted independently, and a hop that cannot be finished says so on stderr rather +/// than being dropped from the report — including a hop whose mints the fence no longer admits, +/// which is stuck by design but must not be stuck in silence. +pub async fn sweep_hops(home: &MobeeHome) -> Result, HopError> { + let store = FsHopJournal::new(hop_journal_dir(home)); + let mut swept = Vec::new(); + for attempt_id in store.attempt_ids()? { + let records = store.replay(&attempt_id)?; + if settled_of(&records).is_some() { + continue; + } + let Some(pairing) = planned_of(&records).cloned() else { + continue; + }; + let result = sweep_one(home, &store, pairing.clone()).await; + if let Err(error) = &result { + eprintln!( + "CROSSMINT STRAND attempt={} could not be completed by the recovery sweep \ + (source {} melt quote {}, target {} mint quote {}): {error}", + pairing.attempt_id, + pairing.source_mint, + pairing.melt_quote_id, + pairing.target_mint, + pairing.mint_quote_id, + ); + } + swept.push(SweptHop { attempt_id, result }); + } + Ok(swept) +} + +/// Refuse a recovery that did not cover both of the hop's mints. +/// +/// cdk's saga recovery reports success after looking only at its own wallet's mint, so "recovery +/// ran" and "the hop was recovered" are different claims. This turns the difference into a refusal: +/// a sweep that somehow touched one mint stops here instead of reporting a hop as swept. +fn require_both_mints_recovered( + recovered: &[String], + pairing: &HopJournal, +) -> Result<(), HopError> { + for mint in [&pairing.source_mint, &pairing.target_mint] { + if !recovered.iter().any(|seen| seen == mint) { + return Err(HopError::Journal(format!( + "attempt {}: saga recovery covered {recovered:?}, which does not include {mint}; \ + refusing to report a hop as swept on half its mints", + pairing.attempt_id + ))); + } + } + Ok(()) +} + +async fn sweep_one( + home: &MobeeHome, + store: &FsHopJournal, + pairing: HopJournal, +) -> Result { + let mut effects = CdkHopEffects::open(home, &pairing.source_mint, &pairing.target_mint).await?; + let mut recovered = Vec::new(); + for (label, wallet) in [("source", &effects.source), ("target", &effects.target)] { + bounded( + &format!("{label} saga recovery"), + HOP_LEG_TIMEOUT, + wallet.recover_incomplete_sagas(), + ) + .await?; + recovered.push(wallet.mint_url.to_string()); + } + require_both_mints_recovered(&recovered, &pairing)?; + // `run_hop` blocks (it bridges each leg onto its own runtime), so it may not run on the + // caller's async thread. + let store = store.clone(); + tokio::task::spawn_blocking(move || run_hop(&store, &mut effects, &pairing)) + .await + .map_err(|error| HopError::Mint(format!("hop sweep task: {error}")))? +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + use std::collections::HashMap; + use std::rc::Rc; + + use super::*; + use crate::budget::{BudgetGate, BudgetRefuse}; + + fn journal(attempt: &str) -> HopJournal { + HopJournal { + attempt_id: attempt.to_owned(), + source_mint: "https://a.example".to_owned(), + melt_quote_id: "melt-1".to_owned(), + target_mint: "https://b.example".to_owned(), + mint_quote_id: "mint-1".to_owned(), + delivered_sats: 100, + planned_cost: 109, + } + } + + /// What the fake mints did, shared with the test so a restart can inspect it. + #[derive(Default)] + struct MintWorld { + melt_leg: Option, + mint_leg: Option, + melts: Vec, + mints: Vec, + /// Melt succeeds, then the process dies before the mint leg — the crash window. + die_after_melt: bool, + mint_fails: bool, + } + + impl MintWorld { + fn shared() -> Rc> { + Rc::new(RefCell::new(Self { + melt_leg: Some(MeltLeg::Unpaid), + mint_leg: Some(MintLeg::Unpaid), + ..Self::default() + })) + } + } + + struct FakeMints { + world: Rc>, + } + + impl HopEffects for FakeMints { + fn melt_leg(&mut self, _melt_quote_id: &str) -> Result { + self.world + .borrow() + .melt_leg + .ok_or_else(|| HopError::Mint("source mint unreachable".into())) + } + + fn mint_leg(&mut self, _mint_quote_id: &str) -> Result { + self.world + .borrow() + .mint_leg + .ok_or_else(|| HopError::Mint("target mint unreachable".into())) + } + + fn melt(&mut self, melt_quote_id: &str) -> Result<(), HopError> { + let mut world = self.world.borrow_mut(); + world.melts.push(melt_quote_id.to_owned()); + world.melt_leg = Some(MeltLeg::Paid); + // The target's invoice is paid by the melt, so its quote flips too — unless the target + // is unreachable (`None`), which stays unreachable no matter what the source did. + if world.mint_leg.is_some() { + world.mint_leg = Some(MintLeg::Paid); + } + if world.die_after_melt { + return Err(HopError::Mint("process died after the melt".into())); + } + Ok(()) + } + + fn mint(&mut self, mint_quote_id: &str, expected_sats: u64) -> Result { + let mut world = self.world.borrow_mut(); + if world.mint_fails { + return Err(HopError::Mint("target mint refused to issue".into())); + } + world.mints.push(mint_quote_id.to_owned()); + world.mint_leg = Some(MintLeg::Issued); + Ok(expected_sats) + } + } + + /// An in-memory journal that survives a "restart" (the store outlives the effects). + #[derive(Default)] + struct MemJournal { + records: RefCell>>, + } + + impl HopJournalStore for MemJournal { + fn replay(&self, attempt_id: &str) -> Result, HopError> { + Ok(self + .records + .borrow() + .get(attempt_id) + .cloned() + .unwrap_or_default()) + } + + fn append_sync(&self, record: &HopRecord) -> Result<(), HopError> { + let attempt_id = match record { + HopRecord::Planned(journal) => journal.attempt_id.clone(), + HopRecord::Settled { attempt_id, .. } => attempt_id.clone(), + }; + self.records + .borrow_mut() + .entry(attempt_id) + .or_default() + .push(record.clone()); + Ok(()) + } + } + + #[test] + fn a_clean_hop_melts_once_mints_once_and_journals_the_pairing_before_the_melt() { + let store = MemJournal::default(); + let world = MintWorld::shared(); + let mut effects = FakeMints { + world: Rc::clone(&world), + }; + let settled = run_hop(&store, &mut effects, &journal("attempt-1")).expect("hop completes"); + + assert_eq!(settled.minted_sats, 100); + assert!(!settled.recovered_strand); + assert_eq!(world.borrow().melts.len(), 1); + assert_eq!(world.borrow().mints.len(), 1); + + // The pairing is the FIRST record — written before the melt, which is what makes the melt + // leg re-enterable after a crash. + let records = store.replay("attempt-1").expect("replays"); + assert!(matches!(records.first(), Some(HopRecord::Planned(_)))); + assert!(matches!( + records.last(), + Some(HopRecord::Settled { + minted_sats: 100, + .. + }) + )); + } + + // Invariant 4, strong form. Kill between the melt and the mint, restart, and the hop must pay + // exactly once — the second run melts ZERO times — and must say out loud that it found a strand. + #[test] + fn kill_between_melt_and_mint_pays_once_on_restart_and_reports_the_strand() { + let store = MemJournal::default(); + let world = MintWorld::shared(); + world.borrow_mut().die_after_melt = true; + + let mut dying = FakeMints { + world: Rc::clone(&world), + }; + let error = run_hop(&store, &mut dying, &journal("attempt-1")) + .expect_err("the run dies after the melt"); + assert!(matches!(error, HopError::Mint(_)), "got: {error}"); + assert_eq!(world.borrow().melts.len(), 1, "the melt did land"); + assert!(world.borrow().mints.is_empty(), "the ecash never issued"); + + // Restart: same journal on disk, fresh effects over the same mints. + world.borrow_mut().die_after_melt = false; + let mut restarted = FakeMints { + world: Rc::clone(&world), + }; + let settled = + run_hop(&store, &mut restarted, &journal("attempt-1")).expect("the restart recovers"); + + assert_eq!(settled.minted_sats, 100); + assert!( + settled.recovered_strand, + "a melted-but-unissued hop is a strand and must be reported as one" + ); + assert_eq!( + world.borrow().melts.len(), + 1, + "exactly-once: the restart must not melt again" + ); + assert_eq!(world.borrow().mints.len(), 1); + + // The loud line names both quote ids and both mints, so an operator reading stderr can act + // on it without opening the journal. + let line = strand_line(&journal("attempt-1")); + for needle in [ + "CROSSMINT STRAND", + "attempt-1", + "melt-1", + "mint-1", + "https://a.example", + "https://b.example", + ] { + assert!( + line.contains(needle), + "strand line missing {needle}: {line}" + ); + } + } + + // A completed hop is never re-run: neither mint is touched again, no matter how many times the + // attempt is retried. + #[test] + fn a_settled_hop_touches_neither_mint_again() { + let store = MemJournal::default(); + let world = MintWorld::shared(); + let mut effects = FakeMints { + world: Rc::clone(&world), + }; + run_hop(&store, &mut effects, &journal("attempt-1")).expect("hop completes"); + + for _ in 0..3 { + let again = + run_hop(&store, &mut effects, &journal("attempt-1")).expect("replay is a no-op"); + assert_eq!(again.minted_sats, 100); + assert!(!again.recovered_strand); + } + assert_eq!(world.borrow().melts.len(), 1); + assert_eq!(world.borrow().mints.len(), 1); + } + + // Money in flight is not a licence to melt again. The source mint saying "pending" refuses. + #[test] + fn a_pending_melt_refuses_instead_of_melting_again() { + let store = MemJournal::default(); + let world = MintWorld::shared(); + world.borrow_mut().melt_leg = Some(MeltLeg::Pending); + let mut effects = FakeMints { + world: Rc::clone(&world), + }; + let error = run_hop(&store, &mut effects, &journal("attempt-1")) + .expect_err("an in-flight melt must refuse"); + assert!( + matches!(error, HopError::MeltInFlight { .. }), + "got: {error}" + ); + assert!( + world.borrow().melts.is_empty(), + "never melt while a melt is in flight" + ); + } + + // Freshly planned quotes must NOT override a pairing already on disk — that is the double-melt. + #[test] + fn a_second_pairing_for_one_attempt_is_refused_rather_than_melted() { + let store = MemJournal::default(); + let world = MintWorld::shared(); + world.borrow_mut().die_after_melt = true; + let mut dying = FakeMints { + world: Rc::clone(&world), + }; + let _ = run_hop(&store, &mut dying, &journal("attempt-1")); + assert_eq!(world.borrow().melts.len(), 1); + + // The retry re-planned and arrived with brand new quote ids. + let mut replanned = journal("attempt-1"); + replanned.melt_quote_id = "melt-2".to_owned(); + replanned.mint_quote_id = "mint-2".to_owned(); + world.borrow_mut().die_after_melt = false; + let mut effects = FakeMints { + world: Rc::clone(&world), + }; + let error = run_hop(&store, &mut effects, &replanned) + .expect_err("a conflicting pairing must refuse"); + assert!( + matches!(error, HopError::PairingConflict { .. }), + "got: {error}" + ); + assert_eq!( + world.borrow().melts.len(), + 1, + "the conflicting pairing must not have melted" + ); + } + + // The send that follows hands the seller exactly the offer amount, so a short issue is refused + // here rather than carried into the send path. + #[test] + fn issuing_less_than_the_pinned_delivery_amount_refuses() { + struct ShortMint; + impl HopEffects for ShortMint { + fn melt_leg(&mut self, _: &str) -> Result { + Ok(MeltLeg::Unpaid) + } + fn mint_leg(&mut self, _: &str) -> Result { + Ok(MintLeg::Paid) + } + fn melt(&mut self, _: &str) -> Result<(), HopError> { + Ok(()) + } + fn mint(&mut self, _: &str, expected_sats: u64) -> Result { + Ok(expected_sats.saturating_sub(1)) + } + } + let store = MemJournal::default(); + let error = run_hop(&store, &mut ShortMint, &journal("attempt-1")) + .expect_err("a short issue must refuse"); + assert!( + matches!( + error, + HopError::MintedAmountMismatch { + expected: 100, + minted: 99 + } + ), + "got: {error}" + ); + assert!( + settled_of(&store.replay("attempt-1").expect("replays")).is_none(), + "a refused hop must not journal a completion" + ); + } + + /// A fresh journal directory for one test, named so concurrent test binaries cannot collide. + fn scratch_dir(label: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "mobee-crossmint-journal-{}-{label}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + dir + } + + #[test] + fn the_file_journal_round_trips_a_pairing_and_a_completion() { + let store = FsHopJournal::new(scratch_dir("round-trip")); + assert!(store.replay("attempt-1").expect("empty replays").is_empty()); + + let pairing = journal("attempt-1"); + store + .append_sync(&HopRecord::Planned(pairing.clone())) + .expect("pairing appends"); + store + .append_sync(&HopRecord::Settled { + attempt_id: "attempt-1".to_owned(), + minted_sats: 100, + }) + .expect("completion appends"); + + let records = store.replay("attempt-1").expect("replays"); + assert_eq!(records.len(), 2); + assert_eq!(planned_of(&records), Some(&pairing)); + assert_eq!(settled_of(&records), Some(100)); + // Attempts do not see each other's records. + assert!(store.replay("attempt-2").expect("replays").is_empty()); + } + + // Invariant 3, strong form. A cap one sat under the hop's planned cost must stop the hop BEFORE + // it melts — not fail it afterwards, by which point the sats have already left the buyer's + // wallet. Driven through the real `BudgetGate`, because the property being tested is that the + // gate never reaches the effect, and a fake gate would be testing the fake. + #[test] + fn a_cap_one_sat_under_the_planned_cost_means_no_melt_is_attempted_at_all() { + let pairing = journal("attempt-1"); + let store = MemJournal::default(); + let world = MintWorld::shared(); + let mut effects = FakeMints { + world: Rc::clone(&world), + }; + + // Per-job and total caps both sit one sat under what the hop would cost. + let under = pairing.planned_cost.saturating_sub(1); + let mut gate = BudgetGate::new(under, under); + let refusal = gate + .authorize_then_attempt(&pairing.attempt_id, pairing.planned_cost, || { + run_hop(&store, &mut effects, &pairing) + }) + .expect_err("a cap under the planned cost must refuse"); + + assert!(matches!(refusal, BudgetRefuse::PerJob { .. }), "{refusal}"); + assert!( + world.borrow().melts.is_empty(), + "the melt must never be attempted when the cap refuses" + ); + assert!(world.borrow().mints.is_empty()); + assert_eq!(gate.spent(), 0, "a refused hop spends nothing"); + assert!( + store.replay("attempt-1").expect("replays").is_empty(), + "a refused hop leaves no pairing on disk" + ); + + // The same hop at a cap that covers it does melt — so the refusal above came from the cap + // and not from something else refusing first. + let mut gate = BudgetGate::new(pairing.planned_cost, pairing.planned_cost); + gate.authorize_then_attempt(&pairing.attempt_id, pairing.planned_cost, || { + run_hop(&store, &mut effects, &pairing) + }) + .expect("the cap admits the hop") + .expect("the hop completes"); + assert_eq!(world.borrow().melts.len(), 1); + assert_eq!(gate.spent(), pairing.planned_cost); + } + + // The cap must see the fee, not just the delivery. A cap sized to the amount the seller receives + // is NOT enough to authorize the hop that delivers it — which is the whole reason the hop is + // charged its planned cost rather than the offer amount. + #[test] + fn a_cap_sized_to_the_delivered_amount_does_not_authorize_the_hop_that_delivers_it() { + let pairing = journal("attempt-1"); + assert!( + pairing.planned_cost > pairing.delivered_sats, + "the fixture must actually cost more than it delivers" + ); + let store = MemJournal::default(); + let world = MintWorld::shared(); + let mut effects = FakeMints { + world: Rc::clone(&world), + }; + + let mut gate = BudgetGate::new(pairing.delivered_sats, pairing.delivered_sats); + let refusal = gate + .authorize_then_attempt(&pairing.attempt_id, pairing.planned_cost, || { + run_hop(&store, &mut effects, &pairing) + }) + .expect_err("the fee reserve and input fee must not slip past the cap"); + assert!(matches!(refusal, BudgetRefuse::PerJob { .. }), "{refusal}"); + assert!(world.borrow().melts.is_empty()); + } + + // Invariant 4's trap, made into a refusal. cdk's saga recovery filters to its own wallet's + // mint, so a sweep that ran on one wallet would report success having examined half the hop. + #[test] + fn a_sweep_that_covered_one_mint_refuses_to_call_the_hop_swept() { + let pairing = journal("attempt-1"); + require_both_mints_recovered( + &[pairing.source_mint.clone(), pairing.target_mint.clone()], + &pairing, + ) + .expect("both mints covered"); + + for half in [&pairing.source_mint, &pairing.target_mint] { + let error = require_both_mints_recovered(std::slice::from_ref(half), &pairing) + .expect_err("one mint is not a recovery"); + assert!(error.to_string().contains("half its mints"), "got: {error}"); + } + // A recovery that touched two mints, but not the RIGHT two, is no better. + let error = require_both_mints_recovered( + &[ + "https://elsewhere.example".to_owned(), + pairing.source_mint.clone(), + ], + &pairing, + ) + .expect_err("the wrong second mint is not a recovery"); + assert!( + error.to_string().contains(&pairing.target_mint), + "got: {error}" + ); + } + + // A melt the source mint reports as FAILED stops the attempt. Nothing left the wallet, so this + // is not the strand — but the quote is dead, and re-melting it is not the answer either. + #[test] + fn a_failed_melt_refuses_without_melting_again() { + let store = MemJournal::default(); + let world = MintWorld::shared(); + world.borrow_mut().melt_leg = Some(MeltLeg::Failed); + let mut effects = FakeMints { + world: Rc::clone(&world), + }; + let error = run_hop(&store, &mut effects, &journal("attempt-1")) + .expect_err("a failed melt must refuse"); + assert!(matches!(error, HopError::MeltFailed { .. }), "got: {error}"); + assert!(world.borrow().melts.is_empty()); + assert!(world.borrow().mints.is_empty()); + } + + // Every leg that can fail must leave the attempt un-completed: no completion record, so a later + // run re-reads the pairing and asks the mints again rather than assuming anything landed. This + // is the safety the old membership refusal used to provide, re-pinned at the layer that replaced + // it — the hop is now what stands between "cannot settle at the seller's mint" and a wrong spend. + #[test] + fn no_failing_leg_leaves_a_completion_record_behind() { + /// How one case breaks the world, paired with the name of the leg it breaks. + type BrokenLeg = (&'static str, Box); + + let cases: Vec = vec![ + ( + "source mint unreachable", + Box::new(|world: &mut MintWorld| world.melt_leg = None), + ), + ( + "melt in flight", + Box::new(|world: &mut MintWorld| world.melt_leg = Some(MeltLeg::Pending)), + ), + ( + "melt failed", + Box::new(|world: &mut MintWorld| world.melt_leg = Some(MeltLeg::Failed)), + ), + ( + "target mint unreachable", + Box::new(|world: &mut MintWorld| world.mint_leg = None), + ), + ( + "target refuses to issue", + Box::new(|world: &mut MintWorld| world.mint_fails = true), + ), + ]; + for (label, break_it) in cases { + let store = MemJournal::default(); + let world = MintWorld::shared(); + break_it(&mut world.borrow_mut()); + let mut effects = FakeMints { + world: Rc::clone(&world), + }; + let error = run_hop(&store, &mut effects, &journal("attempt-1")) + .expect_err(&format!("{label} must refuse")); + assert!( + settled_of(&store.replay("attempt-1").expect("replays")).is_none(), + "{label}: refused with {error}, but left a completion record" + ); + assert!( + world.borrow().mints.is_empty(), + "{label}: refused but claimed ecash anyway" + ); + } + } + + // A torn final record is refused, not parsed around: a half-written pairing is exactly the + // state in which a wrong answer melts twice. + #[test] + fn a_torn_final_record_refuses_rather_than_being_parsed_around() { + let journal_dir = scratch_dir("torn-tail"); + let store = FsHopJournal::new(&journal_dir); + store + .append_sync(&HopRecord::Planned(journal("attempt-1"))) + .expect("pairing appends"); + let path = journal_dir.join("attempt-1.jsonl"); + let mut bytes = std::fs::read(&path).expect("read"); + bytes.extend_from_slice(b"{\"record\":\"settled\""); + std::fs::write(&path, bytes).expect("write"); + + let error = store + .replay("attempt-1") + .expect_err("a torn record refuses"); + assert!( + error.to_string().contains("torn write"), + "expected a torn-write refusal, got: {error}" + ); + } +} diff --git a/crates/mobee-core/src/job_lifecycle.rs b/crates/mobee-core/src/job_lifecycle.rs index 47d76050..0cf7c744 100644 --- a/crates/mobee-core/src/job_lifecycle.rs +++ b/crates/mobee-core/src/job_lifecycle.rs @@ -967,18 +967,26 @@ pub async fn accept_claim_async( let accepted_mints = verify_accepted_claim_creq(claim.creq.as_deref(), &request.job_id, offer.amount_sats)?; - // FREEZE the realized paying mint at accept from the buyer's then-configured default (validated - // against the accepted set + the real-mint fence). Sealing the SELECTION here — not just the - // accepted SET (finding V) — is what makes the pay-path attempt id stable across retries: a - // config-default change after accept can no longer shift the mint into a different attempt id and - // mint a second payment for one job (double-pay). A buyer whose configured mint is not payable - // for this claim is refused HERE (fail-closed), never accepted into an unpayable bind. - let realized_mint = crate::authorize_pay::resolve_realized_mint( + // FREEZE the buyer's paying mint at accept from its then-configured default, validated by + // planning the payment against the accepted set + the real-mint fence. Sealing the SELECTION + // here — not just the accepted SET (finding V) — is what makes the pay-path attempt id stable + // across retries: a config-default change after accept can no longer shift the mint into a + // different attempt id and mint a second payment for one job (double-pay). A buyer with no + // payable route to this claim is refused HERE (fail-closed), never accepted into an unpayable + // bind. + // + // The seal is the buyer's own funded mint, which is also the mint a direct payment realizes at. + // A payment that reaches the seller's mint by hopping realizes at the TARGET, but sealing that + // would re-plan at pay time as a direct payment from a mint the buyer holds nothing at; the + // target is re-derived there from this seal plus the accepted set frozen alongside it, so it + // stays deterministic without being stored twice. + let realized_mint = crate::crossmint::plan_payment( home.config.default_mint(), &accepted_mints, home.config.allow_real_mints, ) .map_err(|error| JobLifecycleError::Input(error.to_string()))? + .source_mint() .to_string(); let buyer_pubkey = keys.public_key().to_hex(); @@ -1408,8 +1416,9 @@ pub fn authorize_request_from_bind( /// (finding V). The co-signed receipt preimage binds only `creq_hash` (which pins the accepted SET), /// not the realized mint, so the seller cosig does not pin `accepted_mints`; a caller-supplied list /// could otherwise substitute a mint outside the bound set. Deriving it solely from the sealed bind -/// closes that: the realized-mint selection ([`crate::authorize_pay::resolve_realized_mint`]) can -/// only pick the buyer's own mint from within the bound set. +/// closes that: payment planning ([`crate::crossmint::plan_payment`]) realizes only at a mint drawn +/// from the bound set — the buyer's own when the seller accepts it, otherwise a hop target taken +/// from that same set. pub fn fill_explicit_request_from_bind( request: &mut crate::authorize_pay::AuthorizePayRequest, bind: &AcceptedBind, @@ -2365,8 +2374,8 @@ mod tests { // co-signed receipt preimage binds only creq_hash (the accepted SET), not the realized mint, so // a caller list is unpinned by the seller cosig; `fill_explicit_request_from_bind` overwrites it // from the sealed bind unconditionally. Here the caller passes a mint OUTSIDE the bind's set; - // after fill the request carries ONLY the bind's set, so realized-mint selection can never pick - // the substituted mint (`resolve_realized_mint` accepts only a buyer mint within that set). + // after fill the request carries ONLY the bind's set, so planning can never realize at the + // substituted mint (`plan_payment` realizes only at a mint drawn from that set). #[test] fn fill_explicit_overwrites_caller_accepted_mints_with_bind() { let bound_mint = "https://mint.minibits.cash/Bitcoin".to_string(); @@ -2440,7 +2449,8 @@ mod tests { // `authorize_pay`'s exact mint-selection + attempt-id derivation over the resulting request. #[tokio::test(flavor = "current_thread")] async fn config_default_flip_after_accept_does_not_shift_attempt_id() { - use crate::authorize_pay::{resolve_realized_mint, wallet_open_mint_url}; + use crate::authorize_pay::wallet_open_mint_url; + use crate::crossmint::plan_payment; use crate::payment::{ DeliveryIntegrityHash, JobHash, JobId, PaymentKey, PaymentTerms, ResultId, }; @@ -2508,8 +2518,10 @@ mod tests { // wallet-open seam consumes). let attempt_for = |config_default: &str| -> (String, MintUrl, PaymentTerms) { let selected = request.realized_mint.as_deref().unwrap_or(config_default); - let mint = resolve_realized_mint(selected, &request.accepted_mints, true) - .expect("resolve realized mint"); + let mint = plan_payment(selected, &request.accepted_mints, true) + .expect("plan payment") + .realized_mint() + .clone(); let terms = PaymentTerms::new( mint.clone(), Amount::from(request.amount_sats), diff --git a/crates/mobee-core/src/lib.rs b/crates/mobee-core/src/lib.rs index 443a9cdd..67529c56 100644 --- a/crates/mobee-core/src/lib.rs +++ b/crates/mobee-core/src/lib.rs @@ -9,6 +9,10 @@ pub mod job_lifecycle; #[cfg(all(feature = "wallet", feature = "gateway"))] pub mod profile; pub mod contribution; +#[cfg(all(feature = "wallet", feature = "gateway"))] +pub mod crossmint; +#[cfg(all(feature = "wallet", feature = "gateway"))] +pub mod crossmint_hop; pub mod delivery; #[cfg(feature = "git-delivery")] pub mod delivery_git; diff --git a/crates/mobee-core/src/payment_wallet.rs b/crates/mobee-core/src/payment_wallet.rs index e9af9276..f847e218 100644 --- a/crates/mobee-core/src/payment_wallet.rs +++ b/crates/mobee-core/src/payment_wallet.rs @@ -512,6 +512,24 @@ async fn cached_input_fee_floor( Ok(floor.map(|ppk| Amount::from((ppk * proof_count).div_ceil(1000)))) } +/// Live input fee for `proof_count` inputs at this mint, bounded and fail-closed. +/// +/// The same reader and the same bound as the dust guard below, for callers that must PRICE a spend +/// whose input count they have already bounded rather than test the N=1 floor. Never defaults the +/// fee: a mint that will not answer refuses the spend. +pub(crate) async fn bounded_input_fee( + wallet: &Wallet, + proof_count: u64, +) -> Result { + match mint_input_fee_bounded(wallet, proof_count, MINT_TOUCH_TIMEOUT).await { + BoundedFee::Fee(fee) => Ok(fee), + BoundedFee::Failed(error) => Err(error), + BoundedFee::Unreachable(detail) => { + Err(mint_unreachable(wallet, MINT_UNREACHABLE_PAY, detail)) + } + } +} + /// Refuse amounts that cannot yield a redeemable locked token after mint input fees. /// /// Uses the N=1 floor from the live keyset (`ceil(ppk/1000)`), bounded so a dead diff --git a/crates/mobee-core/src/wallet_ops.rs b/crates/mobee-core/src/wallet_ops.rs index 8c29edbb..b2a5b346 100644 --- a/crates/mobee-core/src/wallet_ops.rs +++ b/crates/mobee-core/src/wallet_ops.rs @@ -273,7 +273,9 @@ pub async fn open_wallet_async( .map_err(|error| WalletOpsError::Wallet(error.to_string())) } -async fn poll_and_mint( +/// Wait for a mint quote to be paid, then issue it. Refuses a phantom credit (nothing issued) and an +/// issue that does not equal what was quoted. +pub(crate) async fn poll_and_mint( wallet: &Wallet, quote_id: &str, expected_sats: u64, diff --git a/scripts/crossmint-smoke.sh b/scripts/crossmint-smoke.sh new file mode 100755 index 00000000..cdbe2ce3 --- /dev/null +++ b/scripts/crossmint-smoke.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash +# +# Live cross-mint hop smoke — a small REAL-SATS trade between two different mints. +# +# ┌───────────────────────────────────────────────────────────────────────────────────────────┐ +# │ THIS SCRIPT SPENDS REAL MONEY AND REQUIRES A MONEY-GATE CONFIG CHANGE. │ +# │ It refuses to run without an explicit authorization token naming the exact amount and │ +# │ both mints, so it cannot be started by an agent, a retry, or a stray shell history entry. │ +# └───────────────────────────────────────────────────────────────────────────────────────────┘ +# +# Why this exists at all: test ecash structurally CANNOT hop. Fake mints cannot pay each other's +# invoices over real Lightning, so the hermetic teeth prove control flow, the journal, budget +# accounting and recovery — but not real LN routing between two mints. This is the only thing that +# proves that leg, and it is the reason the slice ships with that caveat stated rather than hidden. +# +# Usage (after authorization): +# CROSSMINT_SMOKE_AUTH="::" ./scripts/crossmint-smoke.sh +# +# The token must match the three values below exactly. Change the values, and the token stops +# matching — which is the point: authorization is for one specific spend, not for the script. + +set -euo pipefail + +# ── The authorized spend ──────────────────────────────────────────────────────────────────────── +# Fill these in from the authorization, then hand the operator the matching CROSSMINT_SMOKE_AUTH. +AMOUNT_SATS="${AMOUNT_SATS:-}" # what the seller receives (the buyer pays this plus hop fees) +SOURCE_MINT="${SOURCE_MINT:-}" # the mint the buyer holds sats at +TARGET_MINT="${TARGET_MINT:-}" # the mint the seller accepts — MUST differ from SOURCE_MINT +BUYER_HOME="${BUYER_HOME:-}" # buyer home whose config gets allow_real_mints +SELLER_HOME="${SELLER_HOME:-}" # seller home advertising TARGET_MINT + +MOBEE="${MOBEE:-./target/release/mobee}" + +die() { echo "crossmint-smoke: $*" >&2; exit 1; } + +# ── Gate ──────────────────────────────────────────────────────────────────────────────────────── +for var in AMOUNT_SATS SOURCE_MINT TARGET_MINT BUYER_HOME SELLER_HOME; do + [ -n "${!var}" ] || die "$var is not set — fill in the authorized values first" +done +[ "$SOURCE_MINT" != "$TARGET_MINT" ] || die "source and target mints are identical; that is a direct payment, not a hop" + +expected_auth="${AMOUNT_SATS}:${SOURCE_MINT}:${TARGET_MINT}" +# The token is not printed on refusal, deliberately. Its job is to make the run DELIBERATE — a human +# naming the amount and both mints — and a refusal that echoed the expected value would hand that +# right back to whatever just tried to run this, which is exactly the caller it exists to stop. +[ "${CROSSMINT_SMOKE_AUTH:-}" = "$expected_auth" ] || die \ +"refusing to spend real sats without matching authorization. +CROSSMINT_SMOKE_AUTH must be set to '::' for the spend that was +actually authorized. This is real money and a money-gate config change: it is authorized by a human +naming those three values, never by an agent and never by a retry." + +echo "=== crossmint smoke: ${AMOUNT_SATS} sats, ${SOURCE_MINT} -> ${TARGET_MINT} ===" + +# ── 0. Record the before-state, so the after-state means something ────────────────────────────── +echo "--- balances before ---" +"$MOBEE" --home "$BUYER_HOME" wallet balances +"$MOBEE" --home "$SELLER_HOME" wallet balances +before_spent=$("$MOBEE" --home "$BUYER_HOME" budget status --json | jq -r '.spent_total_sats') +echo "buyer spent before: ${before_spent}" + +# ── 1. Money-gate config: allow_real_mints on both homes ──────────────────────────────────────── +# THIS IS THE MONEY-GATE CHANGE. It is part of what was authorized, and it is reverted in step 6 +# whether the trade succeeds or fails. +restore_fence() { + echo "--- restoring the real-mint fence on both homes ---" + "$MOBEE" --home "$BUYER_HOME" config set allow_real_mints false || true + "$MOBEE" --home "$SELLER_HOME" config set allow_real_mints false || true +} +trap restore_fence EXIT + +"$MOBEE" --home "$BUYER_HOME" config set allow_real_mints true +"$MOBEE" --home "$SELLER_HOME" config set allow_real_mints true + +# The buyer holds sats ONLY at the source; the seller accepts ONLY the target. That non-overlap is +# the whole experiment — if they overlap, the pay path plans Direct and proves nothing. +"$MOBEE" --home "$BUYER_HOME" config set default_mint "$SOURCE_MINT" +"$MOBEE" --home "$SELLER_HOME" config set accepted_mints "$TARGET_MINT" + +buyer_balance=$("$MOBEE" --home "$BUYER_HOME" wallet balances --json \ + | jq -r --arg m "$SOURCE_MINT" '.[] | select(.mint_url == $m) | .balance_sats') +[ -n "$buyer_balance" ] && [ "$buyer_balance" -gt "$AMOUNT_SATS" ] || die \ + "buyer holds ${buyer_balance:-0} sats at ${SOURCE_MINT}; the hop needs more than ${AMOUNT_SATS} (delivery plus fees)" + +# ── 2. Run one trade ──────────────────────────────────────────────────────────────────────────── +# Nothing here is hop-specific: it is an ordinary trade, which is the point. The hop is chosen +# inside the pay path because the mints do not overlap, and every other step is unchanged. +echo "--- posting and settling one job ---" +job_id="crossmint-smoke-$(date -u +%Y%m%dT%H%M%SZ)" +"$MOBEE" --home "$BUYER_HOME" job post --id "$job_id" --amount "$AMOUNT_SATS" --spec "cross-mint hop smoke" +"$MOBEE" --home "$BUYER_HOME" job settle --id "$job_id" --wait + +# ── 3. What has to be true ────────────────────────────────────────────────────────────────────── +echo "--- assertions ---" +outcome=$("$MOBEE" --home "$BUYER_HOME" job show --id "$job_id" --json) + +state=$(echo "$outcome" | jq -r '.state') +amount_sats=$(echo "$outcome" | jq -r '.amount_sats') +charged_sats=$(echo "$outcome" | jq -r '.charged_sats') + +[ "$state" = "Paid" ] || die "job state is ${state}, expected Paid" +# THE headline assertion: the seller received exactly the offer amount, and the hop's fees came out +# of the BUYER's cost rather than the seller's delivery. +[ "$amount_sats" = "$AMOUNT_SATS" ] || die "seller received ${amount_sats}, expected exactly ${AMOUNT_SATS} — delivery is supposed to be pinned" +[ "$charged_sats" -gt "$amount_sats" ] || die "charged ${charged_sats} vs delivered ${amount_sats}: a hop must cost the buyer MORE than it delivers (fee reserve + input fee), so this did not hop" + +# The receipt binds the TARGET mint: after the hop, that is where the seller's ecash lives. +receipt_mint=$(echo "$outcome" | jq -r '.receipt.mint') +[ "$receipt_mint" = "$TARGET_MINT" ] || die "receipt binds ${receipt_mint}, expected the hop target ${TARGET_MINT}" + +# The hop journal recorded the pairing and completed it — one hop, both quote ids, settled. +journal="${BUYER_HOME}/crossmint-journal" +attempt_id=$(echo "$outcome" | jq -r '.attempt_id') +[ -f "${journal}/${attempt_id}.jsonl" ] || die "no hop journal at ${journal}/${attempt_id}.jsonl — the pay path did not hop" +grep -q '"record":"planned"' "${journal}/${attempt_id}.jsonl" || die "hop journal has no pairing record" +grep -q '"record":"settled"' "${journal}/${attempt_id}.jsonl" || die "hop journal never settled — a leg is stranded" + +# The budget moved by the hop's COST, not by the delivered amount. +after_spent=$("$MOBEE" --home "$BUYER_HOME" budget status --json | jq -r '.spent_total_sats') +delta=$(( after_spent - before_spent )) +[ "$delta" = "$charged_sats" ] || die "budget moved ${delta} but the hop charged ${charged_sats}" + +echo "--- balances after ---" +"$MOBEE" --home "$BUYER_HOME" wallet balances +"$MOBEE" --home "$SELLER_HOME" wallet balances + +# ── 4. Pays-once under a restart ──────────────────────────────────────────────────────────────── +# Re-driving a settled attempt must touch neither mint. This is the live form of the hermetic +# exactly-once tooth, and it is cheap: if it is wrong, it costs another AMOUNT_SATS and we find out. +echo "--- re-driving the settled attempt (must be a no-op) ---" +"$MOBEE" --home "$BUYER_HOME" job settle --id "$job_id" --wait || true +replay_spent=$("$MOBEE" --home "$BUYER_HOME" budget status --json | jq -r '.spent_total_sats') +[ "$replay_spent" = "$after_spent" ] || die \ + "PAYS-ONCE VIOLATED: re-driving the attempt moved the budget from ${after_spent} to ${replay_spent}" + +# ── 5. The startup sweep sees a finished hop and leaves it alone ──────────────────────────────── +echo "--- restarting the buyer daemon (the sweep must find nothing to resume) ---" +"$MOBEE" --home "$BUYER_HOME" buyer restart 2>&1 | tee /dev/stderr | grep -q "cross-mint hop sweep" \ + || die "the daemon did not report a hop sweep at startup" + +echo +echo "=== SMOKE PASSED ===" +echo " delivered to seller : ${amount_sats} sats at ${TARGET_MINT}" +echo " charged to buyer : ${charged_sats} sats (hop fees = $(( charged_sats - amount_sats )))" +echo " attempt : ${attempt_id}" +echo +echo "Report the delivered/charged pair and the fee delta — the gap between them IS the hop, and it" +echo "is the number #186 will later reconcile." +# The EXIT trap restores allow_real_mints=false on both homes from here.