From 4520cc2eb8fdfade44bc5f897da3cfb786ceaf88 Mon Sep 17 00:00:00 2001 From: maxie-agent <330537927+maxie-agent@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:17:19 +0000 Subject: [PATCH 1/3] fix: prefer credits and unify buyer mint settlement rules --- .../maxplayer-private-protocol/src/wire.rs | 20 +- crates/maxplayer-core/src/authorize_pay.rs | 65 +++- crates/maxplayer-core/src/buyer/lifecycle.rs | 125 ++++++- crates/maxplayer-core/src/buyer/mod.rs | 127 +++++-- crates/maxplayer-core/src/crossmint.rs | 99 +++-- crates/maxplayer-core/src/crossmint_hop.rs | 31 ++ crates/maxplayer-core/src/job_lifecycle.rs | 242 +++++++++++- .../src/private_content/evidence.rs | 52 ++- .../src/private_content/invoice.rs | 2 +- .../src/private_content/tests.rs | 39 +- crates/maxplayer-mint/Cargo.lock | 217 ++++++++++- crates/maxplayer-mint/Cargo.toml | 6 + crates/maxplayer-mint/tests/sidecar.rs | 343 ++++++++++++++++++ .../tests/support/https_mint.rs | 96 +++++ crates/maxplayer/src/mcp.rs | 1 + docs/specs/buyer-mint-choice.md | 152 ++++++++ 16 files changed, 1524 insertions(+), 93 deletions(-) create mode 100644 crates/maxplayer-mint/tests/support/https_mint.rs create mode 100644 docs/specs/buyer-mint-choice.md diff --git a/crates/buzz/crates/maxplayer-private-protocol/src/wire.rs b/crates/buzz/crates/maxplayer-private-protocol/src/wire.rs index e542a5de7..0bbb0db05 100644 --- a/crates/buzz/crates/maxplayer-private-protocol/src/wire.rs +++ b/crates/buzz/crates/maxplayer-private-protocol/src/wire.rs @@ -100,6 +100,16 @@ impl HostPolicy { Ok(()) } pub fn mint(&self, mint: &str) -> Result<()> { + self.well_formed_mint(mint)?; + if !self.accepted_mints.iter().any(|v| v == mint) { + return Err(Error("unapproved mint")); + } + Ok(()) + } + + /// Signed claim and receipt mints need not be configured by the checker. + /// Settlement still binds the destination to the signed seller claim. + pub fn well_formed_mint(&self, mint: &str) -> Result<()> { // Signed wire values are canonical: unlike configured wallet URLs, no // trailing slash is stripped here. Repository URLs remain HTTPS-only. if let Some(npub) = mint.strip_prefix("nostr://") { @@ -107,8 +117,8 @@ impl HostPolicy { } else { secure_url(mint)?; } - if mint.len() > 2048 || !self.accepted_mints.iter().any(|v| v == mint) { - return Err(Error("unapproved mint")); + if mint.len() > 2048 { + return Err(Error("mint URL too long")); } Ok(()) } @@ -417,7 +427,7 @@ pub fn validate_private(event: &Event, host: &HostPolicy) -> Result { "job-class" => member(v, &["contribution"])?, "metadata_trust" => member(v, &["seller-claimed"])?, "repo" => host.repo(v)?, - "mint" => host.mint(v)?, + "mint" => host.well_formed_mint(v)?, "branch" => require_hex( v.strip_prefix("refs/heads/delivery/") .ok_or(Error("invalid delivery ref"))?, @@ -633,7 +643,7 @@ mod mint_tests { } #[test] - fn private_receipt_validates_realized_nostr_mint_membership() { + fn private_receipt_validates_realized_nostr_mint_well_formedness() { use nostr::prelude::{EventBuilder, Keys, Kind, Tag}; let mint = format!("nostr://{NPUB}"); let keys = Keys::parse(&format!("{:064x}", 1)).unwrap(); @@ -660,7 +670,7 @@ mod mint_tests { .sign_with_keys(&keys) .unwrap(); validate_private(&event, &policy(&mint)).unwrap(); - assert!(validate_private(&event, &policy("https://mint.example")).is_err()); + validate_private(&event, &policy("https://mint.example")).unwrap(); } #[test] diff --git a/crates/maxplayer-core/src/authorize_pay.rs b/crates/maxplayer-core/src/authorize_pay.rs index bc25d9ed1..7321b550d 100644 --- a/crates/maxplayer-core/src/authorize_pay.rs +++ b/crates/maxplayer-core/src/authorize_pay.rs @@ -1150,8 +1150,18 @@ fn receipt_preimage_bound( let mut computed = receipt_preimage_for(key, buyer, seller, kind); if let Some(private) = private { computed.protocol = crate::receipt::ReceiptProtocol::V2; - if computed != private.preimage || key.result_id.as_str() != private.evidence.result.id.to_hex() { - return Err(EffectError::new("payment key differs from immutable private result")); + if !crate::private_content::public_v2::is_public(&private.evidence.offer) { + private + .evidence + .validate_realized_mint(&key.mint.to_string(), &private.policy) + .map_err(|e| EffectError::new(e.to_string()))?; + } + if computed != private.preimage + || key.result_id.as_str() != private.evidence.result.id.to_hex() + { + return Err(EffectError::new( + "payment key differs from immutable private result", + )); } } Ok(computed) @@ -2002,6 +2012,57 @@ mod tests { ); } + #[test] + fn credits_first_receipt_destination_guard_runs_before_spend() { + let (evidence, request, buyer, policy) = + crate::private_content::evidence::inline_fixture_with_payment(true, true); + let verified = evidence + .validate_request(&request, &buyer.public_key().to_hex(), &policy) + .unwrap(); + let private = PrivateReceipt { + preimage: verified.preimage, + evidence, + policy, + }; + let dir = tempfile::tempdir().unwrap(); + let mut home = home::bootstrap(dir.path()).unwrap(); + home.config.allow_real_mints = true; + let derive = |mints: &[String]| { + derive_payment( + &home, + &request.job_id, + &request.result_id, + &request.delivery_integrity_hash, + &request.job_hash, + &request.seller_pubkey, + request.amount_sats, + mints, + Some("https://funding.example"), + request.creq_hash.clone(), + ) + .unwrap() + }; + let good = derive(&request.accepted_mints); + receipt_preimage_bound( + &good.key, + &buyer.public_key().to_hex(), + &request.seller_pubkey, + DeliveryKind::Inline, + Some(&private), + ) + .unwrap(); + let bad = derive(&["https://outside.example".into()]); + let err = receipt_preimage_bound( + &bad.key, + &buyer.public_key().to_hex(), + &request.seller_pubkey, + DeliveryKind::Inline, + Some(&private), + ) + .unwrap_err(); + assert!(err.to_string().contains("outside signed claim"), "{err}"); + } + // Finding CC (load-bearing): the pay-path attempt id is derived from the SEALED realized-mint // SELECTION frozen in the accept-bind — NOT the live config default — so a config-default change // BETWEEN attempts (e.g. after a receipt-publish failure, before the buyer retries) cannot shift diff --git a/crates/maxplayer-core/src/buyer/lifecycle.rs b/crates/maxplayer-core/src/buyer/lifecycle.rs index 29f2b1016..21aa827ed 100644 --- a/crates/maxplayer-core/src/buyer/lifecycle.rs +++ b/crates/maxplayer-core/src/buyer/lifecycle.rs @@ -41,6 +41,8 @@ pub struct AwardFilters<'a> { /// The buyer's own paying mint (config default). A claim whose `creq` lists no mint the buyer /// can settle at is skipped — the #126 mandatory guard: never auto-award what we cannot pay. pub buyer_mint: &'a str, + /// Configured-wallet snapshot shared with the reservation ceiling. Empty on read failure. + pub balances: &'a [crate::wallet_ops::MintBalance], /// Whether real (non-testnut) mints are permitted; gates the mint-compat check. pub allow_real_mints: bool, /// The harness the OFFER asked for, read back from the relay (never from award params — the @@ -96,11 +98,13 @@ pub fn award_filters_for_offer<'a>( max_sats: u64, buyer_mint: &'a str, allow_real_mints: bool, + balances: &'a [crate::wallet_ops::MintBalance], ) -> AwardFilters<'a> { AwardFilters { offer_amount_sats: offer.amount_sats, max_sats, buyer_mint, + balances, allow_real_mints, requested_agent: offer.requested_agent.as_deref(), requested_harness_family: offer.requested_harness_family.as_deref(), @@ -420,6 +424,7 @@ pub fn unsatisfiable_capability_request( offer_amount_sats: 0, max_sats: 0, buyer_mint: "", + balances: &[], allow_real_mints: false, // Placeholder, like the money fields above: this helper consults only the request axes. payment_mode: PaymentMode::Sat, @@ -485,6 +490,42 @@ pub fn capability_park_reason(view: &JobView, filters: &AwardFilters) -> Option< )) } +/// Diagnose a seller list that would require hopping into a credit mint. +pub fn credit_hop_refusal(creq: Option<&str>, filters: &AwardFilters) -> Option { + let request = crate::gateway::creq::parse_creq(creq?).ok()?; + let listed: Vec = request.mints.iter().map(ToString::to_string).collect(); + if listed.is_empty() + || !listed + .iter() + .all(|mint| crate::mint_wire::is_nostr_scheme(mint)) + { + return None; + } + let source = crate::crossmint::select_source_mint( + filters.buyer_mint, + &listed, + filters.allow_real_mints, + filters.balances, + filters.offer_amount_sats, + ); + plan_payment(&source, &listed, filters.allow_real_mints) + .err() + .map(|e| e.to_string()) +} + +pub fn mint_park_reason(view: &JobView, filters: &AwardFilters) -> Option { + let candidates = crate::job_lifecycle::claims_at_deadline(view); + let live: Vec<_> = candidates.iter().filter(|c| c.live).collect(); + if live.is_empty() { + return None; + } + let reasons = live + .iter() + .map(|c| credit_hop_refusal(c.creq.as_deref(), filters)) + .collect::>>()?; + Some(reasons.join("; ")) +} + /// Whether a claim may be awarded a job that asked for a specific harness. /// /// No request ⇒ every claim passes. A request ⇒ the claim must ADVERTISE that harness. A claim @@ -513,7 +554,13 @@ pub enum NamedAwardRefused { 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 }, + Unpayable { + claim_id: String, + }, + CreditHop { + claim_id: String, + reason: String, + }, /// The job asked for a harness the named claim does not advertise — awarding it would buy work /// from a seller that never said it could do it this way. AgentMismatch { claim_id: String, requested: String }, @@ -537,7 +584,13 @@ impl std::fmt::Display for NamedAwardRefused { formatter, "award refused: claim {claim_id} is not payable (price/mint/creq incompatible — the buyer could not settle it)" ), - Self::AgentMismatch { claim_id, requested } => write!( + Self::CreditHop { claim_id, reason } => { + write!(formatter, "award refused: claim {claim_id}: {reason}") + } + Self::AgentMismatch { + claim_id, + requested, + } => write!( formatter, "award refused: job requested agent {requested:?}, which claim {claim_id} does not advertise" ), @@ -589,8 +642,21 @@ pub fn named_claim_awardable( if let Err(refusal) = claim_meets_capability_request(&claim.capability, filters) { return Err(NamedAwardRefused::Capability { claim_id: claim_id.to_owned(), refusal }); } - if !claim_is_settleable(claim.payment_mode, &view.job_id, claim.creq.as_deref(), filters) { - return Err(NamedAwardRefused::Unpayable { claim_id: claim_id.to_owned() }); + if !claim_is_settleable( + claim.payment_mode, + &view.job_id, + claim.creq.as_deref(), + filters, + ) { + if let Some(reason) = credit_hop_refusal(claim.creq.as_deref(), filters) { + return Err(NamedAwardRefused::CreditHop { + claim_id: claim_id.to_owned(), + reason, + }); + } + return Err(NamedAwardRefused::Unpayable { + claim_id: claim_id.to_owned(), + }); } Ok(()) } @@ -655,7 +721,14 @@ fn claim_is_settleable( // 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(); - plan_payment(filters.buyer_mint, &listed, filters.allow_real_mints).is_ok() + let source = crate::crossmint::select_source_mint( + filters.buyer_mint, + &listed, + filters.allow_real_mints, + filters.balances, + filters.offer_amount_sats, + ); + plan_payment(&source, &listed, filters.allow_real_mints).is_ok() } /// What [`award_with_reservation`] may do about a job, decided BEFORE any reserve, sign, or send. @@ -1662,6 +1735,7 @@ mod tests { offer_amount_sats: offer_amount, max_sats, buyer_mint: DEFAULT_MINT_URL, + balances: &[], allow_real_mints: false, requested_agent: None, requested_harness_family: None, @@ -1670,6 +1744,34 @@ mod tests { } } + #[test] + fn credits_first_extra_credit_is_awardable_on_manual_and_auto_paths() { + let credit = "nostr://npub10xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqpkge6d"; + let job = "a".repeat(64); + let view = view_with(&job, 10, vec![claim(&job, true, 10, &[credit.into()])]); + let rows = [crate::wallet_ops::MintBalance { + mint_url: credit.into(), + balance_sats: 10, + is_default: false, + configured: true, + }]; + let mut f = filters(10, 10); + f.allow_real_mints = true; + f.balances = &rows; + let selected = select_awardable_claim(&view, &f).expect("held extra credits pay direct"); + named_claim_awardable(&view, &selected, &f).unwrap(); + // An unavailable snapshot falls back to the default, and cannot hop to credits. + f.balances = &[]; + assert!(select_awardable_claim(&view, &f).is_none()); + let refusal = named_claim_awardable(&view, &selected, &f) + .unwrap_err() + .to_string(); + assert!( + refusal.contains("cannot receive") && refusal.contains(credit), + "{refusal}" + ); + } + // A live claim priced at the offer amount, quoting the buyer's default mint, is selected. #[test] fn select_picks_live_payable_claim() { @@ -4008,7 +4110,7 @@ mod tests { /// predicate, and the fix in both cases is to call the real thing rather than to test the copy /// harder. fn filters_from_offer<'a>(offer: &'a OfferView, max_sats: u64) -> AwardFilters<'a> { - award_filters_for_offer(offer, max_sats, DEFAULT_MINT_URL, false) + award_filters_for_offer(offer, max_sats, DEFAULT_MINT_URL, false, &[]) } // THE ACCEPTANCE TEST FOR #897, both axes through BOTH selection entry points. @@ -4694,7 +4796,7 @@ mod free_lane_tests { /// path that reached `plan_payment` with these would refuse, so a free award that succeeds here /// has provably not touched one. fn walletless_filters(offer: &OfferView) -> AwardFilters<'_> { - award_filters_for_offer(offer, 0, "", false) + award_filters_for_offer(offer, 0, "", false, &[]) } /// PROPERTY 1 — THE BOTH-ENDS RULE, at the award. All four combinations, in one test so no @@ -4735,7 +4837,7 @@ mod free_lane_tests { assert_eq!( select_awardable_claim( &view(priced.clone(), claim_view(PaymentMode::None, None)), - &award_filters_for_offer(&priced, 21, MINT, false) + &award_filters_for_offer(&priced, 21, MINT, false, &[]) ), None, "offer=absent / claim=none must REFUSE: a seller cannot make a priced job free" @@ -4745,8 +4847,11 @@ mod free_lane_tests { let paid_creq = build_seller_creq(JOB, 21, "sat", &[MINT.to_owned()], SELLER).expect("creq"); assert_eq!( select_awardable_claim( - &view(priced.clone(), claim_view(PaymentMode::Sat, Some(paid_creq))), - &award_filters_for_offer(&priced, 21, MINT, false) + &view( + priced.clone(), + claim_view(PaymentMode::Sat, Some(paid_creq)) + ), + &award_filters_for_offer(&priced, 21, MINT, false, &[]) ) .as_deref(), Some("c".repeat(64).as_str()), diff --git a/crates/maxplayer-core/src/buyer/mod.rs b/crates/maxplayer-core/src/buyer/mod.rs index 4d73507c4..0c0aafa15 100644 --- a/crates/maxplayer-core/src/buyer/mod.rs +++ b/crates/maxplayer-core/src/buyer/mod.rs @@ -914,6 +914,10 @@ async fn award(context: &BuyerContext, id: Value, params: Value) -> Response { }; } } + // Pinned-attempt resolution above can re-enter money_lock. From here on, + // keep selection, its balance snapshot, and reservation under one guard. + let _guard = context.money_lock.lock().await; + let balances = read_award_balances(context).await; let (award_amount, claim_id, send_relay, mut quoted_mints) = match &attempt { Some(attempt) => ( attempt.amount_sats, @@ -952,6 +956,7 @@ async fn award(context: &BuyerContext, id: Value, params: Value) -> Response { max_sats, context.home.config.default_mint(), context.home.config.allow_real_mints, + balances.as_deref().unwrap_or(&[]), ); // Manual award names the claim but applies the SAME hard filters as auto-award — @@ -984,14 +989,10 @@ async fn award(context: &BuyerContext, id: Value, params: Value) -> Response { } }; - // Serialize with collect: the reserve below reads a balance/spent snapshot that must not race - // a concurrent melt. Held across the whole chokepoint call (see the deadlock note above for - // why not earlier). - let _guard = context.money_lock.lock().await; - // Re-derive BOTH pinned-attempt gates from a fresh read under the guard. The reads above ran - // before the lock, and the wait to get here (view fetches, then the guard itself — unbounded - // behind a settle) is long enough for an attempt to be pinned by a concurrent path or for - // the deadline to cross. + // The guard already protects the shared selection/ceiling balance snapshot. + // Recheck both pinned-attempt gates under the guard. A concurrent path could + // have pinned an attempt before we acquired it, and the balance/view awaits + // can carry us across the deadline. if let Ok(Some(current)) = context.store.award_attempt(¶ms.job_id) { // A claim named by the caller must still be refused when an attempt pinned MEANWHILE // names another: silently resolving a claim the caller never sanctioned is the thing @@ -1028,7 +1029,13 @@ async fn award(context: &BuyerContext, id: Value, params: Value) -> Response { // not the list read before the guard. quoted_mints = attempt_quoted_mints(¤t); } - let ceiling = match award_ceiling(context, ¶ms.job_id, "ed_mints, award_amount).await { + let ceiling = match award_ceiling_with_read( + context, + ¶ms.job_id, + "ed_mints, + award_amount, + balances, + ) { Ok(ceiling) => ceiling, Err(error) => return Response::err(id, CODE_INTERNAL, error), }; @@ -1397,16 +1404,28 @@ async fn award_ceiling( job_id: &str, quoted_mints: &[String], amount_sats: u64, +) -> Result { + let read = read_award_balances(context).await; + award_ceiling_with_read(context, job_id, quoted_mints, amount_sats, read) +} + +async fn read_award_balances( + context: &BuyerContext, +) -> Result, String> { + context.wallet.balances().await.map_err(|e| e.to_string())? +} + +fn award_ceiling_with_read( + context: &BuyerContext, + job_id: &str, + quoted_mints: &[String], + amount_sats: u64, + read: Result, String>, ) -> Result { let pin = context .store .reservation_pin(job_id) - .map_err(|error| error.to_string())?; - let read = context - .wallet - .balances() - .await - .map_err(|error| error.to_string())?; + .map_err(|e| e.to_string())?; ceiling_from_read( context.home.config.default_mint(), context.home.config.allow_real_mints, @@ -1622,11 +1641,14 @@ async fn drive_auto_award( // THE SAME constructor the manual award path uses, so the two cannot apply different filters. // Both selection entry points then consult `claim_meets_capability_request`: // `select_awardable_claim` here, `named_claim_awardable` on the manual path. + let guard = context.money_lock.lock().await; + let balances = read_award_balances(context).await; let filters = lifecycle::award_filters_for_offer( offer, max_sats, context.home.config.default_mint(), context.home.config.allow_real_mints, + balances.as_deref().unwrap_or(&[]), ); // Built AFTER `filters` so the deadline park can name the capability request that refused @@ -1634,15 +1656,16 @@ async fn drive_auto_award( // the only thing that makes an actionable reason available here; the decision itself is // unchanged, and a job with no request parks with the wording it always did. if now_unix() as u64 > offer.deadline_unix { + drop(guard); // settle_intent_from_attempt acquires the same lock. // A pinned attempt past its deadline is NOT "no awardable claim appeared" — a claim // was selected and signed for. Reflect the ATTEMPT's truth on the intent instead of // a false park reason; the periodic sweep continues anything still unresolved. if settle_intent_from_attempt(context, &keys, job_id).await { return Ok(()); } - let reason = lifecycle::park_reason_deadline_passed( - lifecycle::capability_park_reason(&view, &filters).as_deref(), - ); + let diagnosis = lifecycle::capability_park_reason(&view, &filters) + .or_else(|| lifecycle::mint_park_reason(&view, &filters)); + let reason = lifecycle::park_reason_deadline_passed(diagnosis.as_deref()); crate::opline!("{}", auto_award_park_line(job_id, &reason)); let _ = context.store.mark_award_parked(job_id, &reason, now_unix()); return Ok(()); @@ -1650,10 +1673,18 @@ async fn drive_auto_award( if let Some(claim_id) = lifecycle::select_awardable_claim(&view, &filters) { let quoted_mints = claim_creq_mints(&view, &claim_id); - return finalize_auto_award(context, job_id, offer.amount_sats, claim_id, quoted_mints) - .await; + return finalize_auto_award( + context, + job_id, + offer.amount_sats, + claim_id, + quoted_mints, + balances, + ) + .await; } + drop(guard); // No awardable claim yet — re-check after a bounded interval (no tight spin on a // live-but-unpayable claim). The deadline check above bounds the total wait. tokio::time::sleep(AUTO_AWARD_POLL_INTERVAL).await; @@ -1685,11 +1716,11 @@ async fn finalize_auto_award( offer_amount: u64, claim_id: String, quoted_mints: Vec, + balances: Result, String>, ) -> Result<(), String> { - let _guard = context.money_lock.lock().await; - // Deadline TOCTOU re-check under the guard (same as the manual RPC): the caller's deadline - // gate ran before this lock, whose wait is unbounded behind a settle. Err keeps the intent - // pending — the deadline arm / the sweep resolves the pinned attempt by probe. + // Caller holds money_lock from the shared filter/ceiling snapshot through this await. + // Keep the pinned-attempt deadline check at the reserve/publish boundary. + // The caller holds the guard; Err leaves the intent pending for a probe. if let Ok(Some(current)) = context.store.award_attempt(job_id) { if resume_crossed_deadline(¤t, now_unix()) { return Err(format!( @@ -1698,7 +1729,7 @@ async fn finalize_auto_award( )); } } - let ceiling = award_ceiling(context, job_id, "ed_mints, offer_amount).await?; + let ceiling = award_ceiling_with_read(context, job_id, "ed_mints, offer_amount, balances)?; let home = context.home.clone(); let job = job_id.to_owned(); let publish_claim = claim_id.clone(); @@ -3541,10 +3572,50 @@ mod tests { // (the call accept seals through) picks from the same inputs. #[test] fn award_ceiling_mint_matches_the_accept_selection() { + let credit = "nostr://npub10xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqpkge6d"; let cases: Vec<(Vec, Vec, u64)> = vec![ - (vec![CEIL_EXTRA.into()], vec![mint_row(CEIL_DEFAULT, 500, true, true), mint_row(CEIL_EXTRA, 500, false, true)], 100), - (vec![CEIL_EXTRA.into()], vec![mint_row(CEIL_DEFAULT, 500, true, true)], 100), - (vec![CEIL_OTHER.into(), CEIL_EXTRA.into()], vec![mint_row(CEIL_OTHER, 50, false, true), mint_row(CEIL_EXTRA, 500, false, true)], 100), + ( + vec![CEIL_DEFAULT.into(), credit.into()], + vec![ + mint_row(CEIL_DEFAULT, 500, true, true), + mint_row(credit, 500, false, true), + ], + 100, + ), + ( + vec![credit.into()], + vec![mint_row(credit, 100, false, true)], + 100, + ), + ( + vec![credit.into(), CEIL_EXTRA.into()], + vec![ + mint_row(credit, 99, false, true), + mint_row(CEIL_EXTRA, 100, false, true), + ], + 100, + ), + ( + vec![CEIL_EXTRA.into()], + vec![ + mint_row(CEIL_DEFAULT, 500, true, true), + mint_row(CEIL_EXTRA, 500, false, true), + ], + 100, + ), + ( + vec![CEIL_EXTRA.into()], + vec![mint_row(CEIL_DEFAULT, 500, true, true)], + 100, + ), + ( + vec![CEIL_OTHER.into(), CEIL_EXTRA.into()], + vec![ + mint_row(CEIL_OTHER, 50, false, true), + mint_row(CEIL_EXTRA, 500, false, true), + ], + 100, + ), (vec![], vec![mint_row(CEIL_EXTRA, 500, false, true)], 100), ]; for (quoted, balances, amount) in cases { diff --git a/crates/maxplayer-core/src/crossmint.rs b/crates/maxplayer-core/src/crossmint.rs index 54c7c9672..1b718371b 100644 --- a/crates/maxplayer-core/src/crossmint.rs +++ b/crates/maxplayer-core/src/crossmint.rs @@ -87,7 +87,7 @@ impl PayPlan { /// 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 +/// Hop target selection is the FIRST fence-admitted non-credit entry of `accepted_mints` — 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. @@ -131,12 +131,14 @@ pub fn plan_payment( ))); } - // 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. + // No overlap. Credit mints cannot receive a hop. Preserve seller order among + // the remaining fence-admitted targets; refuse before award if none remain. let target = accepted_mints .iter() .zip(listed) - .find(|(raw, _)| home::mint_allowed(raw, allow_real_mints)) + .find(|(raw, _)| { + !crate::mint_wire::is_nostr_scheme(raw) && home::mint_allowed(raw, allow_real_mints) + }) .map(|(_, parsed)| parsed); match target { @@ -146,9 +148,9 @@ pub fn plan_payment( }), 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" + {accepted_mints:?} and no accepted non-credit mint passes the real-mint fence, so the \ + cross-mint hop has nowhere permitted to land; nostr:// credit mints cannot receive \ + a hop. Fund a listed mint directly or use a permitted https:// target" ))), } } @@ -158,10 +160,9 @@ pub fn plan_payment( /// the default (which would drain the default and pay a melt fee). Falls back to the configured /// default — today's behavior — when no held, accepted, fence-admissible mint covers the amount. /// -/// Deterministic preference: the FIRST entry of the seller's `accepted_mints`, in the seller's list -/// order, that (1) passes the real-mint fence and (2) shows a balance `>= amount_sats`. Returning an -/// accepted mint makes [`plan_payment`] plan a DIRECT payment from it (no hop); the seller's order is -/// their stated preference and keeps the choice stable across retries. +/// Deterministic preference: covering configured credits (`nostr://`) first, then +/// covering HTTPS mints, preserving seller order within each group. The fence +/// applies to both. Returning a listed mint makes [`plan_payment`] pay direct. /// /// Balance-awareness is ADVISORY and applied ONCE, here at accept. The result is sealed into the /// accept-bind and re-derived (not re-decided) at pay, so a later balance or config-default change @@ -175,14 +176,18 @@ pub(crate) fn select_source_mint( balances: &[crate::wallet_ops::MintBalance], amount_sats: u64, ) -> String { - accepted_mints - .iter() - .find(|accepted| { - let mint = accepted.as_str(); - home::mint_allowed(mint, allow_real_mints) && holds_at_least(balances, mint, amount_sats) - }) - .cloned() - .unwrap_or_else(|| config_default.to_owned()) + // Credits first; preserve the seller's order within each scheme. Never sum + // balances or spend a discovered-but-unconfigured wallet row. + for scheme in ["nostr://", "https://"] { + if let Some(mint) = accepted_mints.iter().find(|mint| { + mint.starts_with(scheme) + && home::mint_allowed(mint, allow_real_mints) + && holds_at_least(balances, mint, amount_sats) + }) { + return mint.clone(); + } + } + config_default.to_owned() } /// Whether configured `balances` shows at least `amount_sats` at `mint`, comparing normalized mint @@ -680,8 +685,58 @@ mod tests { ); let direct = plan_payment(nostr, &[nostr.to_owned()], true).expect("listed nostr mint"); assert!(!direct.is_hop(), "a listed nostr:// mint pays direct"); - let hop_to = plan_payment("https://buyer.example", &[nostr.to_owned()], true) - .expect("an https source may still hop"); - assert!(hop_to.is_hop()); + let refusal = plan_payment("https://buyer.example", &[nostr.to_owned()], true) + .expect_err("credits cannot receive a hop"); + assert!(refusal.to_string().contains(nostr)); + } + const CREDIT: &str = "nostr://npub10xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqpkge6d"; + + #[test] + fn credits_first_source_selection_matrix() { + let sats = "https://seller.example"; + let default = DEFAULT_MINT_URL; + for listed in [ + vec![sats.into(), CREDIT.into()], + vec![CREDIT.into(), sats.into()], + ] { + for (credit_sats, configured, allowed, expected) in [ + (100, true, true, CREDIT), + (99, true, true, sats), + (100, false, true, sats), + (100, true, false, default), + ] { + let mut credits = balance(CREDIT, credit_sats); + credits.configured = configured; + let rows = [balance(sats, 100), credits]; + assert_eq!( + select_source_mint(default, &listed, allowed, &rows, 100), + expected + ); + } + } + } + + #[test] + fn credits_first_hop_skips_credit_targets_in_every_position() { + let a = "https://a.example"; + let b = "https://b.example"; + for listed in [ + vec![CREDIT.into(), a.into(), b.into()], + vec![a.into(), CREDIT.into(), b.into()], + vec![a.into(), b.into(), CREDIT.into()], + ] { + let plan = plan_payment(DEFAULT_MINT_URL, &listed, true).unwrap(); + assert!(plan.is_hop()); + assert_eq!(plan.realized_mint().to_string(), a); + } + let listed = vec![CREDIT.into()]; + let error = plan_payment(DEFAULT_MINT_URL, &listed, true) + .unwrap_err() + .to_string(); + assert!( + error.contains(CREDIT) && error.contains("cannot receive"), + "{error}" + ); + assert!(!plan_payment(CREDIT, &listed, true).unwrap().is_hop()); } } diff --git a/crates/maxplayer-core/src/crossmint_hop.rs b/crates/maxplayer-core/src/crossmint_hop.rs index c38dbe642..a44316f78 100644 --- a/crates/maxplayer-core/src/crossmint_hop.rs +++ b/crates/maxplayer-core/src/crossmint_hop.rs @@ -1489,6 +1489,37 @@ mod tests { } } + #[test] + fn credits_first_https_hop_settles_once_with_credit_listed_first() { + let credit = "nostr://npub10xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqpkge6d"; + let target = "https://seller.example"; + let plan = crate::crossmint::plan_payment( + "https://buyer.example", + &[credit.into(), target.into()], + true, + ) + .unwrap(); + assert_eq!(plan.realized_mint().to_string(), target); + let mut pairing = journal("credits-first-hop"); + pairing.source_mint = plan.source_mint().to_string(); + pairing.target_mint = plan.realized_mint().to_string(); + let store = MemJournal::default(); + let world = MintWorld::shared(); + let mut effects = FakeMints { + world: Rc::clone(&world), + }; + assert_eq!( + run_hop(&store, &mut effects, &pairing).unwrap().minted_sats, + 100 + ); + assert_eq!( + run_hop(&store, &mut effects, &pairing).unwrap().minted_sats, + 100 + ); + assert_eq!(world.borrow().melts.len(), 1); + assert_eq!(world.borrow().mints.len(), 1); + } + #[test] fn a_clean_hop_melts_once_mints_once_and_journals_the_pairing_before_the_melt() { let store = MemJournal::default(); diff --git a/crates/maxplayer-core/src/job_lifecycle.rs b/crates/maxplayer-core/src/job_lifecycle.rs index b746fa4c8..8adc5a3ed 100644 --- a/crates/maxplayer-core/src/job_lifecycle.rs +++ b/crates/maxplayer-core/src/job_lifecycle.rs @@ -1584,19 +1584,33 @@ pub async fn accept_claim_async( // re-bind a corrected result) is tracked separately; until then one settlement per job. let existing_bind = load_accepted_bind(home, &request.job_id)?; assert_single_settlement(existing_bind.as_ref(), &request.job_id, &result.result_id)?; - if let Some(mut bind) = existing_bind.filter(|b| b.private_evidence.is_some()) { + if let Some(mut bind) = existing_bind { // Never re-plan funding or replace immutable evidence on a same-result retry. if bind.claim_id != request.claim_id || bind.private_evidence != result.private_evidence { return Err(JobLifecycleError::Input("accepted private evidence changed".into())); } validate_private_bind(home, &bind)?; if bind.accept_event_id.is_empty() { - let evidence = bind.private_evidence.as_ref().unwrap(); - let policy = crate::private_content::runtime::Policy::for_evidence(home, evidence) - .map_err(|e| JobLifecycleError::Input(e.to_string()))?; - let draft = accept_draft(&bind.job_id, &bind.claim_id, &keys.public_key().to_hex(), &bind.seller_pubkey); - let draft = crate::private_content::carriers::project(&evidence.offer, &draft, Some(&evidence.award), None, &policy.host) - .map_err(|e| JobLifecycleError::Input(e.to_string()))?; + let draft = accept_draft( + &bind.job_id, + &bind.claim_id, + &keys.public_key().to_hex(), + &bind.seller_pubkey, + ); + let draft = if let Some(evidence) = bind.private_evidence.as_ref() { + let policy = crate::private_content::runtime::Policy::for_evidence(home, evidence) + .map_err(|e| JobLifecycleError::Input(e.to_string()))?; + crate::private_content::carriers::project( + &evidence.offer, + &draft, + Some(&evidence.award), + None, + &policy.host, + ) + .map_err(|e| JobLifecycleError::Input(e.to_string()))? + } else { + draft + }; bind.accept_event_id = publish_draft_async(home, &keys, &draft).await?; bind.accepted_at = now_unix(); write_accepted_bind(home, &bind)?; @@ -3983,6 +3997,176 @@ mod tests { use super::*; use crate::home; + #[tokio::test(flavor = "multi_thread")] + async fn credits_first_same_result_reaccept_keeps_mints_and_attempt_id() { + use crate::payment::{ + DeliveryIntegrityHash, JobHash, JobId, PaymentKey, PaymentTerms, ResultId, + }; + use cashu::secret::Secret; + use cashu::{Amount, CurrencyUnit, Id, Proof, State}; + use cdk::wallet::types::ProofInfo; + use nostr_relay_builder::prelude::{LocalRelay, RelayBuilder}; + use nostr_sdk::prelude::*; + let relay = LocalRelay::new(RelayBuilder::default()); + relay.run().await.unwrap(); + let dir = tempfile::tempdir().unwrap(); + let mut home = home::bootstrap(dir.path()).unwrap(); + home.config.relay_url = relay.url().await.to_string(); + home.config.allow_real_mints = true; + home.config.review.buyer_delivery = false; + let lightning = "https://seller.example"; + let credit = "nostr://npub10xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqpkge6d"; + home.config.accepted_mints = vec![lightning.into()]; + home.config.extra_mints = vec![credit.into()]; + let buyer = buyer_keys(&home).unwrap(); + let seller = Keys::generate(); + let sign = |draft: &EventDraft, keys: &Keys| { + gateway::nostr::event_builder(draft) + .unwrap() + .sign_with_keys(keys) + .unwrap() + }; + let mut offer = OfferDraft::new( + "credits-first reaccept", + "text/plain", + 10, + Timestamp::now().as_secs() + 600, + seller.public_key().to_hex(), + ); + offer.accepts_delivery = vec!["inline".into()]; + let offer = sign(&offer.to_event_draft(), &buyer); + let job = offer.id.to_hex(); + let listed = vec![lightning.into(), credit.into()]; + let creq = gateway::creq::build_seller_creq( + &job, + 10, + "sat", + &listed, + &seller.public_key().to_hex(), + ) + .unwrap(); + let claim = sign( + &gateway::claim_draft( + &job, + &buyer.public_key().to_hex(), + &seller.public_key().to_hex(), + gateway::ClaimPayment::Sat(&creq), + &[], + &Default::default(), + ), + &seller, + ); + let answer = "accepted once"; + let hash = crate::receipt::result_content_hash_hex(answer); + let preimage = crate::receipt::ReceiptPreimage { + protocol: crate::receipt::ReceiptProtocol::V1, + job_hash: job_hash_for_offer(&job, "credits-first reaccept", 10), + offer_id: job.clone(), + amount: 10, + unit: "sat".into(), + buyer_pubkey: buyer.public_key().to_hex(), + seller_pubkey: seller.public_key().to_hex(), + delivery_integrity_hash: hash, + delivery_kind: "inline".into(), + exec_metadata_commitment: "none".into(), + creq_hash: Some(gateway::creq_hash_hex(&creq)), + }; + let sig = seller + .sign_schnorr(&nostr_sdk::secp256k1::Message::from_digest( + preimage.digest_bytes(), + )) + .to_string(); + let result = sign( + &gateway::inline_result_draft( + &job, + &buyer.public_key().to_hex(), + "text/plain", + 10, + &preimage.job_hash, + &sig, + answer, + &[], + ), + &seller, + ); + let client = Client::new(buyer.clone()); + client.add_relay(&home.config.relay_url).await.unwrap(); + client.connect().await; + client.wait_for_connection(Duration::from_secs(5)).await; + for event in [&offer, &claim, &result] { + assert!(!client.send_event(event).await.unwrap().success.is_empty()); + } + let request = || AcceptClaimRequest { + job_id: job.clone(), + claim_id: claim.id.to_hex(), + result_id: Some(result.id.to_hex()), + }; + let first = accept_claim_async(&home, request()).await.unwrap().bind; + assert_eq!(first.funding_mint.as_deref(), Some(lightning)); + let wallet = crate::buyer_fund::open_wallet_at_mint_async(&home, credit) + .await + .unwrap(); + let proof = Proof::new( + Amount::from(10), + "009a1f293253e41e".parse::().unwrap(), + Secret::new("credits-first-reaccept-local-balance"), + cashu::SecretKey::generate().public_key(), + ); + wallet + .localstore + .update_proofs( + vec![ + ProofInfo::new( + proof, + credit.parse().unwrap(), + State::Unspent, + CurrencyUnit::Sat, + ) + .unwrap(), + ], + vec![], + ) + .await + .unwrap(); + let rows = crate::wallet_ops::balances_async(&home).await.unwrap(); + assert_eq!( + crate::crossmint::select_source_mint(lightning, &listed, true, &rows, 10), + credit, + "control: fresh selection changes after credits arrive" + ); + let retry = accept_claim_async(&home, request()).await.unwrap().bind; + assert_eq!(retry.funding_mint, first.funding_mint); + assert_eq!(retry.delivery_mint, first.delivery_mint); + let attempt = |bind: &AcceptedBind| { + let plan = crate::crossmint::plan_payment( + bind.funding_mint.as_deref().unwrap(), + &bind.accepted_mints, + true, + ) + .unwrap(); + let terms = PaymentTerms::new( + plan.realized_mint().clone(), + Amount::from(bind.amount_sats), + CurrencyUnit::Sat, + seller.public_key(), + format!("02{}", seller.public_key().to_hex()) + .parse() + .unwrap(), + ); + PaymentKey::new( + JobId::new(&bind.job_id).unwrap(), + ResultId::new(&bind.result_id).unwrap(), + DeliveryIntegrityHash::from_hex(&bind.commit_oid).unwrap(), + JobHash::from_hex(&bind.job_hash).unwrap(), + &terms, + bind.creq_hash.clone(), + ) + .attempt_id() + }; + assert_eq!(attempt(&first), attempt(&retry)); + client.disconnect().await; + } + // ---- #1076 review: accept seals the mint a LIVE reservation holds --------------------------- fn seed_row(mint_url: &str, sats: u64, is_default: bool) -> crate::wallet_ops::MintBalance { @@ -7972,6 +8156,50 @@ mod private_flow_tests { client.disconnect().await; } + #[test] + fn credits_first_private_mixed_claim_is_visible_and_awardable() { + let approved = "https://testnut.cashu.space"; + let (e, _, buyer, policy) = crate::private_content::evidence::inline_fixture_with_mints( + true, + true, + 2_000_000_000, + vec![approved.into(), "https://unknown.example".into()], + ); + let dir = tempfile::tempdir().unwrap(); + let mut home = home::bootstrap(dir.path()).unwrap(); + home.config.accepted_mints = vec![approved.into()]; + home.config.privacy.service_pubkey = Some(policy.service); + home.config.privacy.git_base = Some(policy.host.git_prefix); + let mut ctx = ContentContext::open(&home, &buyer.public_key().to_hex()).unwrap(); + for raw in [e.task_envelope.as_ref(), e.answer_envelope.as_ref()] + .into_iter() + .flatten() + { + ctx.stage(&PreparedContent::decode(raw).unwrap(), 1_999_999_900) + .unwrap(); + } + let view = private_view_from_events( + &home, + &mut ctx, + &e.offer, + vec![e.claim.clone()], + vec![e.award.clone()], + vec![e.result.clone()], + 1_999_999_900, + ) + .unwrap(); + assert_eq!(view.claims.len(), 1, "#1069 mixed claim must be visible"); + let f = crate::buyer::lifecycle::award_filters_for_offer( + view.offer.as_ref().unwrap(), + 10, + approved, + true, + &[], + ); + let claim = crate::buyer::lifecycle::select_awardable_claim(&view, &f).unwrap(); + crate::buyer::lifecycle::named_claim_awardable(&view, &claim, &f).unwrap(); + } + #[tokio::test] async fn private_content_view_and_bind_remain_exact_across_restart_and_payment_refusal() { let (evidence, request, buyer, policy) = inline_fixture(); diff --git a/crates/maxplayer-core/src/private_content/evidence.rs b/crates/maxplayer-core/src/private_content/evidence.rs index 81f8f6cc2..64f350d2c 100644 --- a/crates/maxplayer-core/src/private_content/evidence.rs +++ b/crates/maxplayer-core/src/private_content/evidence.rs @@ -126,6 +126,21 @@ impl PrivateEvidence { #[cfg(feature = "wallet")] impl PrivateEvidence { + /// Check the derived delivery/receipt mint, not request.realized_mint (which + /// carries the funding source and may legitimately be outside the creq). + pub(crate) fn validate_realized_mint(&self, mint: &str, policy: &Policy) -> Result<()> { + let claim = wire::validate_private(&self.claim, &policy.host)?; + let request = crate::gateway::creq::parse_creq(claim.required("creq")?) + .map_err(|_| Error("invalid signed claim invoice"))?; + policy.host.well_formed_mint(mint)?; + let realized: cdk::mint_url::MintUrl = + mint.parse().map_err(|_| Error("invalid realized mint"))?; + if !request.mints.contains(&realized) { + return Err(Error("realized mint is outside signed claim creq")); + } + Ok(()) + } + /// The sealed bind is a projection, not a second source of authority. Check /// every spend/artifact field against the original signed chain on each use. pub fn validate_request( @@ -239,6 +254,25 @@ pub(crate) fn inline_fixture_with_deadline( crate::authorize_pay::AuthorizePayRequest, nostr_sdk::Keys, Policy, +) { + inline_fixture_with_mints( + targeted, + paid, + deadline, + vec!["https://testnut.cashu.space".into()], + ) +} +#[cfg(all(test, feature = "wallet"))] +pub(crate) fn inline_fixture_with_mints( + targeted: bool, + paid: bool, + deadline: u64, + mints: Vec, +) -> ( + PrivateEvidence, + crate::authorize_pay::AuthorizePayRequest, + nostr_sdk::Keys, + Policy, ) { let amount = if paid { 10 } else { 0 }; let payment_mode = if paid { @@ -260,7 +294,7 @@ pub(crate) fn inline_fixture_with_deadline( service: service.clone(), host: wire::HostPolicy { git_prefix: "https://git.example/git/".into(), - accepted_mints: vec!["https://testnut.cashu.space".into()], + accepted_mints: mints, }, }; let prepared = builders::prepare_offer( @@ -493,4 +527,20 @@ mod tests { .is_err() ); } + #[test] + fn credits_first_realized_mint_must_be_in_signed_creq() { + let (evidence, request, buyer, mut policy) = inline_fixture_with_payment(true, true); + // Config membership is irrelevant; signed seller membership is authority. + policy.host.accepted_mints.clear(); + evidence + .validate_request(&request, &buyer.public_key().to_hex(), &policy) + .unwrap(); + evidence + .validate_realized_mint(&request.accepted_mints[0], &policy) + .unwrap(); + let error = evidence + .validate_realized_mint("https://outside.example", &policy) + .unwrap_err(); + assert!(error.to_string().contains("outside signed claim")); + } } diff --git a/crates/maxplayer-core/src/private_content/invoice.rs b/crates/maxplayer-core/src/private_content/invoice.rs index 9d8f55e3a..f7d221ffa 100644 --- a/crates/maxplayer-core/src/private_content/invoice.rs +++ b/crates/maxplayer-core/src/private_content/invoice.rs @@ -114,7 +114,7 @@ pub fn validate( let mut seen = BTreeSet::new(); for mint in mints { let mint = text(mint)?; - host.mint(mint)?; + host.well_formed_mint(mint)?; if !seen.insert(mint) { return Err(Error("duplicate invoice mint")); } diff --git a/crates/maxplayer-core/src/private_content/tests.rs b/crates/maxplayer-core/src/private_content/tests.rs index 1d68c9367..fa9e688b3 100644 --- a/crates/maxplayer-core/src/private_content/tests.rs +++ b/crates/maxplayer-core/src/private_content/tests.rs @@ -1672,7 +1672,7 @@ fn shared_quotas_allow_more_than_thousand_commits_and_files() { #[cfg(feature = "wallet")] #[test] -fn private_invoice_accepts_mixed_https_nostr_and_preserves_membership_guard() { +fn private_invoice_accepts_well_formed_mixed_and_unknown_mints() { use cashu::nuts::nut18::PaymentRequest; use nostr_sdk::prelude::{Nip19Profile, ToBech32}; let offer = "ab".repeat(32); @@ -1684,6 +1684,11 @@ fn private_invoice_accepts_mixed_https_nostr_and_preserves_membership_guard() { for mints in [ vec!["https://mint.example".to_owned(), nostr_mint.clone()], vec![nostr_mint.clone()], + vec!["https://unknown.example".into()], + vec![ + "https://mint.example".into(), + "https://unknown.example".into(), + ], ] { let request: PaymentRequest = serde_json::from_value(serde_json::json!({ "i":offer,"a":100,"u":"sat","s":true,"m":mints,"d":null, @@ -1692,17 +1697,18 @@ fn private_invoice_accepts_mixed_https_nostr_and_preserves_membership_guard() { .unwrap(); for raw in [request.to_string(), request.to_bech32_string().unwrap()] { invoice::validate(&raw, &offer, 100, &seller.to_hex(), &policy).unwrap(); - // A only: the old all-entry membership guard deliberately remains. - assert!(invoice::validate(&raw, &offer, 100, &seller.to_hex(), &host()).is_err()); + invoice::validate(&raw, &offer, 100, &seller.to_hex(), &host()).unwrap(); } } for mints in [ vec![nostr_mint.clone(), nostr_mint], - vec!["https://unknown.example".into()], - vec![ - "https://unknown.example".into(), - "https://mint.example".into(), - ], + vec![], + (0..33) + .map(|i| format!("https://mint{i}.example")) + .collect(), + vec!["http://insecure.example".into()], + vec!["nostr://not-an-npub".into()], + vec!["https://user@mint.example".into()], ] { let request: PaymentRequest = serde_json::from_value(serde_json::json!({ "i":offer,"a":100,"u":"sat","s":true,"m":mints,"d":null, @@ -1718,10 +1724,9 @@ fn private_invoice_accepts_mixed_https_nostr_and_preserves_membership_guard() { #[cfg(feature = "wallet")] #[test] -fn private_invoice_all_mints_guard_blocks_unapproved_hop_target() { - // Evidence for deferring the proposed at-least-one rule: when balances do - // not cover the invoice, planning can hop to the seller's first mint, not - // necessarily the entry approved by the private checker. +fn private_invoice_all_mints_guard_now_allows_seller_listed_hop_target() { + // Deliberate trust widening: private jobs now follow the public rule, + // including hopping to a seller-listed mint the checker did not configure. let listed = vec![ "https://unapproved.example".into(), "https://mint.example".into(), @@ -1736,5 +1741,13 @@ fn private_invoice_all_mints_guard_blocks_unapproved_hop_target() { let plan = crate::crossmint::plan_payment(&source, &listed, true).unwrap(); assert_eq!(plan.realized_mint().to_string(), listed[0]); assert!(host().mint(&listed[1]).is_ok()); - assert!(host().mint(&plan.realized_mint().to_string()).is_err()); + host() + .well_formed_mint(&plan.realized_mint().to_string()) + .unwrap(); + let seller = keys(2).public_key(); + let offer = "ab".repeat(32); + let raw = + crate::gateway::creq::build_seller_creq(&offer, 100, "sat", &listed, &seller.to_hex()) + .unwrap(); + invoice::validate(&raw, &offer, 100, &seller.to_hex(), &host()).unwrap(); } diff --git a/crates/maxplayer-mint/Cargo.lock b/crates/maxplayer-mint/Cargo.lock index ee3c38f9c..5d984eb58 100644 --- a/crates/maxplayer-mint/Cargo.lock +++ b/crates/maxplayer-mint/Cargo.lock @@ -146,6 +146,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + [[package]] name = "async-trait" version = "0.1.92" @@ -182,7 +193,7 @@ dependencies = [ "tokio", "tokio-rustls", "tokio-socks", - "tokio-tungstenite", + "tokio-tungstenite 0.26.2", "url", "wasm-bindgen", "web-sys", @@ -213,11 +224,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", + "base64 0.22.1", "bytes", + "form_urlencoded", "futures-util", "http", "http-body", "http-body-util", + "hyper", + "hyper-util", "itoa", "matchit", "memchr", @@ -225,10 +240,17 @@ dependencies = [ "percent-encoding", "pin-project-lite", "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", "sync_wrapper", + "tokio", + "tokio-tungstenite 0.29.0", "tower", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -247,6 +269,7 @@ dependencies = [ "sync_wrapper", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -561,6 +584,27 @@ dependencies = [ "zeroize", ] +[[package]] +name = "cdk-axum" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "197fd2d08800212a3bdbf0e868836025ecd76a42765af346ba7a79cc76c41a06" +dependencies = [ + "anyhow", + "async-trait", + "axum", + "cdk", + "futures", + "moka", + "paste", + "serde", + "serde_json", + "sha2 0.10.9", + "tokio", + "tracing", + "uuid", +] + [[package]] name = "cdk-common" version = "0.17.2" @@ -595,6 +639,29 @@ dependencies = [ "web-time", ] +[[package]] +name = "cdk-fake-wallet" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8a564a9ebede8ecd625fdaaab25540c2b79f28f6067aa9c7497cf9f5c6af31" +dependencies = [ + "async-trait", + "bitcoin", + "cdk-common", + "futures", + "lightning", + "lightning-invoice", + "serde", + "serde_json", + "thiserror 2.0.21", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "uuid", + "web-time", +] + [[package]] name = "cdk-http-client" version = "0.17.2" @@ -609,7 +676,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.21", - "tokio-tungstenite", + "tokio-tungstenite 0.26.2", "tracing", "url", "wasm-bindgen", @@ -907,6 +974,30 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + [[package]] name = "crunchy" version = "0.2.4" @@ -1083,6 +1174,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -2045,19 +2156,24 @@ dependencies = [ "anyhow", "bip39", "cdk", + "cdk-axum", "cdk-common", + "cdk-fake-wallet", "cdk-sqlite", "futures-util", "getrandom 0.3.4", + "hyper-util", "maxplayer-core", "nostr-relay-builder", "nostr-sdk", + "rcgen", "serde", "serde_json", "sha2 0.10.9", "tempfile", "tokio", - "tokio-tungstenite", + "tokio-rustls", + "tokio-tungstenite 0.26.2", "toml", "uuid", ] @@ -2114,6 +2230,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moka" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4293f18e7567a1caf3c584855554377025c65e0aa445344d04171f5ad63d19b9" +dependencies = [ + "async-lock", + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "event-listener", + "futures-util", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + [[package]] name = "multimap" version = "0.10.1" @@ -2308,6 +2444,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2736,6 +2878,19 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -3098,6 +3253,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -3328,6 +3494,12 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + [[package]] name = "tempfile" version = "3.27.0" @@ -3510,10 +3682,22 @@ dependencies = [ "rustls-pki-types", "tokio", "tokio-rustls", - "tungstenite", + "tungstenite 0.26.2", "webpki-roots 0.26.11", ] +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.29.0", +] + [[package]] name = "tokio-util" version = "0.7.19" @@ -3779,6 +3963,22 @@ dependencies = [ "utf-8", ] +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "sha1", + "thiserror 2.0.21", +] + [[package]] name = "typenum" version = "1.20.1" @@ -4184,6 +4384,15 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/crates/maxplayer-mint/Cargo.toml b/crates/maxplayer-mint/Cargo.toml index 9511c53a3..a7708bf6e 100644 --- a/crates/maxplayer-mint/Cargo.toml +++ b/crates/maxplayer-mint/Cargo.toml @@ -50,3 +50,9 @@ tempfile = "3" tokio-tungstenite = "=0.26.2" futures-util = "0.3" uuid = { version = "1", features = ["v7"] } +# Credits-first integration: real HTTPS CDK mint APIs, simulated Lightning only. +cdk-axum = { version = "=0.17.2", default-features = false } +cdk-fake-wallet = "=0.17.2" +hyper-util = { version = "0.1", features = ["server-auto", "service", "tokio"] } +rcgen = "0.13" +tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] } diff --git a/crates/maxplayer-mint/tests/sidecar.rs b/crates/maxplayer-mint/tests/sidecar.rs index 5e63aad18..7acb39b40 100644 --- a/crates/maxplayer-mint/tests/sidecar.rs +++ b/crates/maxplayer-mint/tests/sidecar.rs @@ -998,3 +998,346 @@ async fn second_run_process_exits_while_first_serves() { .await .unwrap(); } + +/// Pre-upgrade safety probe: the old planner selects a credit target. A valid +/// inline cosignature ensures this reaches quote planning, not an earlier gate. +#[tokio::test(flavor = "multi_thread")] +async fn credits_first_preupgrade_bind_replans_without_spend_or_journal() { + use maxplayer_core::{ + authorize_pay::{AuthorizePayRequest, JobClass, authorize_pay_async}, + budget::BudgetGate, + home, + receipt::{ReceiptPreimage, ReceiptProtocol, result_content_hash_hex}, + }; + use nostr_sdk::secp256k1::Message; + let h = harness(1, 100).await; + // Pin the old target failure with the actual sidecar and CDK quote call. + let target_wallet = h.wallet().await; + let quote_error = target_wallet + .mint_quote( + cdk::nuts::PaymentMethod::BOLT11, + Some(Amount::from(100)), + None, + None, + ) + .await + .unwrap_err() + .to_string(); + assert!(quote_error.contains("unsupported"), "{quote_error}"); + assert_eq!(target_wallet.total_balance().await.unwrap(), Amount::ZERO); + let dir = tempfile::tempdir().unwrap(); + let mut home = home::bootstrap(dir.path()).unwrap(); + home.config.allow_real_mints = true; + home.config.relay_url = h.relay_urls[0].clone(); + // An unopened local source: any source HTTP access would fail, proving the + // reported target-quote refusal precedes even a source melt quote. + let source = "https://127.0.0.1:1"; + home.config.accepted_mints = vec![source.into()]; + let keys = Keys::parse(&home::read_secret_key_hex(&home).unwrap()).unwrap(); + let answer = "credits-first safety probe"; + let hash = result_content_hash_hex(answer); + let preimage = ReceiptPreimage { + protocol: ReceiptProtocol::V1, + job_hash: "bb".repeat(32), + offer_id: "aa".repeat(32), + amount: 100, + unit: "sat".into(), + buyer_pubkey: keys.public_key().to_hex(), + seller_pubkey: keys.public_key().to_hex(), + delivery_integrity_hash: hash.clone(), + delivery_kind: "inline".into(), + exec_metadata_commitment: "none".into(), + creq_hash: None, + }; + let request = AuthorizePayRequest { + private_evidence: None, + job_id: preimage.offer_id.clone(), + result_id: "cc".repeat(32), + job_class: JobClass::FromScratch, + delivery_integrity_hash: hash.clone(), + job_hash: preimage.job_hash.clone(), + seller_pubkey: preimage.seller_pubkey.clone(), + amount_sats: 100, + repo: String::new(), + branch: String::new(), + commit_oid: hash, + inline_answer: Some(answer.into()), + seller_signature: keys + .sign_schnorr(&Message::from_digest(preimage.digest_bytes())) + .to_string(), + creq_hash: None, + accepted_mints: vec![h.url.clone(), "https://127.0.0.1:2".into()], + realized_mint: Some(source.into()), + contribution: None, + payment_mode: maxplayer_core::gateway::PaymentMode::Sat, + }; + let mut gate = BudgetGate::from_home(&home).unwrap(); + let error = authorize_pay_async(&home, &mut gate, request) + .await + .unwrap_err() + .to_string(); + assert!(error.contains("target mint quote"), "{error}"); + if std::env::var_os("MAXPLAYER_TEST_PREUPGRADE").is_some() { + // Run with the target skip reverted to prove the upgrade assumption. + assert!(error.contains("unsupported"), "{error}"); + } else { + assert!( + error.contains("127.0.0.1:2"), + "must reach the HTTPS target: {error}" + ); + assert!( + !error.contains("unsupported"), + "must not quote the credit target: {error}" + ); + } + assert_eq!(gate.spent(), 0); + for path in ["payment-journal", "crossmint-journal", "spent.jsonl"] { + assert!(!home.root.join(path).exists(), "unexpected {path}"); + } + eprintln!("UPGRADE SAFETY: {error}; spent=0, no payment/hop journals or budget ledger"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn credits_first_real_sidecar_direct_seller_locked_payment() { + use cdk::nuts::SpendingConditions; + use maxplayer_core::{ + crossmint::plan_payment, payment::PaymentTerms, payment_wallet::CdkSellerReceive, + }; + let h = harness(1, 100).await; + let buyer = h.wallet().await; + let token = Token::new( + h.url.parse().unwrap(), + fund(&h.mint, 100).await, + None, + CurrencyUnit::Sat, + ); + buyer + .receive(&token.to_string(), ReceiveOptions::default()) + .await + .unwrap(); + let seller = Keys::generate(); + let seller_key: cdk::nuts::SecretKey = seller.secret_key().to_secret_hex().parse().unwrap(); + // Lightning-first seller ordering must not obstruct direct credits. + let listed = vec!["https://lightning.example".into(), h.url.clone()]; + let plan = plan_payment(&h.url, &listed, true).unwrap(); + assert!(!plan.is_hop()); + let terms = PaymentTerms::new( + plan.realized_mint().clone(), + Amount::from(10), + CurrencyUnit::Sat, + seller.public_key(), + seller_key.public_key(), + ); + let sent = buyer + .prepare_send( + Amount::from(10), + SendOptions { + conditions: Some(SpendingConditions::new_p2pk(seller_key.public_key(), None)), + ..SendOptions::default() + }, + ) + .await + .unwrap() + .confirm(None) + .await + .unwrap(); + let seller_wallet = h.wallet().await; + let receive = CdkSellerReceive::new(&seller_wallet, seller_key); + let accepted = listed.iter().map(|m| m.parse().unwrap()).collect(); + assert_eq!( + receive + .receive(&sent, &terms, &accepted, plan.realized_mint()) + .await + .unwrap(), + Amount::from(10) + ); + assert_eq!(buyer.total_balance().await.unwrap(), Amount::from(90)); + assert_eq!( + seller_wallet.total_balance().await.unwrap(), + Amount::from(10) + ); + assert!( + receive + .receive(&sent, &terms, &accepted, plan.realized_mint()) + .await + .is_err(), + "never redeem twice" + ); +} + +#[path = "support/https_mint.rs"] +mod https_mint; + +/// Child process confines SSL_CERT_FILE to this fixture; no process-global TLS +/// configuration races with other tests. The actual sidecar and both HTTPS mints +/// stay alive in the parent's Tokio runtime. +#[tokio::test(flavor = "multi_thread")] +async fn credits_first_real_sidecar_and_https_hop() { + use cdk::wallet::types::ProofInfo; + use maxplayer_core::{buyer_fund, home}; + let credits = harness(1, 100).await; + let source = https_mint::start().await; + let target = https_mint::start().await; + let dir = tempfile::tempdir().unwrap(); + let mut home = home::bootstrap(dir.path()).unwrap(); + home::save_config(&mut home, |c| { + c.accepted_mints = vec![source.url.clone()]; + c.extra_mints = vec![credits.url.clone()]; + c.relay_url = credits.relay_urls[0].clone(); + c.allow_real_mints = true; + c.per_job_budget_sats = 1_000; + }) + .unwrap(); + // Authentic CDK proofs. Insert the issued funding into the buyer DB, without + // requiring a parent-process TLS override just to receive test funding. + let wallet = buyer_fund::open_wallet_at_mint_async(&home, &source.url) + .await + .unwrap(); + let mut rows = Vec::new(); + for (mint, url, amount) in [ + (&source.mint, &source.url, 256), + (&credits.mint, &credits.url, 5), + ] { + for proof in fund(mint, amount).await { + rows.push( + ProofInfo::new( + proof, + url.parse().unwrap(), + State::Unspent, + CurrencyUnit::Sat, + ) + .unwrap(), + ); + } + } + wallet.localstore.update_proofs(rows, vec![]).await.unwrap(); + let ca = dir.path().join("fixture-ca.pem"); + std::fs::write(&ca, format!("{}{}", source.certificate, target.certificate)).unwrap(); + let mut child = std::process::Command::new(std::env::current_exe().unwrap()); + child + .args([ + "--ignored", + "--exact", + "credits_first_https_hop_child", + "--nocapture", + ]) + .env("CREDITS_FIRST_TEST_HOME", dir.path()) + .env("CREDITS_FIRST_TEST_TARGET", &target.url) + .env("SSL_CERT_FILE", &ca) + .env("NO_PROXY", "127.0.0.1,localhost"); + let output = tokio::task::spawn_blocking(move || child.output().unwrap()) + .await + .unwrap(); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "invoked only by the HTTPS fixture parent with isolated TLS roots"] +async fn credits_first_https_hop_child() { + use maxplayer_core::{ + authorize_pay::{AuthorizePayRequest, JobClass, authorize_pay_async}, + budget::BudgetGate, + home, + receipt::{ReceiptPreimage, ReceiptProtocol, result_content_hash_hex}, + wallet_ops, + }; + use nostr_sdk::secp256k1::Message; + let root = std::env::var("CREDITS_FIRST_TEST_HOME").expect("fixture parent required"); + let target = std::env::var("CREDITS_FIRST_TEST_TARGET").unwrap(); + let home = home::bootstrap(root).unwrap(); + let source = home.config.default_mint().to_owned(); + let credit = home.config.extra_mints[0].clone(); + let keys = Keys::parse(&home::read_secret_key_hex(&home).unwrap()).unwrap(); + let answer = "credits-first actual HTTPS hop"; + let hash = result_content_hash_hex(answer); + let preimage = ReceiptPreimage { + protocol: ReceiptProtocol::V1, + job_hash: "bb".repeat(32), + offer_id: "aa".repeat(32), + amount: 100, + unit: "sat".into(), + buyer_pubkey: keys.public_key().to_hex(), + seller_pubkey: keys.public_key().to_hex(), + delivery_integrity_hash: hash.clone(), + delivery_kind: "inline".into(), + exec_metadata_commitment: "none".into(), + creq_hash: None, + }; + let request = AuthorizePayRequest { + private_evidence: None, + job_id: preimage.offer_id.clone(), + result_id: "cc".repeat(32), + job_class: JobClass::FromScratch, + delivery_integrity_hash: hash.clone(), + job_hash: preimage.job_hash.clone(), + seller_pubkey: preimage.seller_pubkey.clone(), + amount_sats: 100, + repo: String::new(), + branch: String::new(), + commit_oid: hash, + inline_answer: Some(answer.into()), + seller_signature: keys + .sign_schnorr(&Message::from_digest(preimage.digest_bytes())) + .to_string(), + creq_hash: None, + accepted_mints: vec![credit.clone(), target.clone()], + realized_mint: Some(source.clone()), + contribution: None, + payment_mode: maxplayer_core::gateway::PaymentMode::Sat, + }; + let mut gate = BudgetGate::from_home(&home).unwrap(); + // Receipt delivery is outside this mint fixture: the plain local relay does + // not provide the auth-on-connect receipt handshake. Assert the hop itself + // settled through production authorize_pay, and that retry cannot melt twice. + let first = authorize_pay_async(&home, &mut gate, request.clone()).await; + let directory = maxplayer_core::crossmint_hop::hop_journal_dir(&home); + let entries: Vec<_> = std::fs::read_dir(&directory) + .unwrap_or_else(|e| panic!("no hop journal: {e}; pay={first:?}")) + .collect(); + assert_eq!(entries.len(), 1); + let records = std::fs::read_to_string(entries[0].as_ref().unwrap().path()).unwrap(); + let records: Vec = records + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + assert!( + records.iter().any(|r| r["record"] == "planned" + && r["source_mint"] == source + && r["target_mint"] == target), + "{records:?}; {first:?}" + ); + assert!( + records + .iter() + .any(|r| r["record"] == "settled" && r["minted_sats"] == 100), + "{records:?}; {first:?}" + ); + let balances = wallet_ops::balances_async(&home).await.unwrap(); + let held = |rows: &[wallet_ops::MintBalance], mint: &str| { + rows.iter() + .find(|r| r.mint_url == mint) + .unwrap() + .balance_sats + }; + assert_eq!( + held(&balances, &credit), + 5, + "insufficient credits stay untouched" + ); + assert!(held(&balances, &source) <= 156, "source paid the full hop"); + let spent = gate.spent(); + let _ = authorize_pay_async(&home, &mut gate, request).await; + let retry = wallet_ops::balances_async(&home).await.unwrap(); + assert_eq!( + held(&retry, &source), + held(&balances, &source), + "no second melt" + ); + assert_eq!(held(&retry, &credit), 5); + assert_eq!(gate.spent(), spent, "no second budget charge"); +} diff --git a/crates/maxplayer-mint/tests/support/https_mint.rs b/crates/maxplayer-mint/tests/support/https_mint.rs new file mode 100644 index 000000000..d454a93a7 --- /dev/null +++ b/crates/maxplayer-mint/tests/support/https_mint.rs @@ -0,0 +1,96 @@ +//! Real CDK HTTPS mint fixture; only the Lightning backend is simulated. +use cdk::{ + Mint, + mint::{MintBuilder, MintMeltLimits}, + nuts::{CurrencyUnit, PaymentMethod}, + types::FeeReserve, +}; +use hyper_util::{ + rt::{TokioExecutor, TokioIo}, + service::TowerToHyperService, +}; +use std::sync::Arc; +use tokio_rustls::{TlsAcceptor, rustls}; + +pub struct HttpsMint { + pub mint: Mint, + pub url: String, + pub certificate: String, + task: tokio::task::JoinHandle<()>, +} +impl Drop for HttpsMint { + fn drop(&mut self) { + self.task.abort(); + } +} + +pub async fn start() -> HttpsMint { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("https://{}", listener.local_addr().unwrap()); + let db = Arc::new(cdk_sqlite::mint::memory::empty().await.unwrap()); + let mut builder = MintBuilder::new(db.clone()); + let backend = cdk_fake_wallet::FakeWallet::new( + FeeReserve { + min_fee_reserve: 1.into(), + percent_fee_reserve: 1.0, + }, + Default::default(), + Default::default(), + 0, + CurrencyUnit::Sat, + ); + builder + .add_payment_processor( + CurrencyUnit::Sat, + PaymentMethod::BOLT11, + MintMeltLimits::new(1, 10_000), + Arc::new(backend), + ) + .await + .unwrap(); + let mint = builder + .with_name("credits-first local test".into()) + .with_urls(vec![url.clone()]) + .build_with_seed(db, &super::seed()) + .await + .unwrap(); + mint.start().await.unwrap(); + let router = cdk_axum::create_mint_router(Arc::new(mint.clone()), vec!["bolt11".into()]) + .await + .unwrap(); + let certified = + rcgen::generate_simple_self_signed(vec!["localhost".into(), "127.0.0.1".into()]).unwrap(); + let certificate = certified.cert.pem(); + let key = rustls::pki_types::PrivatePkcs8KeyDer::from(certified.key_pair.serialize_der()); + let config = rustls::ServerConfig::builder_with_provider(Arc::new( + rustls::crypto::ring::default_provider(), + )) + .with_safe_default_protocol_versions() + .unwrap() + .with_no_client_auth() + .with_single_cert(vec![certified.cert.der().clone()], key.into()) + .unwrap(); + let acceptor = TlsAcceptor::from(Arc::new(config)); + let task = tokio::spawn(async move { + while let Ok((stream, _)) = listener.accept().await { + let (acceptor, router) = (acceptor.clone(), router.clone()); + tokio::spawn(async move { + let Ok(tls) = acceptor.accept(stream).await else { + return; + }; + let _ = hyper_util::server::conn::auto::Builder::new(TokioExecutor::new()) + .serve_connection_with_upgrades( + TokioIo::new(tls), + TowerToHyperService::new(router), + ) + .await; + }); + } + }); + HttpsMint { + mint, + url, + certificate, + task, + } +} diff --git a/crates/maxplayer/src/mcp.rs b/crates/maxplayer/src/mcp.rs index f3938f5fe..95242fcfe 100644 --- a/crates/maxplayer/src/mcp.rs +++ b/crates/maxplayer/src/mcp.rs @@ -696,6 +696,7 @@ mod tests { offer_amount_sats: 10, max_sats: 10, buyer_mint: DEFAULT_MINT_URL, + balances: &[], allow_real_mints: false, requested_agent: None, requested_harness_family: None, diff --git a/docs/specs/buyer-mint-choice.md b/docs/specs/buyer-mint-choice.md new file mode 100644 index 000000000..29691465e --- /dev/null +++ b/docs/specs/buyer-mint-choice.md @@ -0,0 +1,152 @@ +# Buyer mint choice: credits first, one rule for public and private jobs + +**Status:** implementation draft authorized by bob. Josip sign-off pending; do not merge. **Anchor commit: +`9ba25cda0206b194e6c81d983108eb7ef857f505`** (= `origin/main` = `v0.6.1-rc6`). Citations are +`file:line @9ba25cd`. + +**Supersedes** the earlier draft of this file (the `["param","mints",…]` offer tag). bob, #buyer-mint-choice, +2026-10-05: +- The real problem is mints that can't receive a hop (`nostr://` credit mints). +- Public and private jobs follow the same mint rule (option 1: today's public rule everywhere). +- When the buyer holds enough at more than one mint the seller accepts, **credits are spent first**. No + per-job `pay_from` option. + +No offer tag, no protocol change, no relay change, no buyer-store migration, no new bind field. + +## 1. The rule + +For a priced job, given the seller's creq mint list: + +1. **Pick the source mint** (`select_source_mint`, `crossmint.rs:171`), from mints in the buyer's wallet + (`accepted_mints[0]` + `extra_mints`) that pass `allow_real_mints` and hold **the full price**: + 1. the first `nostr://` mint in the seller's list the buyer holds enough at (credits); + 2. else the first `https://` mint in the seller's list the buyer holds enough at; + 3. else the buyer's default mint. +2. **Plan** (`plan_payment`, `crossmint.rs:94`): + - source in the seller's list → pay direct; + - source is `nostr://` and not listed → refuse (credits never melt; unchanged, `:126`); + - otherwise hop to the first mint in the **seller's order** that passes the fence **and is not + `nostr://`**; none → refuse. +3. **Same rule for private jobs.** A private claim no longer has to list only mints the buyer approved. + +Payments never split across mints. + +## 2. Cases + +C = seller's credit mint (`nostr://`), L = Lightning mint the seller lists, X = Lightning mint the buyer +uses that the seller doesn't list. "Enough" = the full price at one mint. Credits count only if the buyer +added C with `maxplayer wallet mints add` (otherwise the row is `configured=false` and ignored, +`crossmint.rs:200`). Any seller accepting C takes them, not just the issuer. + +| Seller lists | Buyer holds | Result | Today | +|---|---|---|---| +| C + L (any order) | enough at C and at L | credits, direct | seller's first mint wins | +| C + L | enough at C only | credits, direct | same | +| C + L | enough at L only | sats at L, direct | same | +| C + L | sats at X (default) | hop X→L, buyer pays the fee | **C listed first: hop to C, fails after delivery** | +| C + L | some credits (< price) + sats at X | hop X→L, credits untouched | same bug if C first | +| C + L | not enough anywhere | refused at award (ceiling) | same | +| C only | enough at C | credits, direct | same | +| C only | sats only | **refused at award** | awarded, fails after delivery | +| L only | at L / at X | direct / hop X→L | same | +| L only | credits only | refused at award | same | +| several L + C | sats at X | hop to first `https://` in seller order | could hop to C | + +Private jobs: identical. Today a private claim listing any mint not in the buyer's `accepted_mints` is +dropped silently (§3). + +## 3. Current behavior being changed + +- **Hop target can be `nostr://`.** `plan_payment` takes the first fence-admitted mint + (`crossmint.rs:134-140`) with no scheme check. The sidecar disables NUT-04/05 + (`maxplayer-mint/src/dispatch.rs:28-35`), so the hop fails at the target mint quote + (`crossmint_hop.rs:917`) after delivery. +- **Source preference follows seller order** (`crossmint.rs:171`): a buyer holding both credits and sats + spends whichever the seller listed first. +- **Award filter ignores balances.** `claim_is_settleable` plans from the config default + (`buyer/lifecycle.rs:658`; filters built at `:94`) while the ceiling (`buyer/mod.rs:1453`) and accept + (`job_lifecycle.rs:1465`) use `select_source_mint`. With the `nostr://` skip alone, a buyer holding + credits at an `extra_mints` mint would be refused by a credits-only seller. The filter must use the + same source choice. +- **Private all-mints check.** `invoice::validate` requires every creq mint in the buyer's + `accepted_mints` (`private_content/invoice.rs:111-117` via `HostPolicy::mint`, + `maxplayer-private-protocol/src/wire.rs:102`); `extra_mints` don't count (`runtime.rs:26`, `:51`). + A failing claim is dropped by `continue` (`job_lifecycle.rs:3738`). This is the private mechanism of + #1069. #1092 kept it only because a hop could land on an unapproved mint + (`private_content/tests.rs:1721`). Under option 1 that's accepted, as it already is for public jobs. +- **Private receipt mint check.** A kind-3400 receipt's `mint` is checked with the same + `HostPolicy::mint` (`wire.rs:420`). Under option 1 the realized mint can be a seller-listed mint the + buyer never added, so this check must change too. (Correction to an earlier chat answer that said it + could stay.) + +## 4. Changes (one PR) + +- `crossmint.rs` + - `select_source_mint`: two passes, `nostr://` first, then `https://`, each in seller order. + - `plan_payment`: skip `nostr://` hop targets; refusal names the list. +- `buyer/lifecycle.rs`: `AwardFilters` carries the balance snapshot; `claim_is_settleable` plans from + `select_source_mint(...)`. A failed balance read falls back to the default mint (today's filter). +- `buyer/mod.rs`: both `award_filters_for_offer` callers (`:950`, `:1625`) pass the balances already read + for the ceiling. Park/refusal text says when the only route was a credit-mint hop. +- `maxplayer-private-protocol/src/wire.rs`: split `HostPolicy::mint` into a well-formedness check + (HTTPS rules or canonical `nostr://npub`, ≤2048 bytes) and the approval check. Claim `creq` mints and + the receipt `mint` use well-formedness only. +- `private_content/invoice.rs`: keep count (1..=32), no duplicates, well-formed; drop membership. +- `private_content/evidence.rs`: check the receipt / sealed realized mint is in the **signed claim's** + creq list (the set comparison at `:171` covers the list; add the realized-mint membership if it's not + already implied — confirm in the PR). +- `reviewer.rs`: no logic change, but it validates private invoices with its own mint list (`:59`), so the + reviewer service must run the new code to see these jobs. +- `job_lifecycle.rs`: #1039 — a same-result re-accept keeps the stored `funding_mint`/`delivery_mint`. + Included because credits-first makes a different re-pick more likely. + +`HostPolicy.accepted_mints` is then unused by validation; leave the field for now. + +## 5. Pays-once + +- New selections: the source is chosen once at award (reservation pin) and sealed at accept + (`job_lifecycle.rs:1863`); pay re-plans from the sealed mints (`authorize_pay.rs:1064`). Credits-first + only changes what is chosen, not when. +- Ceiling, filter and accept call the same `select_source_mint`, so they can't diverge (extend the parity + test at `buyer/mod.rs:3543`). +- In-flight binds sealed before upgrade with a hop to `nostr://`: after upgrade they re-plan to the next + `https://` target, which is a new attempt id. Safe because the old attempt failed at the target mint + quote, before any melt and before the hop journal was written (`crossmint_hop.rs:906-927`). The PR + adds a test pinning that no journal or spend exists for that failure. +- #1039 closes the re-accept hole. + +## 6. Tests + +- `select_source_mint`: credits beat sats regardless of seller order; insufficient credits fall through to + sats; unconfigured credit rows ignored; fence still applies. +- `plan_payment`: `nostr://` target skipped wherever it sits; credits-only seller + sats-only buyer + refused; existing direct/hop cases unchanged. +- Award filter: credits held at an `extra_mints` mint against a credits-only seller is awardable; manual + and auto paths identical (extend `lifecycle.rs:4577`). +- Private: invert `tests.rs:1721`; a `[approved, unknown]` (#1069-shaped) claim is visible and + awardable; all-unknown well-formed list accepted; malformed / duplicate / >32 refused; receipt with a + realized mint outside the signed creq refused. +- Pays-once: pre-upgrade bind with a `nostr://` hop target re-plans without double spend; same-result + re-accept keeps mints and attempt id (#1039). +- Mutation checks (each must go red): remove the `nostr://` target skip; revert to seller-order source; + filter back to default-only planning; drop the receipt-in-creq check. +- Money-path suite with a real sidecar credit mint: credits direct; seller `[C, L]` + buyer at X hops to L. + +## 7. Rollout + +- Client-only release; no relay deploy, no migration. +- The reviewer service must be redeployed in the same window, or it keeps ignoring private review + requests whose claims list mints outside its list (as today). +- Mixed versions: an old buyer keeps today's behavior (including the bug); an old seller is unaffected + (it only ever receives at its own listed mints, checked against its stored creq, `seller_node/run.rs:9777`). + +## 8. Not in scope / known gaps + +- **Hop source is always the default mint.** A buyer whose sats sit only at a non-default `extra_mints` + mint the seller doesn't list is refused at award. Follow-up: hop from the default if it covers, else the + first other `https://` wallet mint that does. +- **#1069 public repro** isn't explained by this trace; the private mechanism is fixed here. +- **Future Lightning-backed `nostr://` mint** would be skipped as a hop target by scheme. Revisit when one + exists (e.g. read NUT-04 from mint info). +- **Trust widening for private jobs** is the deliberate cost of option 1: a hop may pay into any + `https://` mint the seller lists, as public jobs already do. From 25c2d25d510c9a6a12eb12974200adf603be29f2 Mon Sep 17 00:00:00 2001 From: maxie-agent <330537927+maxie-agent@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:11:12 +0000 Subject: [PATCH 2/3] fix: keep award snapshots fresh and select unreserved mint funds --- crates/maxplayer-core/src/buyer/lifecycle.rs | 68 +++ crates/maxplayer-core/src/buyer/mod.rs | 397 +++++++++++++++--- crates/maxplayer-core/src/buyer/store.rs | 48 +++ crates/maxplayer-core/src/crossmint.rs | 6 +- crates/maxplayer-core/src/crossmint_hop.rs | 21 + crates/maxplayer-core/src/gateway.rs | 36 +- crates/maxplayer-core/src/job_lifecycle.rs | 15 +- .../src/private_content/invoice.rs | 7 +- .../src/private_content/tests.rs | 22 + 9 files changed, 561 insertions(+), 59 deletions(-) diff --git a/crates/maxplayer-core/src/buyer/lifecycle.rs b/crates/maxplayer-core/src/buyer/lifecycle.rs index 21aa827ed..8fbc04ff8 100644 --- a/crates/maxplayer-core/src/buyer/lifecycle.rs +++ b/crates/maxplayer-core/src/buyer/lifecycle.rs @@ -1772,6 +1772,74 @@ mod tests { ); } + #[tokio::test] + async fn reserved_credits_filter_uses_available_sats_on_manual_and_auto_paths() { + let credit = "nostr://npub10xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqpkge6d"; + let lightning = "https://lightning.example"; + let root = tempfile::tempdir().unwrap(); + let db = root.path().join("buyer.sqlite"); + let store = super::super::store::BuyerStore::open(&db).unwrap(); + let credit = crate::wallet_ops::normalize_mint_url(credit).unwrap(); + store + .reserve_at("job-a", 100, &MintCeiling::at_mint(&credit, 100, false), 1) + .unwrap(); + let raw = [credit.as_str(), lightning].map(|mint| crate::wallet_ops::MintBalance { + mint_url: mint.into(), + balance_sats: 100, + is_default: false, + configured: true, + }); + let rows = + super::super::store::available_balances(&db, "job-b", DEFAULT_MINT_URL, &raw).unwrap(); + let job = "b".repeat(64); + let listed = vec![lightning.into(), credit]; + let view = view_with(&job, 100, vec![claim(&job, true, 100, &listed)]); + let mut f = filters(100, 100); + f.allow_real_mints = true; + f.balances = &rows; + assert_eq!( + crate::crossmint::select_source_mint(f.buyer_mint, &listed, true, f.balances, 100), + lightning + ); + let selected = select_awardable_claim(&view, &f).unwrap(); + named_claim_awardable(&view, &selected, &f).unwrap(); + let ceiling = super::super::ceiling_from_read( + DEFAULT_MINT_URL, + true, + &super::super::store::ReservationPin::Unpinned, + &listed, + Ok(super::super::AwardBalances { + raw: raw.to_vec(), + available: rows, + }), + 100, + &job, + ) + .unwrap(); + let outcome = award_with_reservation( + &store, + &job, + 100, + ceiling, + 2, + no_relay, + || async { + let mut prepared = fake_prepared(&job); + prepared.quoted_mints = listed; + Ok(prepared) + }, + send_acked, + None, + ) + .await + .unwrap(); + assert!(matches!(outcome, AwardOutcome::Published(_))); + assert_eq!( + store.reservation_pin(&job).unwrap(), + super::super::store::ReservationPin::Mint(lightning.into()) + ); + } + // A live claim priced at the offer amount, quoting the buyer's default mint, is selected. #[test] fn select_picks_live_payable_claim() { diff --git a/crates/maxplayer-core/src/buyer/mod.rs b/crates/maxplayer-core/src/buyer/mod.rs index 0c0aafa15..259d5a6de 100644 --- a/crates/maxplayer-core/src/buyer/mod.rs +++ b/crates/maxplayer-core/src/buyer/mod.rs @@ -914,10 +914,31 @@ async fn award(context: &BuyerContext, id: Value, params: Value) -> Response { }; } } + // Fetch before locking or snapshotting balances: relay I/O must not hold up other + // money paths or age the snapshot before reservation. Pinned attempts still skip + // this read entirely; their signed decision is already sealed. + let view = if attempt.is_none() { + Some( + match job_lifecycle::fetch_job_view_async( + &context.home, + &keys, + ¶ms.job_id, + RELAY_TIMEOUT, + now_unix() as u64, + ) + .await + { + Ok(view) => view, + Err(error) => return Response::err(id, CODE_INTERNAL, error.to_string()), + }, + ) + } else { + None + }; // Pinned-attempt resolution above can re-enter money_lock. From here on, // keep selection, its balance snapshot, and reservation under one guard. let _guard = context.money_lock.lock().await; - let balances = read_award_balances(context).await; + let balances = read_award_balances(context, ¶ms.job_id).await; let (award_amount, claim_id, send_relay, mut quoted_mints) = match &attempt { Some(attempt) => ( attempt.amount_sats, @@ -926,18 +947,9 @@ async fn award(context: &BuyerContext, id: Value, params: Value) -> Response { attempt_quoted_mints(attempt), ), None => { - let view = match job_lifecycle::fetch_job_view_async( - &context.home, - &keys, - ¶ms.job_id, - RELAY_TIMEOUT, - now_unix() as u64, - ) - .await - { - Ok(view) => view, - Err(error) => return Response::err(id, CODE_INTERNAL, error.to_string()), - }; + let view = view + .as_ref() + .expect("unpinned award fetched its view before locking"); let Some(offer) = view.offer.as_ref() else { return Response::err( id, @@ -951,12 +963,16 @@ async fn award(context: &BuyerContext, id: Value, params: Value) -> Response { // every other filter come from the SIGNED OFFER, never from award params, so the request // cannot be changed after the fact. Sharing the constructor is what makes "both paths // filter identically" structural instead of a convention someone has to keep noticing. + let available = match award_filter_balances(¶ms.job_id, &balances) { + Ok(available) => available, + Err(error) => return Response::err(id, CODE_INTERNAL, error), + }; let filters = lifecycle::award_filters_for_offer( offer, max_sats, context.home.config.default_mint(), context.home.config.allow_real_mints, - balances.as_deref().unwrap_or(&[]), + available, ); // Manual award names the claim but applies the SAME hard filters as auto-award — @@ -965,12 +981,14 @@ async fn award(context: &BuyerContext, id: Value, params: Value) -> Response { // claim that passes those same filters. let claim_id = match params.claim_id.clone() { Some(claim_id) => { - if let Err(refused) = lifecycle::named_claim_awardable(&view, &claim_id, &filters) { + if let Err(refused) = + lifecycle::named_claim_awardable(view, &claim_id, &filters) + { return Response::err(id, CODE_REFUSED, refused.to_string()); } claim_id } - None => match lifecycle::select_awardable_claim(&view, &filters) { + None => match lifecycle::select_awardable_claim(view, &filters) { Some(claim_id) => claim_id, None => { return Response::err( @@ -984,8 +1002,13 @@ async fn award(context: &BuyerContext, id: Value, params: Value) -> Response { } }, }; - let quoted_mints = claim_creq_mints(&view, &claim_id); - (offer_amount, claim_id, context.home.config.relay_url.clone(), quoted_mints) + let quoted_mints = claim_creq_mints(view, &claim_id); + ( + offer_amount, + claim_id, + context.home.config.relay_url.clone(), + quoted_mints, + ) } }; @@ -1405,14 +1428,50 @@ async fn award_ceiling( quoted_mints: &[String], amount_sats: u64, ) -> Result { - let read = read_award_balances(context).await; + let read = read_award_balances(context, job_id).await; award_ceiling_with_read(context, job_id, quoted_mints, amount_sats, read) } +#[derive(Debug)] +struct AwardBalances { + raw: Vec, + available: Vec, +} + async fn read_award_balances( context: &BuyerContext, -) -> Result, String> { - context.wallet.balances().await.map_err(|e| e.to_string())? + job_id: &str, +) -> Result { + let raw = context + .wallet + .balances() + .await + .map_err(|e| e.to_string())??; + let available = store::available_balances( + &context.home.root.join(STATE_DB_FILE), + job_id, + context.home.config.default_mint(), + &raw, + ) + .map_err(|e| e.to_string())?; + Ok(AwardBalances { raw, available }) +} + +fn balance_snapshot_error(job_id: &str, error: &str) -> String { + format!( + "per-mint balance read failed for job {job_id} ({error}); the award ceiling cannot be checked, so nothing was reserved — retry" + ) +} + +/// Both manual and auto paths must pass this gate BEFORE mint filtering, so an +/// unavailable wallet snapshot can never masquerade as a credits-hop refusal. +fn award_filter_balances<'a>( + job_id: &str, + read: &'a Result, +) -> Result<&'a [crate::wallet_ops::MintBalance], String> { + read.as_ref() + .map(|snapshot| snapshot.available.as_slice()) + .map_err(|error| balance_snapshot_error(job_id, error)) } fn award_ceiling_with_read( @@ -1420,7 +1479,7 @@ fn award_ceiling_with_read( job_id: &str, quoted_mints: &[String], amount_sats: u64, - read: Result, String>, + read: Result, ) -> Result { let pin = context .store @@ -1443,16 +1502,11 @@ fn ceiling_from_read( allow_real_mints: bool, pin: &store::ReservationPin, quoted_mints: &[String], - read: Result, String>, + read: Result, amount_sats: u64, job_id: &str, ) -> Result { - let balances = read.map_err(|error| { - format!( - "per-mint balance read failed for job {job_id} ({error}); the award ceiling cannot be \ - checked, so nothing was reserved — retry" - ) - })?; + let balances = read.map_err(|error| balance_snapshot_error(job_id, &error))?; let recorded = match pin { store::ReservationPin::Unpinned => None, store::ReservationPin::Default => Some(default_mint.to_owned()), @@ -1463,7 +1517,8 @@ fn ceiling_from_read( allow_real_mints, recorded.as_deref(), quoted_mints, - &balances, + &balances.raw, + &balances.available, amount_sats, )) } @@ -1475,6 +1530,7 @@ fn award_ceiling_from_balances( recorded_mint: Option<&str>, quoted_mints: &[String], balances: &[crate::wallet_ops::MintBalance], + available: &[crate::wallet_ops::MintBalance], amount_sats: u64, ) -> MintCeiling { let normalize = |raw: &str| { @@ -1486,7 +1542,7 @@ fn award_ceiling_from_balances( default_mint, quoted_mints, allow_real_mints, - balances, + available, amount_sats, ), }; @@ -1642,13 +1698,30 @@ async fn drive_auto_award( // Both selection entry points then consult `claim_meets_capability_request`: // `select_awardable_claim` here, `named_claim_awardable` on the manual path. let guard = context.money_lock.lock().await; - let balances = read_award_balances(context).await; + let balances = read_award_balances(context, job_id).await; + let available = match award_filter_balances(job_id, &balances) { + Ok(available) => available, + Err(reason) => { + drop(guard); + if now_unix() as u64 > offer.deadline_unix { + if settle_intent_from_attempt(context, &keys, job_id).await { + return Ok(()); + } + crate::opline!("{}", auto_award_park_line(job_id, &reason)); + let _ = context.store.mark_award_parked(job_id, &reason, now_unix()); + return Ok(()); + } + // Like a failed ceiling read, keep the intent pending and surface + // the retryable error to the driver instead of diagnosing its mints. + return Err(reason); + } + }; let filters = lifecycle::award_filters_for_offer( offer, max_sats, context.home.config.default_mint(), context.home.config.allow_real_mints, - balances.as_deref().unwrap_or(&[]), + available, ); // Built AFTER `filters` so the deadline park can name the capability request that refused @@ -1716,7 +1789,7 @@ async fn finalize_auto_award( offer_amount: u64, claim_id: String, quoted_mints: Vec, - balances: Result, String>, + balances: Result, ) -> Result<(), String> { // Caller holds money_lock from the shared filter/ceiling snapshot through this await. // Keep the pinned-attempt deadline check at the reserve/publish boundary. @@ -3410,6 +3483,45 @@ mod tests { static NEXT: AtomicU64 = AtomicU64::new(0); + // Drive the actual manual handler into a relay connection whose handshake never + // completes. No sleeps or elapsed-time assertion: receiving the HTTP request is + // the barrier proving the fetch is in flight, and try_lock checks immediately. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn manual_award_relay_fetch_does_not_hold_money_lock() { + use tokio::io::AsyncReadExt; + + let root = temp_home("award-fetch-lock"); + let mut home = bootstrap_home(&root).expect("bootstrap home"); + let relay = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + home.config.relay_url = format!("ws://{}", relay.local_addr().expect("address")); + let (_lock, context, _socket) = bootstrap(home).await.expect("buyer bootstrap"); + let request = award(&context, json!(1), json!({"job_id": "a".repeat(64)})); + tokio::pin!(request); + let blocked_fetch = async { + let (mut stream, _) = relay.accept().await.expect("relay connection"); + let mut byte = [0]; + stream + .read_exact(&mut byte) + .await + .expect("handshake request"); + stream // Keep the connection open without answering the handshake. + }; + let stream = tokio::select! { + response = &mut request => panic!("award finished before relay barrier: {response:?}"), + stream = tokio::time::timeout(Duration::from_secs(10), blocked_fetch) => { + stream.expect("manual award must reach the relay") + } + }; + assert!( + context.money_lock.try_lock().is_ok(), + "manual award must leave money_lock available while its relay fetch is blocked" + ); + drop(stream); + let _ = std::fs::remove_dir_all(&root); + } + // #774 defence in depth. `raise_open_file_limit` claims it lifts the soft descriptor limit // toward the hard one; a comment saying so is not evidence, so this executes it. // @@ -3548,10 +3660,19 @@ mod tests { #[test] fn award_ceiling_counts_a_covering_extra_mint_the_claim_accepts() { let quoted = vec![CEIL_DEFAULT.to_owned(), CEIL_EXTRA.to_owned()]; - let balances = - vec![mint_row(CEIL_DEFAULT, 0, true, true), mint_row(CEIL_EXTRA, 300, false, true)]; - let ceiling = - award_ceiling_from_balances(CEIL_DEFAULT, true, None, "ed, &balances, 200); + let balances = vec![ + mint_row(CEIL_DEFAULT, 0, true, true), + mint_row(CEIL_EXTRA, 300, false, true), + ]; + let ceiling = award_ceiling_from_balances( + CEIL_DEFAULT, + true, + None, + "ed, + &balances, + &balances, + 200, + ); assert_eq!(ceiling, MintCeiling::at_mint(CEIL_EXTRA, 300, false)); } @@ -3560,10 +3681,19 @@ mod tests { #[test] fn award_ceiling_never_sums_balances_across_mints() { let quoted = vec![CEIL_DEFAULT.to_owned(), CEIL_EXTRA.to_owned()]; - let balances = - vec![mint_row(CEIL_DEFAULT, 150, true, true), mint_row(CEIL_EXTRA, 150, false, true)]; - let ceiling = - award_ceiling_from_balances(CEIL_DEFAULT, true, None, "ed, &balances, 200); + let balances = vec![ + mint_row(CEIL_DEFAULT, 150, true, true), + mint_row(CEIL_EXTRA, 150, false, true), + ]; + let ceiling = award_ceiling_from_balances( + CEIL_DEFAULT, + true, + None, + "ed, + &balances, + &balances, + 200, + ); assert_eq!(ceiling.balance, 150, "one mint's balance, never 300"); assert!(ceiling.balance < 200); } @@ -3618,9 +3748,64 @@ mod tests { ), (vec![], vec![mint_row(CEIL_EXTRA, 500, false, true)], 100), ]; + let root = tempfile::tempdir().unwrap(); + let db = root.path().join("buyer.sqlite"); + let store = BuyerStore::open(&db).unwrap(); + let credit_mint = crate::wallet_ops::normalize_mint_url(credit).unwrap(); + store + .reserve_at( + "job-a", + 100, + &MintCeiling::at_mint(credit_mint, 100, false), + 1, + ) + .unwrap(); + let raw = vec![ + mint_row(credit, 100, false, true), + mint_row(CEIL_EXTRA, 100, false, true), + ]; + let net = store::available_balances(&db, "job-b", CEIL_DEFAULT, &raw).unwrap(); + let quoted = vec![CEIL_EXTRA.to_owned(), credit.to_owned()]; + let accept = job_lifecycle::accept_source_seed( + CEIL_DEFAULT, + "ed, + true, + &store::ReservationPin::Unpinned, + Some(Ok(&net)), + 100, + ); + let ceiling = ceiling_from_read( + CEIL_DEFAULT, + true, + &store::ReservationPin::Unpinned, + "ed, + Ok(AwardBalances { + raw, + available: net, + }), + 100, + "job-b", + ) + .unwrap(); + assert_eq!(accept, CEIL_EXTRA); + assert_eq!(ceiling.mint.as_deref(), Some(accept.as_str())); for (quoted, balances, amount) in cases { - let accept = crate::crossmint::select_source_mint(CEIL_DEFAULT, "ed, true, &balances, amount); - let ceiling = award_ceiling_from_balances(CEIL_DEFAULT, true, None, "ed, &balances, amount); + let accept = crate::crossmint::select_source_mint( + CEIL_DEFAULT, + "ed, + true, + &balances, + amount, + ); + let ceiling = award_ceiling_from_balances( + CEIL_DEFAULT, + true, + None, + "ed, + &balances, + &balances, + amount, + ); assert_eq!( ceiling.mint.as_deref(), Some(crate::wallet_ops::normalize_mint_url(&accept).unwrap().as_str()), @@ -3629,14 +3814,103 @@ mod tests { } } + #[tokio::test] + async fn reserved_credits_choose_sats_for_filter_ceiling_accept_and_award() { + let root = tempfile::tempdir().unwrap(); + let db = root.path().join("buyer.sqlite"); + let store = BuyerStore::open(&db).unwrap(); + let credit = "nostr://npub10xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqpkge6d"; + let credit = crate::wallet_ops::normalize_mint_url(credit).unwrap(); + let raw = vec![ + mint_row(&credit, 100, false, true), + mint_row(CEIL_EXTRA, 100, false, true), + ]; + store + .reserve_at("job-a", 100, &MintCeiling::at_mint(&credit, 100, false), 1) + .unwrap(); + let listed = vec![CEIL_EXTRA.to_owned(), credit.clone()]; + let available = store::available_balances(&db, "job-b", CEIL_DEFAULT, &raw).unwrap(); + assert_eq!(available[0].balance_sats, 0); + let source = + crate::crossmint::select_source_mint(CEIL_DEFAULT, &listed, true, &available, 100); + assert_eq!( + source, CEIL_EXTRA, + "reserved credits must not win source selection" + ); + let ceiling = ceiling_from_read( + CEIL_DEFAULT, + true, + &store::ReservationPin::Unpinned, + &listed, + Ok(AwardBalances { + raw: raw.clone(), + available, + }), + 100, + "job-b", + ) + .unwrap(); + assert_eq!(ceiling.mint.as_deref(), Some(CEIL_EXTRA)); + let (pin, balances) = job_lifecycle::accept_pin_and_balances(&db, "job-b", || async { + store::available_balances(&db, "job-b", CEIL_DEFAULT, &raw).map_err(|e| e.to_string()) + }) + .await + .unwrap(); + let accept = job_lifecycle::accept_source_seed( + CEIL_DEFAULT, + &listed, + true, + &pin, + balances + .as_ref() + .map(|r| r.as_ref().map(Vec::as_slice).map_err(String::as_str)), + 100, + ); + assert_eq!(accept, CEIL_EXTRA); + assert_eq!( + store.reserve_at("job-b", 100, &ceiling, 2).unwrap(), + reservations::Reserved::New { + available_before: 100 + } + ); + assert_eq!( + store.reservation_pin("job-b").unwrap(), + store::ReservationPin::Mint(CEIL_EXTRA.into()) + ); + // Exclude this job's own live row: a retry must not count its own hold twice. + let own = store::available_balances(&db, "job-a", CEIL_DEFAULT, &raw).unwrap(); + assert_eq!(own[0].balance_sats, 100); + } + + #[test] + fn balance_read_failure_diagnostic_is_not_a_credit_hop_refusal() { + let error = award_filter_balances("job-b", &Err("wallet db locked".into())).unwrap_err(); + assert!( + error.contains("per-mint balance read failed") && error.contains("wallet db locked") + ); + assert!(!error.contains("cannot receive a hop")); + let parked = auto_award_park_line("job-b", &error); + assert!(parked.contains("balance read failed") && parked.contains("retry")); + } + // An unconfigured, DB-discovered mint is never a funding source, so its proofs never raise the // ceiling — even when the claim quotes it. #[test] fn award_ceiling_ignores_unconfigured_mints() { let quoted = vec![CEIL_OTHER.to_owned()]; - let balances = - vec![mint_row(CEIL_DEFAULT, 0, true, true), mint_row(CEIL_OTHER, 900, false, false)]; - let ceiling = award_ceiling_from_balances(CEIL_DEFAULT, true, None, "ed, &balances, 200); + let balances = vec![ + mint_row(CEIL_DEFAULT, 0, true, true), + mint_row(CEIL_OTHER, 900, false, false), + ]; + let ceiling = award_ceiling_from_balances( + CEIL_DEFAULT, + true, + None, + "ed, + &balances, + &balances, + 200, + ); assert_eq!(ceiling, MintCeiling::at_mint(CEIL_DEFAULT, 0, true)); } @@ -3655,14 +3929,26 @@ mod tests { let balances = vec![mint_row(CEIL_DEFAULT, 0, true, true), mint_row(&normalized, 300, false, true)]; let quoted = vec![CREDITS.to_owned()]; - let ceiling = award_ceiling_from_balances(CEIL_DEFAULT, true, None, "ed, &balances, 200); - assert_eq!(ceiling, MintCeiling::at_mint(normalized.clone(), 300, false)); + let ceiling = award_ceiling_from_balances( + CEIL_DEFAULT, + true, + None, + "ed, + &balances, + &balances, + 200, + ); + assert_eq!( + ceiling, + MintCeiling::at_mint(normalized.clone(), 300, false) + ); let pinned = award_ceiling_from_balances( CEIL_DEFAULT, true, Some(&normalized), "ed, &balances, + &balances, 200, ); assert_eq!(pinned, ceiling, "a re-hold at the recorded credit mint finds the same row"); @@ -3696,7 +3982,10 @@ mod tests { true, &store::ReservationPin::Default, &[CEIL_EXTRA.to_owned()], - Ok(balances), + Ok(AwardBalances { + available: balances.clone(), + raw: balances, + }), 200, "job-x", ) @@ -3712,7 +4001,13 @@ mod tests { let balances = vec![mint_row(CEIL_DEFAULT, 500, true, true), mint_row(CEIL_EXTRA, 250, false, true)]; let ceiling = award_ceiling_from_balances( - CEIL_DEFAULT, true, Some(CEIL_EXTRA), "ed, &balances, 200, + CEIL_DEFAULT, + true, + Some(CEIL_EXTRA), + "ed, + &balances, + &balances, + 200, ); assert_eq!(ceiling, MintCeiling::at_mint(CEIL_EXTRA, 250, false)); } diff --git a/crates/maxplayer-core/src/buyer/store.rs b/crates/maxplayer-core/src/buyer/store.rs index 5d879615d..7c039c858 100644 --- a/crates/maxplayer-core/src/buyer/store.rs +++ b/crates/maxplayer-core/src/buyer/store.rs @@ -1422,6 +1422,54 @@ pub fn read_reservation_pin(db_path: &Path, job_id: &str) -> Result Result, StoreError> { + let mut available = balances.to_vec(); + if !db_path.exists() { + return Ok(available); + } + let conn = Connection::open_with_flags( + db_path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + )?; + conn.busy_timeout(std::time::Duration::from_secs(5))?; + let has_table: bool = conn.query_row( + "SELECT EXISTS (SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'reservations')", [], |row| row.get(0))?; + if !has_table { + return Ok(available); + } + let has_source = BuyerStore::column_exists(&conn, "reservations", "source_mint")?; + let sql = if has_source { + "SELECT source_mint, amount_sats FROM reservations WHERE state = 'reserved' AND job_id != ?1" + } else { + "SELECT NULL, amount_sats FROM reservations WHERE state = 'reserved' AND job_id != ?1" + }; + let normalize = |mint: &str| { + crate::wallet_ops::normalize_mint_url(mint).unwrap_or_else(|_| mint.trim().to_owned()) + }; + let mut stmt = conn.prepare(sql)?; + let rows = stmt.query_map([job_id], |row| { + Ok((row.get::<_, Option>(0)?, row.get::<_, i64>(1)?)) + })?; + for row in rows { + let (mint, amount) = row?; + let mint = normalize(mint.as_deref().unwrap_or(default_mint)); + for balance in &mut available { + if normalize(&balance.mint_url) == mint { + balance.balance_sats = balance.balance_sats.saturating_sub(amount.max(0) as u64); + } + } + } + Ok(available) +} + /// The in-flight `reserved` term for one ceiling (#1076). Pooled: every `Reserved` row. Per mint: /// rows recorded at that mint, plus unrecorded (pre-v7 / pooled) rows when the mint is the default. fn sum_reserved_against( diff --git a/crates/maxplayer-core/src/crossmint.rs b/crates/maxplayer-core/src/crossmint.rs index 1b718371b..42003273b 100644 --- a/crates/maxplayer-core/src/crossmint.rs +++ b/crates/maxplayer-core/src/crossmint.rs @@ -164,8 +164,9 @@ pub fn plan_payment( /// covering HTTPS mints, preserving seller order within each group. The fence /// applies to both. Returning a listed mint makes [`plan_payment`] pay direct. /// -/// Balance-awareness is ADVISORY and applied ONCE, here at accept. The result is sealed into the -/// accept-bind and re-derived (not re-decided) at pay, so a later balance or config-default change +/// Balance-awareness is ADVISORY: award uses it to pin the reservation source, and accept +/// honors that pin (or selects a source when no pin exists). The accept-bind seals the result, +/// which is re-derived (not re-decided) at pay, so a later balance or config-default change /// cannot shift the sealed mint — the pays-once attempt-id invariant is unchanged. Exact coverage /// (including fees) is enforced at pay: if the chosen mint's balance is spent before pay, the pay /// refuses fail-closed at that sealed mint rather than silently re-selecting a different one. @@ -190,6 +191,7 @@ pub(crate) fn select_source_mint( config_default.to_owned() } +/// Callers supply net-available balances (live holds for other jobs subtracted). /// Whether configured `balances` shows at least `amount_sats` at `mint`, comparing normalized mint /// URLs. Balance display was widened in #266 to include DB-discovered, unconfigured mints; source /// selection deliberately excludes those rows because the selected mint is sealed into the diff --git a/crates/maxplayer-core/src/crossmint_hop.rs b/crates/maxplayer-core/src/crossmint_hop.rs index a44316f78..6886cf4f6 100644 --- a/crates/maxplayer-core/src/crossmint_hop.rs +++ b/crates/maxplayer-core/src/crossmint_hop.rs @@ -877,6 +877,11 @@ impl CdkHopEffects { "source mint {source_mint}: a nostr:// mint cannot be a hop source (it never melts)" ))); } + if crate::mint_wire::is_nostr_scheme(target_mint) { + return Err(HopError::Mint(format!( + "target mint {target_mint}: a nostr:// mint cannot be a hop target (credits cannot receive a hop)" + ))); + } let source = buyer_fund::open_wallet_at_mint_async(home, source_mint) .await .map_err(|error| HopError::Mint(format!("source mint {source_mint}: {error}")))?; @@ -1980,6 +1985,22 @@ mod tests { // #1034 re-review: the opener is the guard for journal replay (`sweep_hops`) and authorize_pay's // hop path. A nostr:// source must be refused before either wallet is opened. + #[tokio::test] + async fn a_nostr_target_cannot_open_a_hop() { + let root = scratch_dir("nostr-target-open"); + let home = crate::home::bootstrap(&root).expect("bootstrap"); + let nostr = "nostr://npub10xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqpkge6d"; + let Err(error) = CdkHopEffects::open(&home, "https://127.0.0.1:1", nostr).await else { + panic!("a nostr:// hop target opened"); + }; + assert!( + matches!(error, HopError::Mint(ref detail) if detail.contains("cannot be a hop target")), + "{error:?}" + ); + assert!(!root.join("crossmint-journal").exists()); + let _ = std::fs::remove_dir_all(root); + } + #[tokio::test] async fn a_nostr_source_cannot_open_a_hop() { let root = scratch_dir("nostr-source-open"); diff --git a/crates/maxplayer-core/src/gateway.rs b/crates/maxplayer-core/src/gateway.rs index 7a53d5158..40287b7b1 100644 --- a/crates/maxplayer-core/src/gateway.rs +++ b/crates/maxplayer-core/src/gateway.rs @@ -1457,12 +1457,19 @@ pub mod creq { // CurrencyUnit::from_str is infallible (unknown units fall back to Custom), so an // offer unit always maps to a NUT-18 unit. let unit = CurrencyUnit::from_str(unit).unwrap_or(CurrencyUnit::Custom(unit.to_owned())); - let mints = accepted_mints + let mut mints = accepted_mints .iter() - .map(|m| MintUrl::from_str(m).map_err(|e| CreqError::Mint(format!("{m}: {e}")))) + .map(|m| { + let url = + nostr_sdk::Url::parse(m).map_err(|e| CreqError::Mint(format!("{m}: {e}")))?; + MintUrl::from_str(url.as_str()).map_err(|e| CreqError::Mint(format!("{m}: {e}"))) + }) .collect::, _>>()?; - let seller_key = - PublicKey::from_hex(seller_pubkey_hex).map_err(|e| CreqError::SellerKey(e.to_string()))?; + // Keep seller preference order, but never emit normalized-equal invoice mints. + let mut seen = std::collections::BTreeSet::new(); + mints.retain(|mint| seen.insert(mint.to_string())); + let seller_key = PublicKey::from_hex(seller_pubkey_hex) + .map_err(|e| CreqError::SellerKey(e.to_string()))?; // Empty relay list: the transport addresses the seller's key; relay hints are optional. let nprofile = Nip19Profile::new(seller_key, []) .to_bech32() @@ -2228,6 +2235,27 @@ mod creq_tests { assert!(creq_tag.value().unwrap().starts_with("creqA")); } + #[test] + fn seller_creq_deduplicates_normalized_mints() { + let mints = [ + "https://M.example:443", + "https://m.example/", + "https://m.example", + "https://other.example", + ] + .map(str::to_owned); + let raw = build_seller_creq("job", 100, "sat", &mints, &seller_hex()).unwrap(); + let request = parse_creq(&raw).unwrap(); + assert_eq!( + request + .mints + .iter() + .map(ToString::to_string) + .collect::>(), + ["https://m.example", "https://other.example"] + ); + } + /// Round-trip: `PaymentRequest::from_str(tag)` yields a=offer.amount, u=offer.unit, /// m=accepted_mints (order preserved), one nostr transport to the seller, single-use, no nut10. #[test] diff --git a/crates/maxplayer-core/src/job_lifecycle.rs b/crates/maxplayer-core/src/job_lifecycle.rs index 8adc5a3ed..34516a7a9 100644 --- a/crates/maxplayer-core/src/job_lifecycle.rs +++ b/crates/maxplayer-core/src/job_lifecycle.rs @@ -1807,7 +1807,20 @@ pub async fn accept_claim_async( let (pin, balances) = accept_pin_and_balances( &home.root.join(crate::buyer::STATE_DB_FILE), &request.job_id, - || async { crate::wallet_ops::balances_async(home).await.map_err(|error| error.to_string()) }, + || async { + let balances = crate::wallet_ops::balances_async(home) + .await + .map_err(|error| error.to_string())?; + // Unpinned accept has no live hold of its own. Match award's net selection; + // accept_pin_and_balances rechecks afterward so a concurrent pin still wins. + crate::buyer::store::available_balances( + &home.root.join(crate::buyer::STATE_DB_FILE), + &request.job_id, + home.config.default_mint(), + &balances, + ) + .map_err(|error| error.to_string()) + }, ) .await?; let source_seed = accept_source_seed( diff --git a/crates/maxplayer-core/src/private_content/invoice.rs b/crates/maxplayer-core/src/private_content/invoice.rs index f7d221ffa..9b2bd7abf 100644 --- a/crates/maxplayer-core/src/private_content/invoice.rs +++ b/crates/maxplayer-core/src/private_content/invoice.rs @@ -115,7 +115,12 @@ pub fn validate( for mint in mints { let mint = text(mint)?; host.well_formed_mint(mint)?; - if !seen.insert(mint) { + // URL canonicalization also removes HTTPS's explicit default port, which + // MintUrl alone preserves. Keep the original signed bytes untouched. + let url = nostr_sdk::Url::parse(mint).map_err(|_| Error("invalid invoice mint"))?; + let normalized = + cashu::MintUrl::from_str(url.as_str()).map_err(|_| Error("invalid invoice mint"))?; + if !seen.insert(normalized.to_string()) { return Err(Error("duplicate invoice mint")); } } diff --git a/crates/maxplayer-core/src/private_content/tests.rs b/crates/maxplayer-core/src/private_content/tests.rs index fa9e688b3..7ac7bca60 100644 --- a/crates/maxplayer-core/src/private_content/tests.rs +++ b/crates/maxplayer-core/src/private_content/tests.rs @@ -1722,6 +1722,28 @@ fn private_invoice_accepts_well_formed_mixed_and_unknown_mints() { } } +#[cfg(feature = "wallet")] +#[test] +fn private_invoice_rejects_normalized_equal_mint_aliases() { + use cashu::nuts::nut18::PaymentRequest; + use nostr_sdk::prelude::{Nip19Profile, ToBech32}; + let offer = "ab".repeat(32); + let seller = keys(2).public_key(); + let profile = Nip19Profile::new(seller, []).to_bech32().unwrap(); + for alias in ["https://m.example/", "https://M.example:443"] { + let request: PaymentRequest = serde_json::from_value(serde_json::json!({ + "i":offer,"a":100,"u":"sat","s":true,"m":["https://m.example", alias],"d":null, + "t":[{"t":"nostr","a":profile,"g":[["n","17"]]}] + })) + .unwrap(); + for raw in [request.to_string(), request.to_bech32_string().unwrap()] { + let error = + invoice::validate(&raw, &offer, 100, &seller.to_hex(), &host()).unwrap_err(); + assert_eq!(error.to_string(), "duplicate invoice mint", "{alias}"); + } + } +} + #[cfg(feature = "wallet")] #[test] fn private_invoice_all_mints_guard_now_allows_seller_listed_hop_target() { From 4bb84c81beab40d37c7ff61b9e97019b816ce27f Mon Sep 17 00:00:00 2001 From: maxie-agent <330537927+maxie-agent@users.noreply.github.com> Date: Tue, 6 Oct 2026 03:14:28 +0000 Subject: [PATCH 3/3] fix: preserve wallet mint identity in seller invoices --- crates/maxplayer-core/src/crossmint.rs | 34 +++++++++++++++++++ crates/maxplayer-core/src/gateway.rs | 12 ++++--- .../src/private_content/invoice.rs | 7 ++-- .../src/private_content/tests.rs | 11 +++--- 4 files changed, 52 insertions(+), 12 deletions(-) diff --git a/crates/maxplayer-core/src/crossmint.rs b/crates/maxplayer-core/src/crossmint.rs index 42003273b..639cab17d 100644 --- a/crates/maxplayer-core/src/crossmint.rs +++ b/crates/maxplayer-core/src/crossmint.rs @@ -308,6 +308,40 @@ mod tests { MintUrl::from_str(url).expect("test mint url parses") } + #[test] + fn seller_creq_explicit_default_port_uses_held_mint_and_pays_direct() { + use crate::gateway::creq::{build_seller_creq, parse_creq}; + use crate::wallet_ops::{normalize_mint_url, MintBalance}; + + let configured = "https://mint.example:443"; + let price = 100; + let seller = nostr_sdk::Keys::generate().public_key().to_hex(); + let raw = build_seller_creq("port-regression", price, "sat", &[configured.into()], &seller) + .unwrap(); + let request = parse_creq(&raw).unwrap(); + let listed: Vec = request.mints.iter().map(ToString::to_string).collect(); + let balances = [MintBalance { + mint_url: normalize_mint_url(configured).unwrap(), + balance_sats: price, + is_default: true, + configured: true, + }]; + let source = select_source_mint(configured, &listed, true, &balances, price); + let plan = plan_payment(&source, &listed, true).unwrap(); + assert_eq!( + plan, + PayPlan::Direct { mint: mint(configured) }, + "a seller mint funded under the same :443 wallet identity must pay Direct, not Hop" + ); + assert!(holds_at_least(&balances, &listed[0], price)); + assert_eq!(source, configured); + // A different fallback proves selection used the covering row, not the default. + assert_eq!( + select_source_mint("https://unfunded.example", &listed, true, &balances, price), + configured + ); + } + // 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. diff --git a/crates/maxplayer-core/src/gateway.rs b/crates/maxplayer-core/src/gateway.rs index 40287b7b1..7f914afc8 100644 --- a/crates/maxplayer-core/src/gateway.rs +++ b/crates/maxplayer-core/src/gateway.rs @@ -1460,9 +1460,11 @@ pub mod creq { let mut mints = accepted_mints .iter() .map(|m| { - let url = - nostr_sdk::Url::parse(m).map_err(|e| CreqError::Mint(format!("{m}: {e}")))?; - MintUrl::from_str(url.as_str()).map_err(|e| CreqError::Mint(format!("{m}: {e}"))) + let mint = MintUrl::from_str(m) + .map_err(|e| CreqError::Mint(format!("{m}: {e}")))?; + // Validate URL structure without using Url's port-stripped spelling. + nostr_sdk::Url::parse(m).map_err(|e| CreqError::Mint(format!("{m}: {e}")))?; + Ok::<_, CreqError>(mint) }) .collect::, _>>()?; // Keep seller preference order, but never emit normalized-equal invoice mints. @@ -2240,7 +2242,9 @@ mod creq_tests { let mints = [ "https://M.example:443", "https://m.example/", + "https://M.example", "https://m.example", + "https://m.example:443/", "https://other.example", ] .map(str::to_owned); @@ -2252,7 +2256,7 @@ mod creq_tests { .iter() .map(ToString::to_string) .collect::>(), - ["https://m.example", "https://other.example"] + ["https://m.example:443", "https://m.example", "https://other.example"] ); } diff --git a/crates/maxplayer-core/src/private_content/invoice.rs b/crates/maxplayer-core/src/private_content/invoice.rs index 9b2bd7abf..e8f3c8f3e 100644 --- a/crates/maxplayer-core/src/private_content/invoice.rs +++ b/crates/maxplayer-core/src/private_content/invoice.rs @@ -115,11 +115,10 @@ pub fn validate( for mint in mints { let mint = text(mint)?; host.well_formed_mint(mint)?; - // URL canonicalization also removes HTTPS's explicit default port, which - // MintUrl alone preserves. Keep the original signed bytes untouched. - let url = nostr_sdk::Url::parse(mint).map_err(|_| Error("invalid invoice mint"))?; + // Match buyer wallet/reservation identity: fold case and trailing slashes, + // but preserve explicit ports. Keep the original signed bytes untouched. let normalized = - cashu::MintUrl::from_str(url.as_str()).map_err(|_| Error("invalid invoice mint"))?; + cashu::MintUrl::from_str(mint).map_err(|_| Error("invalid invoice mint"))?; if !seen.insert(normalized.to_string()) { return Err(Error("duplicate invoice mint")); } diff --git a/crates/maxplayer-core/src/private_content/tests.rs b/crates/maxplayer-core/src/private_content/tests.rs index 7ac7bca60..1c0a05d1a 100644 --- a/crates/maxplayer-core/src/private_content/tests.rs +++ b/crates/maxplayer-core/src/private_content/tests.rs @@ -1730,16 +1730,19 @@ fn private_invoice_rejects_normalized_equal_mint_aliases() { let offer = "ab".repeat(32); let seller = keys(2).public_key(); let profile = Nip19Profile::new(seller, []).to_bech32().unwrap(); - for alias in ["https://m.example/", "https://M.example:443"] { + for alias in ["https://m.example/", "https://M.example", "https://M.example:443"] { let request: PaymentRequest = serde_json::from_value(serde_json::json!({ "i":offer,"a":100,"u":"sat","s":true,"m":["https://m.example", alias],"d":null, "t":[{"t":"nostr","a":profile,"g":[["n","17"]]}] })) .unwrap(); for raw in [request.to_string(), request.to_bech32_string().unwrap()] { - let error = - invoice::validate(&raw, &offer, 100, &seller.to_hex(), &host()).unwrap_err(); - assert_eq!(error.to_string(), "duplicate invoice mint", "{alias}"); + let result = invoice::validate(&raw, &offer, 100, &seller.to_hex(), &host()); + if alias.ends_with(":443") { + assert!(result.is_ok(), "explicit port is a distinct wallet identity: {result:?}"); + } else { + assert_eq!(result.unwrap_err().to_string(), "duplicate invoice mint", "{alias}"); + } } } }