Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
177 changes: 176 additions & 1 deletion crates/mobee-core/src/buyer/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ use cashu::{Amount, CurrencyUnit};
use crate::authorize_pay::resolve_realized_mint;
use crate::job_lifecycle::{AwardClaimOutcome, JobLifecycleError, JobView};

use super::reservations::{Converted, JobDisposition, ReserveRefused};
use super::reservations::{Converted, JobDisposition, ReservationState, ReserveRefused};
use super::store::{BuyerStore, StoreError};

/// Hard filters an awardable claim must pass (issue #126). Grounded in the wire the offer/claim
Expand Down Expand Up @@ -55,6 +55,69 @@ pub fn select_awardable_claim(view: &JobView, filters: &AwardFilters) -> Option<
.map(|claim| claim.claim_id.clone())
}

/// Why a specifically-named (manual) award was refused. The manual path names a `claim_id` instead
/// of auto-selecting, so it must apply the SAME hard filters `select_awardable_claim` applies —
/// otherwise `max_sats` and mint/price compatibility would be dead input on the manual path.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NamedAwardRefused {
/// The offer price exceeds the buyer's `max_sats` ceiling for this award.
OverMax { offer_amount_sats: u64, max_sats: u64 },
/// No claim with that id is on the relay for this job.
NotFound { claim_id: String },
/// The named claim is not live (expired / superseded / past deadline) — nothing to award.
NotLive { claim_id: String },
/// The named claim cannot be paid (missing/malformed creq, price ≠ offer amount, wrong unit, or
/// no mutually-payable mint) — awarding it would commit to something the buyer cannot settle.
Unpayable { claim_id: String },
}

impl std::fmt::Display for NamedAwardRefused {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::OverMax { offer_amount_sats, max_sats } => write!(
formatter,
"award refused: offer price {offer_amount_sats} sat exceeds max_sats {max_sats}"
),
Self::NotFound { claim_id } => write!(formatter, "award refused: claim {claim_id} not found for this job"),
Self::NotLive { claim_id } => write!(formatter, "award refused: claim {claim_id} is not live"),
Self::Unpayable { claim_id } => write!(
formatter,
"award refused: claim {claim_id} is not payable (price/mint/creq incompatible — the buyer could not settle it)"
),
}
}
}

impl std::error::Error for NamedAwardRefused {}

/// Verify a specifically-named claim is awardable under the hard filters — the manual-award
/// counterpart of [`select_awardable_claim`], so `max_sats` and mint/price compatibility are applied
/// on the manual path rather than ignored. Pure: relay truth + filters in, verdict out.
pub fn named_claim_awardable(
view: &JobView,
claim_id: &str,
filters: &AwardFilters,
) -> Result<(), NamedAwardRefused> {
if filters.offer_amount_sats > filters.max_sats {
return Err(NamedAwardRefused::OverMax {
offer_amount_sats: filters.offer_amount_sats,
max_sats: filters.max_sats,
});
}
let claim = view
.claims
.iter()
.find(|claim| claim.claim_id == claim_id)
.ok_or_else(|| NamedAwardRefused::NotFound { claim_id: claim_id.to_owned() })?;
if !claim.live {
return Err(NamedAwardRefused::NotLive { claim_id: claim_id.to_owned() });
}
if !claim_is_payable(&view.job_id, claim.creq.as_deref(), filters) {
return Err(NamedAwardRefused::Unpayable { claim_id: claim_id.to_owned() });
}
Ok(())
}

/// True when a claim's `creq` is present, well-formed, priced at the offer amount within the
/// budget ceiling, denominated in sats for this job, and quotes a mint the buyer can pay from.
fn claim_is_payable(job_id: &str, creq: Option<&str>, filters: &AwardFilters) -> bool {
Expand Down Expand Up @@ -206,6 +269,35 @@ pub enum PaymentProgress {
None,
}

/// Whether re-arming a pending auto-award should re-attempt the reserve-then-award, or skip because
/// the job is already awarded.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RearmAction {
/// The job is already awarded — do not award again.
Skip,
/// No award exists yet — run the reserve-then-award path.
Attempt,
}

/// The idempotent re-arm decision (#126/#127 invariant A: never award twice). A job is already
/// awarded iff a buyer AWARD (3405) is on the relay OR its reservation is already `Spent` (collect
/// paid it). A `Reserved` row with NO relay award is the crash window between reserve and publish —
/// the award never went out, so it must be re-attempted (republished); [`award_with_reservation`]'s
/// reserve is idempotent for the same amount, so the re-attempt reserves once and publishes.
///
/// Checking BOTH the relay AND the local ledger is load-bearing: the relay catches a 3405 that
/// published before a crash (local ledger alone would miss it → double award); the `Spent` check
/// catches an already-paid job.
pub fn plan_rearm(award_on_relay: bool, reservation: Option<ReservationState>) -> RearmAction {
if award_on_relay {
return RearmAction::Skip;
}
if matches!(reservation, Some(ReservationState::Spent)) {
return RearmAction::Skip;
}
RearmAction::Attempt
}

