Skip to content
Merged
349 changes: 266 additions & 83 deletions crates/mobee-core/src/authorize_pay.rs

Large diffs are not rendered by default.

11 changes: 6 additions & 5 deletions crates/mobee-core/src/buyer/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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<String> = 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`].
Expand Down
41 changes: 41 additions & 0 deletions crates/mobee-core/src/buyer/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -1067,6 +1073,41 @@ async fn run_reconcile_pass(context: &Arc<BuyerContext>) {
}
}

/// 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<BuyerContext>) {
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
Expand Down
Loading
Loading