/// Classify a reserved job for [`BuyerStore::reconcile`] from its payment progress + relay
/// liveness. The payment journal is authoritative over relay liveness: a `Closed` payment is
/// `Paid` regardless of whether the claim still looks live, and an ambiguous payment is KEPT
Expand Down Expand Up @@ -349,6 +441,70 @@ mod tests {
assert_eq!(select_awardable_claim(&view, &filters(10, 100)), None);
}

// MANUAL-AWARD max_sats tooth: a named claim applies the SAME hard filters as auto-award, so
// max_sats is enforced (not dead input) on the manual path.
#[test]
fn named_claim_awardable_accepts_live_payable_within_max() {
let job = "a".repeat(64);
let claim_id = "c".repeat(64);
let view = view_with(&job, 10, vec![claim(&job, true, 10, &[DEFAULT_MINT_URL.into()])]);
assert_eq!(named_claim_awardable(&view, &claim_id, &filters(10, 100)), Ok(()));
}

// Over the ceiling: a manual award of a claim whose offer price exceeds max_sats is refused —
// the check that was missing (max_sats was ignored) on the manual path.
#[test]
fn named_claim_over_max_sats_refused() {
let job = "a".repeat(64);
let claim_id = "c".repeat(64);
let view = view_with(&job, 50, vec![claim(&job, true, 50, &[DEFAULT_MINT_URL.into()])]);
assert_eq!(
named_claim_awardable(&view, &claim_id, &filters(50, 40)),
Err(NamedAwardRefused::OverMax { offer_amount_sats: 50, max_sats: 40 })
);
}

// A named claim that is not on the relay is refused as NotFound.
#[test]
fn named_claim_not_found_refused() {
let job = "a".repeat(64);
let view = view_with(&job, 10, vec![claim(&job, true, 10, &[DEFAULT_MINT_URL.into()])]);
let missing = "d".repeat(64);
assert_eq!(
named_claim_awardable(&view, &missing, &filters(10, 100)),
Err(NamedAwardRefused::NotFound { claim_id: missing })
);
}

// A named but non-live claim is refused (nothing live to award).
#[test]
fn named_claim_not_live_refused() {
let job = "a".repeat(64);
let claim_id = "c".repeat(64);
let view = view_with(&job, 10, vec![claim(&job, false, 10, &[DEFAULT_MINT_URL.into()])]);
assert_eq!(
named_claim_awardable(&view, &claim_id, &filters(10, 100)),
Err(NamedAwardRefused::NotLive { claim_id })
);
}

// A named claim quoting only a mint the buyer cannot settle at is refused as Unpayable — the
// manual path never awards a claim it cannot pay.
#[test]
fn named_claim_unpayable_mint_refused() {
let job = "a".repeat(64);
let claim_id = "c".repeat(64);
let view = view_with(
&job,
10,
vec![claim(&job, true, 10, &["https://foreign.testnut.example".into()])],
);
assert_eq!(
named_claim_awardable(&view, &claim_id, &filters(10, 100)),
Err(NamedAwardRefused::Unpayable { claim_id })
);
}

// AWARD-REFUSED tooth: when the reservation is refused, `publish` is NEVER called and NO
// reservation row is written. Red-on-revert: reserving AFTER the publish would fire the
// publish closure here (the flag flips), failing the "publish must not run" assertion.
Expand Down Expand Up @@ -549,4 +705,23 @@ mod tests {
assert_eq!(store.available(100, u64::MAX, 0).expect("avail"), 100, "dead job's funds reclaimed");
let _ = std::fs::remove_file(&path);
}

// IDEMPOTENT RE-ARM tooth (invariant A): never award twice. An award already on the relay ⇒
// Skip regardless of local state; an already-`Spent` reservation ⇒ Skip. No relay award ⇒
// Attempt — including the `Reserved`-but-not-published crash window (republish) and the
// Released/None cases.
#[test]
fn plan_rearm_skips_only_when_already_awarded() {
// Relay award present ⇒ Skip regardless of local reservation state.
assert_eq!(plan_rearm(true, None), RearmAction::Skip);
assert_eq!(plan_rearm(true, Some(ReservationState::Reserved)), RearmAction::Skip);
assert_eq!(plan_rearm(true, Some(ReservationState::Spent)), RearmAction::Skip);
// Already paid ⇒ Skip.
assert_eq!(plan_rearm(false, Some(ReservationState::Spent)), RearmAction::Skip);
// Crash window: reserved but no relay award ⇒ Attempt (republish, reserve is idempotent).
assert_eq!(plan_rearm(false, Some(ReservationState::Reserved)), RearmAction::Attempt);
// Released or never reserved, no relay award ⇒ Attempt.
assert_eq!(plan_rearm(false, Some(ReservationState::Released)), RearmAction::Attempt);
assert_eq!(plan_rearm(false, None), RearmAction::Attempt);
}
}
Loading
Loading