diff --git a/README.md b/README.md index 9157ddaf2..cadcf6fee 100644 --- a/README.md +++ b/README.md @@ -50,10 +50,6 @@ fund yourself; nothing is auto-funded. Jobs are paid in sats. maxplayer wallet mint-complete maxplayer wallet balance ``` - Not ready to spend real sats? The testnut dev mint settles its own invoices with play money — - `maxplayer wallet setup 21 --mint https://testnut.cashudevkit.org` funds instantly, nothing to - pay. Play sats only trade with sellers on that same dev mint; come back to the real invoice when - you want the live market. 2. Register the MCP with your agent — set `MAXPLAYER_HOME` on the server so it uses the right buyer: ```bash claude mcp add maxplayer -- env MAXPLAYER_HOME="$HOME/.maxplayer" maxplayer mcp diff --git a/crates/maxplayer-core/src/authorize_pay.rs b/crates/maxplayer-core/src/authorize_pay.rs index 4b01de22f..a41d3541a 100644 --- a/crates/maxplayer-core/src/authorize_pay.rs +++ b/crates/maxplayer-core/src/authorize_pay.rs @@ -75,7 +75,7 @@ pub struct AuthorizePayRequest { pub accepted_mints: Vec, /// The realized paying mint the buyer SELECTED for this job, sealed into the accept-bind at /// accept time and threaded here. When `Some`, the pay path derives the realized mint from THIS - /// (still enforcing accepted-set membership + the real-mint fence) instead of the live config + /// (still enforcing accepted-set membership) instead of the live config /// default, so the attempt id is stable across retries even if the buyer's config default /// changes between attempts (double-pay fence). `None` only for a legacy bind that predates the /// sealed field — the pay path then falls back to the live config default. @@ -495,7 +495,7 @@ pub async fn authorize_pay_async( let effects = CdkHopEffects::open( home, &source.to_string(), - &wallet_open_mint_url(home, &terms), + &wallet_open_mint_url(&terms), ) .await?; // A pairing already on disk WINS over freshly raised quotes. This attempt may have @@ -509,7 +509,7 @@ pub async fn authorize_pay_async( } }; - let wallet = buyer_fund::open_wallet_at_mint_async(home, &wallet_open_mint_url(home, &terms)) + let wallet = buyer_fund::open_wallet_at_mint_async(home, &wallet_open_mint_url(&terms)) .await?; // Wallet HTTP must run ONLY on the wallet worker, never on this caller runtime. A pre-spawn dust // check here ran on the current-thread runtime `collect_blocking` builds (collect.rs), priming a @@ -756,7 +756,7 @@ pub async fn complete_recovered_locked_async( // delivery used that kind, so completion reconstructs byte-identical bytes. let delivery_kind = DeliveryKind::Fork; - let wallet = buyer_fund::open_wallet_at_mint_async(home, &wallet_open_mint_url(home, &terms)) + let wallet = buyer_fund::open_wallet_at_mint_async(home, &wallet_open_mint_url(&terms)) .await?; let payment_send = NostrPaymentSend::new(home.config.relay_url.clone(), keys); let mut effects = CdkPaymentEffects::spawn( @@ -813,13 +813,13 @@ fn contribution_policy(home: &MaxplayerHome) -> crate::contribution::ContentPoli /// buyer flips its config default to B, bind the wallet to B while the attempt id + send target A — /// the budget is appended, then the send refuses on mint mismatch and strands the reservation. /// Taking the mint from the sealed terms keeps the wallet, the attempt id, and the send all on one -/// mint. `home` is passed so the already-fenced invariant is asserted at this seam (the realized -/// mint was fenced while planning; `open_wallet_at_mint_async` re-checks, redundant-safe). -pub(crate) fn wallet_open_mint_url(home: &MaxplayerHome, terms: &PaymentTerms) -> String { +/// mint. The sealed mint was already checked while planning; `open_wallet_at_mint_async` re-checks +/// its shape, redundant-safe. +pub(crate) fn wallet_open_mint_url(terms: &PaymentTerms) -> String { let mint_url = terms.mint.to_string(); debug_assert!( - crate::home::mint_allowed(&mint_url, home.config.allow_real_mints), - "frozen realized mint must already be fenced before wallet open" + crate::home::mint_url_supported(&mint_url), + "frozen realized mint must be a usable mint URL before wallet open" ); mint_url } @@ -893,12 +893,12 @@ struct DerivedPayment { /// identity inputs. This is the ONE derivation both [`authorize_pay_async`] and /// [`complete_recovered_locked_async`] call, so both compute the IDENTICAL attempt id for the same /// job — a re-derivation drift would target a different journal file and could double-pay. Pure -/// beyond reading the home config's default mint + real-mint policy. +/// beyond reading the home config's default mint. /// /// The realized mint is chosen from the seller's `creq` `m` list via the SELECTION frozen into the /// accept-bind (`realized_mint`), not the live config default — so a config-default change between /// attempts cannot shift the mint and mint a second attempt id. `plan_payment` still enforces -/// accepted-set membership + the real-mint fence over that selection, and plans a cross-mint hop +/// accepted-set membership over that selection, and plans a cross-mint hop /// when the selected mint is NOT in the seller's accepted set — a membership test over the mint /// URLs that reads no wallet balances; a legacy bind (no sealed mint) falls back to the live default. #[allow(clippy::too_many_arguments)] @@ -926,11 +926,7 @@ fn derive_payment( .map_err(|error| AuthorizePayError::Input(format!("seller_pubkey: {error}")))?; let seller_p2pk = cashu_compressed_from_nostr(&seller_nostr)?; let buyer_selected_mint = realized_mint.unwrap_or_else(|| home.config.default_mint()); - let plan = crate::crossmint::plan_payment( - buyer_selected_mint, - accepted_mints, - home.config.allow_real_mints, - )?; + let plan = crate::crossmint::plan_payment(buyer_selected_mint, accepted_mints)?; let terms = PaymentTerms::new( plan.realized_mint().clone(), Amount::from(amount_sats), @@ -1187,24 +1183,27 @@ fn publish_receipt_event( #[cfg(test)] mod tests { + /// Test fixture mint host — NOT a default. A mint is just a mint: what makes one usable is membership of + /// the home's configured list, which each test sets up explicitly. + const FIXTURE_MINT_URL: &str = "https://mint.example/Bitcoin"; use super::*; use cashu::MintUrl; use crate::budget::BudgetGate; - use crate::home::{self, DEFAULT_MINT_URL}; + use crate::home::{self}; - // A real (non-testnut) mint — admissible ONLY when `allow_real_mints` is true. + // A second mint fixture, distinct from FIXTURE_MINT_URL. const REAL_MINT: &str = "https://minibits.example"; // Empty creq list → pay from the buyer's configured mint (config-driven). // Default flag (false): the configured testnut/dev mint plans a direct payment. #[test] fn pay_plan_empty_creq_uses_configured_mint() { - let plan = crate::crossmint::plan_payment(DEFAULT_MINT_URL, &[], false).unwrap(); + let plan = crate::crossmint::plan_payment(FIXTURE_MINT_URL, &[]).unwrap(); assert!(!plan.is_hop()); assert_eq!( plan.realized_mint(), - &MintUrl::from_str(DEFAULT_MINT_URL).unwrap() + &MintUrl::from_str(FIXTURE_MINT_URL).unwrap() ); } @@ -1212,34 +1211,30 @@ mod tests { #[test] fn pay_plan_is_direct_when_configured_mint_is_listed() { let plan = crate::crossmint::plan_payment( - DEFAULT_MINT_URL, + FIXTURE_MINT_URL, &[ "https://other.example".to_string(), - DEFAULT_MINT_URL.to_string(), + FIXTURE_MINT_URL.to_string(), ], - false, ) .unwrap(); assert!(!plan.is_hop(), "overlap must not hop"); assert_eq!( plan.realized_mint(), - &MintUrl::from_str(DEFAULT_MINT_URL).unwrap() + &MintUrl::from_str(FIXTURE_MINT_URL).unwrap() ); } // The boundary, half one. A configured mint outside the creq list used to be the end of the // road for this claim; it is now a hop to a mint the seller does accept. // - // `allow_real_mints` is on because it has to be: with the flag off the fence admits exactly one - // mint (the testnut default), so a buyer and a seller can never be at two DIFFERENT admissible - // mints and a hop is structurally unreachable in the default posture. The flag is what makes - // two distinct admissible mints possible at all. + // Two DIFFERENT mints is the whole shape of a hop, and nothing gates that any more: the seller + // lists what it accepts, the buyer sits somewhere else, and the hop reaches across. #[test] fn pay_plan_hops_when_the_configured_mint_is_not_listed_but_the_target_is_admissible() { let plan = crate::crossmint::plan_payment( "https://buyer-only.example", - &[DEFAULT_MINT_URL.to_string()], - true, + &[FIXTURE_MINT_URL.to_string()], ) .unwrap(); assert!(plan.is_hop(), "no overlap must plan a hop, not refuse"); @@ -1249,72 +1244,49 @@ mod tests { ); assert_eq!( plan.realized_mint(), - &MintUrl::from_str(DEFAULT_MINT_URL).unwrap() + &MintUrl::from_str(FIXTURE_MINT_URL).unwrap() ); } - // The same no-overlap shape under the DEFAULT posture refuses, because the buyer's own mint is - // not admissible there — the fence stops it before a target is even considered. Together with - // the two tests around it this pins the whole boundary: a hop needs the operator's opt-in AND an - // admissible landing, and the fence refuses first when either is missing. + // NEGATIVE, one-mint form: a buyer mint that is not a mint URL at all refuses before a target + // is even considered — the only thing left to refuse on. #[test] - fn pay_plan_refuses_a_no_overlap_hop_under_the_default_posture() { - let error = crate::crossmint::plan_payment( - "https://buyer-only.example", - &[DEFAULT_MINT_URL.to_string()], - false, - ) - .unwrap_err(); + fn pay_plan_refuses_a_buyer_mint_that_is_not_a_mint_url() { + let error = crate::crossmint::plan_payment("not-a-url", &[FIXTURE_MINT_URL.to_string()]) + .unwrap_err(); assert!( - error.to_string().contains("real-mint fence"), + error.to_string().contains("not a usable mint URL"), "got: {error}" ); } - // The boundary, half two. No overlap AND no admissible landing still refuses fail-closed — the - // target fence is the refusal that now covers what the old membership check covered. This pair - // pins exactly where "hop" ends and "refuse" begins. + // The boundary: no overlap and no landing that is a mint URL still refuses fail-closed. #[test] - fn pay_plan_refuses_when_no_overlap_and_no_accepted_mint_is_admissible() { - let error = - crate::crossmint::plan_payment(DEFAULT_MINT_URL, &[REAL_MINT.to_string()], false) - .unwrap_err(); + fn pay_plan_refuses_when_no_overlap_and_no_accepted_mint_is_usable() { + let error = crate::crossmint::plan_payment(FIXTURE_MINT_URL, &["ftp://nope.example".to_string()]) + .unwrap_err(); assert!(matches!(error, AuthorizePayError::Input(_))); let rendered = error.to_string(); - assert!(rendered.contains("real-mint fence"), "got: {rendered}"); - assert!( - rendered.contains("nowhere permitted to land"), - "got: {rendered}" - ); + assert!(rendered.contains("nowhere to land"), "got: {rendered}"); } - // Real-mint switch: a buyer configured at a real mint X is REFUSED by the fence when the - // operator sets `allow_real_mints = false` (opt-out; since #378 the default is true)... + // A mint is just a mint: any mint the creq lists is payable, with no second switch to flip. + // The seller's own accepted list is what decided it, and it is the creq's contents. #[test] - fn pay_plan_real_mint_refused_when_flag_false() { - let error = - crate::crossmint::plan_payment(REAL_MINT, &[REAL_MINT.to_string()], false).unwrap_err(); - assert!(matches!(error, AuthorizePayError::Input(_))); - assert!(error.to_string().contains("real-mint fence")); - } - - // ...and ADMITTED (pays at X when the creq lists X) once the operator opts in with the flag. - #[test] - fn pay_plan_real_mint_admitted_when_flag_true() { + fn pay_plan_pays_at_any_mint_the_creq_lists() { let plan = - crate::crossmint::plan_payment(REAL_MINT, &[REAL_MINT.to_string()], true).unwrap(); + crate::crossmint::plan_payment(REAL_MINT, &[REAL_MINT.to_string()]).unwrap(); assert!(!plan.is_hop()); assert_eq!(plan.realized_mint(), &MintUrl::from_str(REAL_MINT).unwrap()); - // With the flag on, a creq that lists a DIFFERENT admissible mint is now reachable by hop - // rather than refused for non-membership. + // A creq that lists a DIFFERENT mint is reachable by hop, not refused for non-membership. let hopped = - crate::crossmint::plan_payment(REAL_MINT, &[DEFAULT_MINT_URL.to_string()], true) + crate::crossmint::plan_payment(REAL_MINT, &[FIXTURE_MINT_URL.to_string()]) .unwrap(); assert!(hopped.is_hop()); assert_eq!( hopped.realized_mint(), - &MintUrl::from_str(DEFAULT_MINT_URL).unwrap() + &MintUrl::from_str(FIXTURE_MINT_URL).unwrap() ); } @@ -1570,11 +1542,11 @@ mod tests { } // The replacement invariant, at the pay entry point. `mint_unreachable_pay` no longer refuses a - // buyer that cannot settle at the seller's mint — the hop does, or the fence does. This pins the - // fence half END TO END: a hop with nowhere admissible to land refuses through the real pay - // path, burns zero budget, and leaves no pairing on disk for a later run to resume. + // buyer that cannot settle at the seller's mint — the hop does. This pins that half END TO END: + // a hop with nowhere USABLE to land refuses through the real pay path, burns zero budget, and + // leaves no pairing on disk for a later run to resume. #[test] - fn authorize_pay_refuses_an_inadmissible_hop_with_zero_spend_and_no_pairing() { + fn authorize_pay_refuses_an_unusable_hop_with_zero_spend_and_no_pairing() { let root = std::env::temp_dir().join(format!( "maxplayer-authorize-pay-hop-fence-{}-{}", std::process::id(), @@ -1584,14 +1556,7 @@ mod tests { .as_nanos() )); let _ = std::fs::remove_dir_all(&root); - let mut home = home::bootstrap(&root).expect("home"); - // Issue #378 made allow_real_mints default TRUE; force the fenced posture this test needs so - // the seller's real mint stays inadmissible and the hop has nowhere to land. - home.config.allow_real_mints = false; - assert!( - !home.config.allow_real_mints, - "fenced posture (allow_real_mints = false) is what makes this refuse" - ); + let home = home::bootstrap(&root).expect("home"); let mut gate = BudgetGate::from_home(&home).expect("gate"); let request = AuthorizePayRequest { job_id: "job-hop-fence".into(), @@ -1606,19 +1571,19 @@ mod tests { commit_oid: "aa".repeat(20), seller_signature: String::new(), creq_hash: None, - // The buyer sits on the one fenced mint; the seller accepts only an unfenced one, so - // there is nowhere the hop is permitted to land. - accepted_mints: vec![REAL_MINT.to_string()], - realized_mint: Some(DEFAULT_MINT_URL.to_string()), + // The seller's creq lists only something this wallet cannot speak to, so the hop has + // nowhere to land. (`ftp://` parses as a URL, so the refusal here is the mint-shape + // rule, not a parse error.) + accepted_mints: vec!["ftp://not-a-mint.example".to_string()], + realized_mint: Some(FIXTURE_MINT_URL.to_string()), contribution: None, }; let error = authorize_pay_blocking(&home, &mut gate, request) .expect_err("an inadmissible hop target must refuse"); let message = error.to_string(); - assert!(message.contains("real-mint fence"), "unexpected: {message}"); assert!( - message.contains("nowhere permitted to land"), - "the refusal must say the hop had no permitted target: {message}" + message.contains("nowhere to land"), + "the refusal must say the hop had no target: {message}" ); assert_eq!(gate.spent(), 0, "a refused hop must not burn budget"); assert!( @@ -1805,7 +1770,7 @@ mod tests { ) }; let default_mint = - receipt_preimage_for(&key_at(DEFAULT_MINT_URL), &buyer, &seller, DeliveryKind::Fork); + receipt_preimage_for(&key_at(FIXTURE_MINT_URL), &buyer, &seller, DeliveryKind::Fork); let other_mint = receipt_preimage_for( &key_at("https://other-accepted.testnut.example"), &buyer, @@ -1828,9 +1793,8 @@ mod tests { // The control asserts the pre-CC legacy (unsealed) path DIVERGES — exactly the double-pay vector. #[test] fn sealed_realized_mint_stabilizes_attempt_id_across_config_default_change() { - // Two distinct admissible paying mints, both in the seller's accepted set. `allow_real_mints` - // is on so two DIFFERENT mints can both pass the fence (with it off only DEFAULT_MINT_URL - // does, and there'd be no second admissible mint to shift to). + // Two distinct paying mints, both in the seller's accepted set — the config default can + // therefore shift between them, which is exactly what the seal has to survive. let m1 = "https://mint-a.example"; let m2 = "https://mint-b.example"; let accepted = vec![m1.to_string(), m2.to_string()]; @@ -1859,7 +1823,7 @@ mod tests { // a legacy bind (`None`) falls back to the live config default. let select = |sealed: Option<&str>, config_default: &str| { let chosen = sealed.unwrap_or(config_default); - crate::crossmint::plan_payment(chosen, &accepted, true) + crate::crossmint::plan_payment(chosen, &accepted) .expect("plans") .realized_mint() .clone() @@ -1905,7 +1869,7 @@ mod tests { let seller_nostr = NostrPublicKey::parse(&hex).expect("seller nostr"); let seller_p2pk = cashu_compressed_from_nostr(&seller_nostr).expect("p2pk"); let terms = PaymentTerms::new( - MintUrl::from_str(DEFAULT_MINT_URL).expect("mint"), + MintUrl::from_str(FIXTURE_MINT_URL).expect("mint"), Amount::from(amount_sats), CurrencyUnit::Sat, seller_nostr, @@ -2027,7 +1991,7 @@ mod tests { commit_oid: oid.clone(), seller_signature: forged_sig, creq_hash: Some(creq_hash.clone()), - accepted_mints: vec![DEFAULT_MINT_URL.to_string()], + accepted_mints: vec![FIXTURE_MINT_URL.to_string()], realized_mint: None, contribution: None, }; diff --git a/crates/maxplayer-core/src/buyer/lifecycle.rs b/crates/maxplayer-core/src/buyer/lifecycle.rs index 11749f253..86cdb1ead 100644 --- a/crates/maxplayer-core/src/buyer/lifecycle.rs +++ b/crates/maxplayer-core/src/buyer/lifecycle.rs @@ -40,8 +40,6 @@ 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, - /// 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 /// signed offer is the authority for what the job requested). `None` ⇒ no preference and every /// claim passes this filter unchanged. @@ -80,18 +78,16 @@ pub struct AwardFilters<'a> { /// stronger test. /// /// Everything filterable comes from the OFFER; only the money context is passed in, because the -/// buyer's mint and its real-mint policy are properties of the buyer rather than of the job. +/// buyer's own paying mint is a property of the buyer rather than of the job. pub fn award_filters_for_offer<'a>( offer: &'a OfferView, max_sats: u64, buyer_mint: &'a str, - allow_real_mints: bool, ) -> AwardFilters<'a> { AwardFilters { offer_amount_sats: offer.amount_sats, max_sats, buyer_mint, - allow_real_mints, requested_agent: offer.requested_agent.as_deref(), requested_harness_family: offer.requested_harness_family.as_deref(), requested_model: offer.requested_model.as_deref(), @@ -409,7 +405,6 @@ pub fn unsatisfiable_capability_request( offer_amount_sats: 0, max_sats: 0, buyer_mint: "", - allow_real_mints: false, requested_agent, requested_harness_family, requested_model, @@ -608,7 +603,7 @@ fn claim_is_payable(job_id: &str, creq: Option<&str>, filters: &AwardFilters) -> // 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() + plan_payment(filters.buyer_mint, &listed).is_ok() } /// What [`award_with_reservation`] may do about a job, decided BEFORE any reserve, sign, or send. @@ -1521,10 +1516,13 @@ pub fn apply_unattempted_floor( #[cfg(test)] mod tests { + /// Test fixture mint host — NOT a default. A mint is just a mint: what makes one usable is membership of + /// the home's configured list, which each test sets up explicitly. + const FIXTURE_MINT_URL: &str = "https://mint.example/Bitcoin"; use super::*; use crate::budget::BudgetGate; use crate::gateway::creq::build_seller_creq; - use crate::home::{self, DEFAULT_MINT_URL}; + use crate::home::{self}; use crate::job_lifecycle::{ClaimView, OfferView, RelayedAward}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; @@ -1608,8 +1606,7 @@ mod tests { AwardFilters { offer_amount_sats: offer_amount, max_sats, - buyer_mint: DEFAULT_MINT_URL, - allow_real_mints: false, + buyer_mint: FIXTURE_MINT_URL, requested_agent: None, requested_harness_family: None, requested_model: None, @@ -1621,7 +1618,7 @@ mod tests { #[test] fn select_picks_live_payable_claim() { let job = "a".repeat(64); - let view = view_with(&job, 10, vec![claim(&job, true, 10, &[DEFAULT_MINT_URL.into()])]); + let view = view_with(&job, 10, vec![claim(&job, true, 10, &[FIXTURE_MINT_URL.into()])]); let selected = select_awardable_claim(&view, &filters(10, 100)); assert_eq!(selected.as_deref(), Some("c".repeat(64).as_str())); } @@ -1634,9 +1631,9 @@ mod tests { #[test] fn a_job_requesting_a_harness_is_never_awarded_to_a_claim_without_it() { let job = "a".repeat(64); - let mut codex_only = claim(&job, true, 10, &[DEFAULT_MINT_URL.into()]); + let mut codex_only = claim(&job, true, 10, &[FIXTURE_MINT_URL.into()]); codex_only.agents = vec!["codex".to_owned()]; - let mut silent = claim(&job, true, 10, &[DEFAULT_MINT_URL.into()]); + let mut silent = claim(&job, true, 10, &[FIXTURE_MINT_URL.into()]); silent.claim_id = "d".repeat(64); let view = view_with(&job, 10, vec![codex_only, silent]); @@ -1685,7 +1682,7 @@ mod tests { #[test] fn every_award_entry_point_applies_the_capability_predicate() { let job = "a".repeat(64); - let mut named = claim(&job, true, 10, &[DEFAULT_MINT_URL.into()]); + let mut named = claim(&job, true, 10, &[FIXTURE_MINT_URL.into()]); named.capability = crate::heartbeat::SeatCapability { harness_families: vec!["codex".to_owned()], models: vec![crate::heartbeat::HarnessModel { @@ -1771,7 +1768,7 @@ mod tests { #[test] fn no_harness_request_awards_exactly_as_before() { let job = "a".repeat(64); - let view = view_with(&job, 10, vec![claim(&job, true, 10, &[DEFAULT_MINT_URL.into()])]); + let view = view_with(&job, 10, vec![claim(&job, true, 10, &[FIXTURE_MINT_URL.into()])]); let unfiltered = filters(10, 100); assert!(unfiltered.requested_agent.is_none()); assert_eq!( @@ -1791,29 +1788,43 @@ mod tests { #[test] fn select_skips_non_live_claim() { let job = "a".repeat(64); - let view = view_with(&job, 10, vec![claim(&job, false, 10, &[DEFAULT_MINT_URL.into()])]); + let view = view_with(&job, 10, vec![claim(&job, false, 10, &[FIXTURE_MINT_URL.into()])]); assert_eq!(select_awardable_claim(&view, &filters(10, 100)), None); } - // Mint compatibility is a HARD filter: a live claim quoting only a mint the buyer cannot pay - // from is skipped — the buyer must never auto-award a claim it cannot settle. + // Mint compatibility is a HARD filter: a live claim the buyer cannot plan a payment to is + // skipped — the buyer must never auto-award a claim it cannot settle. A seller mint the buyer + // does not hold is NOT such a claim (the buyer hops to it); a creq that lists nothing this + // wallet can speak to is. #[test] fn select_skips_claim_with_no_payable_mint() { let job = "a".repeat(64); - // The seller lists only a foreign testnut mint; the buyer's default mint is not among it. let view = view_with( &job, 10, - vec![claim(&job, true, 10, &["https://foreign.testnut.example".into()])], + vec![claim(&job, true, 10, &["ftp://not-a-mint.example".into()])], ); assert_eq!(select_awardable_claim(&view, &filters(10, 100)), None); } + // The other half: a seller mint the buyer simply does not hold IS payable — a hop reaches it. + // Together these pin where "payable" ends now that a mint is just a mint. + #[test] + fn select_takes_a_claim_at_a_mint_the_buyer_does_not_hold() { + let job = "a".repeat(64); + let view = view_with( + &job, + 10, + vec![claim(&job, true, 10, &["https://foreign-mint.example".into()])], + ); + assert!(select_awardable_claim(&view, &filters(10, 100)).is_some()); + } + // Over the buyer's ceiling: an offer amount above max_sats yields no selection. #[test] fn select_skips_when_offer_over_max_sats() { let job = "a".repeat(64); - let view = view_with(&job, 50, vec![claim(&job, true, 50, &[DEFAULT_MINT_URL.into()])]); + let view = view_with(&job, 50, vec![claim(&job, true, 50, &[FIXTURE_MINT_URL.into()])]); assert_eq!(select_awardable_claim(&view, &filters(50, 40)), None); } @@ -1822,7 +1833,7 @@ mod tests { #[test] fn select_skips_claim_priced_off_the_offer() { let job = "a".repeat(64); - let view = view_with(&job, 10, vec![claim(&job, true, 11, &[DEFAULT_MINT_URL.into()])]); + let view = view_with(&job, 10, vec![claim(&job, true, 11, &[FIXTURE_MINT_URL.into()])]); assert_eq!(select_awardable_claim(&view, &filters(10, 100)), None); } @@ -1832,7 +1843,7 @@ mod tests { fn named_claim_awardable_accepts_live_payable_within_max() { let job = "a".repeat(64); let claim_id = "c".repeat(64); - let view = view_with(&job, 10, vec![claim(&job, true, 10, &[DEFAULT_MINT_URL.into()])]); + let view = view_with(&job, 10, vec![claim(&job, true, 10, &[FIXTURE_MINT_URL.into()])]); assert_eq!(named_claim_awardable(&view, &claim_id, &filters(10, 100)), Ok(())); } @@ -1842,7 +1853,7 @@ mod tests { fn named_claim_over_max_sats_refused() { let job = "a".repeat(64); let claim_id = "c".repeat(64); - let view = view_with(&job, 50, vec![claim(&job, true, 50, &[DEFAULT_MINT_URL.into()])]); + let view = view_with(&job, 50, vec![claim(&job, true, 50, &[FIXTURE_MINT_URL.into()])]); assert_eq!( named_claim_awardable(&view, &claim_id, &filters(50, 40)), Err(NamedAwardRefused::OverMax { offer_amount_sats: 50, max_sats: 40 }) @@ -1853,7 +1864,7 @@ mod tests { #[test] fn named_claim_not_found_refused() { let job = "a".repeat(64); - let view = view_with(&job, 10, vec![claim(&job, true, 10, &[DEFAULT_MINT_URL.into()])]); + let view = view_with(&job, 10, vec![claim(&job, true, 10, &[FIXTURE_MINT_URL.into()])]); let missing = "d".repeat(64); assert_eq!( named_claim_awardable(&view, &missing, &filters(10, 100)), @@ -1866,7 +1877,7 @@ mod tests { fn named_claim_not_live_refused() { let job = "a".repeat(64); let claim_id = "c".repeat(64); - let view = view_with(&job, 10, vec![claim(&job, false, 10, &[DEFAULT_MINT_URL.into()])]); + let view = view_with(&job, 10, vec![claim(&job, false, 10, &[FIXTURE_MINT_URL.into()])]); assert_eq!( named_claim_awardable(&view, &claim_id, &filters(10, 100)), Err(NamedAwardRefused::NotLive { claim_id }) @@ -1882,7 +1893,7 @@ mod tests { let view = view_with( &job, 10, - vec![claim(&job, true, 10, &["https://foreign.testnut.example".into()])], + vec![claim(&job, true, 10, &["ftp://not-a-mint.example".into()])], ); assert_eq!( named_claim_awardable(&view, &claim_id, &filters(10, 100)), @@ -3872,7 +3883,7 @@ mod tests { fn a_foreign_offer_naming_a_bogus_family_is_refused_on_both_paths_and_parks_blaming_the_request() { let job = "a".repeat(64); - let mints = vec![DEFAULT_MINT_URL.to_owned()]; + let mints = vec![FIXTURE_MINT_URL.to_owned()]; let claim_id = "c".repeat(64); // A claim that is payable, live, and advertises exactly the bogus family requested. Every @@ -3955,7 +3966,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, FIXTURE_MINT_URL) } // THE ACCEPTANCE TEST FOR #897, both axes through BOTH selection entry points. @@ -3970,7 +3981,7 @@ mod tests { #[test] fn an_offer_sourced_request_refuses_a_non_matching_claim_on_both_paths() { let job = "a".repeat(64); - let mints = vec![DEFAULT_MINT_URL.to_owned()]; + let mints = vec![FIXTURE_MINT_URL.to_owned()]; let claim_id = "c".repeat(64); let mut payable = claim(&job, true, 10, &mints); payable.capability = seat(&["codex"], &[], &["rust"]); @@ -4226,7 +4237,7 @@ mod tests { #[test] fn a_request_matching_no_claim_parks_with_an_actionable_reason() { let job = "a".repeat(64); - let mints = vec![DEFAULT_MINT_URL.to_owned()]; + let mints = vec![FIXTURE_MINT_URL.to_owned()]; let mut payable = claim(&job, true, 10, &mints); payable.capability = seat(&["codex"], &[], &[]); @@ -4267,7 +4278,7 @@ mod tests { #[test] fn a_foreign_model_only_offer_is_refused_at_award_and_parks_saying_why() { let job = "a".repeat(64); - let mints = vec![DEFAULT_MINT_URL.to_owned()]; + let mints = vec![FIXTURE_MINT_URL.to_owned()]; let claim_id = "c".repeat(64); // A seat that genuinely advertises the model, under a family. Even this claim must be refused: // the defect is in the REQUEST, and a claim-blaming refusal would send an operator to fix a @@ -4313,7 +4324,7 @@ mod tests { #[test] fn the_park_reason_declines_to_blame_capability_when_it_was_not_the_obstacle() { let job = "a".repeat(64); - let mints = vec![DEFAULT_MINT_URL.to_owned()]; + let mints = vec![FIXTURE_MINT_URL.to_owned()]; let mut matching = claim(&job, true, 10, &mints); matching.capability = seat(&["codex"], &[], &[]); @@ -4365,7 +4376,7 @@ mod tests { #[test] fn the_capability_clause_survives_the_real_deadline_demotion() { let job = "a".repeat(64); - let mints = vec![DEFAULT_MINT_URL.to_owned()]; + let mints = vec![FIXTURE_MINT_URL.to_owned()]; let deadline = 1_000_u64; let park_row_for = |capability: crate::heartbeat::SeatCapability, requested| { @@ -4550,7 +4561,7 @@ mod tests { #[test] fn select_awardable_claim_consults_the_capability_predicate() { let job = "a".repeat(64); - let mints = vec![DEFAULT_MINT_URL.to_owned()]; + let mints = vec![FIXTURE_MINT_URL.to_owned()]; let mut payable = claim(&job, true, 10, &mints); payable.capability = seat(&["codex"], &[], &[]); let view = view_with(&job, 10, vec![payable]); diff --git a/crates/maxplayer-core/src/buyer/mod.rs b/crates/maxplayer-core/src/buyer/mod.rs index 3e935c77e..21c65f8b2 100644 --- a/crates/maxplayer-core/src/buyer/mod.rs +++ b/crates/maxplayer-core/src/buyer/mod.rs @@ -826,7 +826,6 @@ async fn award(context: &BuyerContext, id: Value, params: Value) -> Response { offer, max_sats, context.home.config.default_mint(), - context.home.config.allow_real_mints, ); // Manual award names the claim but applies the SAME hard filters as auto-award — @@ -1316,7 +1315,6 @@ async fn drive_auto_award( offer, max_sats, context.home.config.default_mint(), - context.home.config.allow_real_mints, ); // Built AFTER `filters` so the deadline park can name the capability request that refused @@ -3019,6 +3017,9 @@ async fn status(context: &BuyerContext, id: Value) -> Response { #[cfg(test)] mod tests { + /// Test fixture mint host — NOT a default. A mint is just a mint: what makes one usable is membership of + /// the home's configured list, which each test sets up explicitly. + const FIXTURE_MINT_URL: &str = "https://mint.example/Bitcoin"; /// Every pre-existing reconcile test runs with the local-clock floor OFF, which is how it /// ships. Naming it here means each call site states that rather than leaving it implied. const FLOOR_OFF: lifecycle::UnattemptedFloor = lifecycle::UnattemptedFloor { @@ -5781,7 +5782,7 @@ mod tests { &job_id, amount, "sat", - &[crate::home::DEFAULT_MINT_URL.to_string()], + &[FIXTURE_MINT_URL.to_string()], &seller_hex, ) .expect("creq"); diff --git a/crates/maxplayer-core/src/buyer_fund.rs b/crates/maxplayer-core/src/buyer_fund.rs index 675511c14..81ce5bfa0 100644 --- a/crates/maxplayer-core/src/buyer_fund.rs +++ b/crates/maxplayer-core/src/buyer_fund.rs @@ -1,7 +1,8 @@ //! Buyer wallet setup for packaged `~/.maxplayer`: open the CDK wallet at a mint, derive its seed from //! the nostr secret, and read its balance. The wallet opens at the configured mint //! ([`crate::home::MaxplayerConfig::default_mint`]) or at an explicit realized mint -//! ([`open_wallet_at_mint_async`]); the real-mint fence gates non-testnut mints. +//! ([`open_wallet_at_mint_async`]). Which mints this seat may use is its configured list; the open +//! path only checks the URL is a usable mint URL. //! //! Funding a wallet (mint quote → surface the bolt11 invoice → wait for payment → mint) lives in //! [`crate::wallet_ops`]: `begin_mint_async` returns the invoice up front and `complete_mint_async` @@ -21,8 +22,9 @@ use crate::home::{self, HomeError, MaxplayerHome}; pub enum FundError { Home(HomeError), Wallet(String), - /// The configured mint is not permitted under the real-mint fence (issue #49): a real mint with - /// `allow_real_mints == false`. Fail-closed before opening/quoting the wallet. + /// The mint URL is not a well-formed `http://` / `https://` mint URL. Fail-closed before + /// opening/quoting the wallet. Mint POLICY is not decided here: the seat's configured list is + /// the only gate, and it is applied by the callers that own a config. MintNotAllowed { mint_url: String }, } @@ -33,7 +35,7 @@ impl std::fmt::Display for FundError { Self::Wallet(message) => write!(formatter, "wallet fund error: {message}"), Self::MintNotAllowed { mint_url } => write!( formatter, - "mint {mint_url} not allowed (allow_real_mints is off; set MAXPLAYER_ALLOW_REAL_MINTS to opt in)" + "mint {mint_url} is not a usable mint URL (expected http:// or https:// with a host)" ), } } @@ -86,11 +88,11 @@ pub async fn open_wallet_at_mint_async( home: &MaxplayerHome, mint_url: &str, ) -> Result { - // Real-mint fence (issue #49): fail closed BEFORE opening/quoting if this mint is a real mint - // and the operator has not opted in (`allow_real_mints == false`), the same gate the - // send/melt/receive paths enforce. Callers may have already fenced the realized mint; this - // re-checks so the helper is safe on its own. - if !home::mint_allowed(mint_url, home.config.allow_real_mints) { + // Shape check only: refuse a string that could never be a mint before opening/quoting a + // wallet at it. WHICH mints this seat may use is decided by its configured list, in the callers + // that own the config (`wallet_ops::mint_is_allowed`, the creq accepted set) — there is no + // second policy gate here. + if !home::mint_url_supported(mint_url) { return Err(FundError::MintNotAllowed { mint_url: mint_url.to_owned(), }); @@ -153,16 +155,15 @@ mod tests { assert_eq!(a.len(), 64); } - // The wallet opens at the CONFIGURED mint, not a compile-time pin — a buyer - // configured at a non-default mint spends from that mint (no `MintPinned` refusal). A real - // (non-testnut) mint requires the operator opt-in (`allow_real_mints`; see the real-mint fence). + // The wallet opens at the CONFIGURED mint, not a compile-time pin — a buyer configured at a + // non-default mint spends from that mint (no `MintPinned` refusal). A mint is just a mint: what + // decides is the seat's configured list, which this test sets. #[test] fn wallet_opens_at_the_configured_mint() { let root = temp_home("cfg-mint"); let _ = std::fs::remove_dir_all(&root); let mut home = bootstrap(&root).expect("bootstrap"); home.config.accepted_mints = vec!["https://minibits.example".into()]; - home.config.allow_real_mints = true; let wallet = open_wallet_blocking(&home).expect("open at configured mint"); assert_eq!(wallet.mint_url.to_string(), "https://minibits.example"); let _ = std::fs::remove_dir_all(&root); @@ -179,12 +180,11 @@ mod tests { let mut home = bootstrap(&root).expect("bootstrap"); // accepted[0] (== default) is the seller default; accepted[1] is the realized mint. home.config.accepted_mints = vec![ - "https://default-testnut.example".into(), - "https://realized-testnut.example".into(), + "https://default-mint.example".into(), + "https://realized-mint.example".into(), ]; - home.config.allow_real_mints = true; - assert_eq!(home.config.default_mint(), "https://default-testnut.example"); - let realized = "https://realized-testnut.example"; + assert_eq!(home.config.default_mint(), "https://default-mint.example"); + let realized = "https://realized-mint.example"; let wallet = open_wallet_at_mint_async(&home, realized) .await .expect("open at realized mint"); @@ -193,21 +193,32 @@ mod tests { let _ = std::fs::remove_dir_all(&root); } - // Finding T(2): opening the buyer wallet fails closed on a non-allowlisted REAL mint when - // allow_real_mints=false — BEFORE any network open/quote — the same fence send/melt/receive - // enforce. With the opt-in it opens (covered by `wallet_opens_at_the_configured_mint`). + // Finding T(2), one-mint form: the wallet open fails closed BEFORE any network open/quote on a + // string that is not a usable mint URL, and opens on either scheme when it is. There is no + // real/test mint distinction left to fence on — `http://` is admitted deliberately, because a + // seat's own sidecar mint runs on loopback. #[test] - fn open_wallet_refuses_real_mint_when_disallowed() { - let root = temp_home("open-real-mint-fence"); + fn open_wallet_refuses_a_url_that_is_not_a_mint_and_admits_both_schemes() { + let root = temp_home("open-mint-url-shape"); let _ = std::fs::remove_dir_all(&root); let mut home = bootstrap(&root).expect("bootstrap"); - home.config.accepted_mints = vec!["https://real-mint.example/".into()]; - home.config.allow_real_mints = false; - let err = open_wallet_blocking(&home).expect_err("real mint must refuse under allow_real_mints=false"); - assert!( - matches!(&err, FundError::MintNotAllowed { mint_url } if mint_url == "https://real-mint.example/"), - "expected MintNotAllowed, got {err:?}" - ); + + for bad in ["not-a-url", "ftp://mint.example", "https://", ""] { + home.config.accepted_mints = vec![bad.to_owned()]; + let err = open_wallet_blocking(&home) + .expect_err("a string that is not a mint URL must refuse before any network touch"); + assert!( + matches!(&err, FundError::MintNotAllowed { mint_url } if mint_url == bad), + "expected MintNotAllowed for {bad:?}, got {err:?}" + ); + } + + for good in ["https://real-mint.example/", "http://127.0.0.1:3338"] { + home.config.accepted_mints = vec![good.to_owned()]; + let wallet = open_wallet_blocking(&home) + .unwrap_or_else(|error| panic!("a configured {good} must open: {error:?}")); + assert_eq!(wallet.mint_url.to_string(), good.trim_end_matches('/')); + } let _ = std::fs::remove_dir_all(&root); } @@ -227,8 +238,8 @@ mod tests { #[test] fn default_config_mint_is_the_shipped_minibits_default() { - // Issue #378 flipped the shipped default mint from testnut to a REAL minibits mint (paired - // with allow_real_mints = true). A fresh config's default mint is that minibits mint. + // Issue #378 flipped the shipped default mint to a minibits mint. A fresh config's default + // mint is that minibits mint. assert_eq!( MaxplayerConfig::default().default_mint(), crate::home::DEFAULT_MINIBITS_MINT_URL diff --git a/crates/maxplayer-core/src/collect.rs b/crates/maxplayer-core/src/collect.rs index 2e0fa3c1f..f82b8614e 100644 --- a/crates/maxplayer-core/src/collect.rs +++ b/crates/maxplayer-core/src/collect.rs @@ -254,6 +254,9 @@ pub fn materialize_delivery( #[cfg(test)] mod tests { + /// Test fixture mint host — NOT a default. A mint is just a mint: what makes one usable is membership of + /// the home's configured list, which each test sets up explicitly. + const FIXTURE_MINT_URL: &str = "https://mint.example/Bitcoin"; use super::*; use crate::home; use crate::job_lifecycle::AcceptedBind; @@ -539,7 +542,7 @@ mod tests { &offer_id, amount, "sat", - &[crate::home::DEFAULT_MINT_URL.to_string()], + &[FIXTURE_MINT_URL.to_string()], &seller_hex, ) .expect("creq"); diff --git a/crates/maxplayer-core/src/crossmint.rs b/crates/maxplayer-core/src/crossmint.rs index 283d56fd9..8fa324f25 100644 --- a/crates/maxplayer-core/src/crossmint.rs +++ b/crates/maxplayer-core/src/crossmint.rs @@ -83,9 +83,10 @@ impl PayPlan { /// config default — a config change between attempts must not shift the mint and mint a second attempt /// id. /// -/// Both the source and the target pass the real-mint fence. Fencing the target matters as much as the -/// source: a hop ends with the buyer holding ecash at the target, so an unfenced target would let a -/// real-sats mint in through the back door while `allow_real_mints` is off. +/// Both the source and the target must be usable mint URLs. There is no real-mint/test-mint policy: +/// which mints may be paid at is decided by the seller's `accepted_mints` (that list is the creq's +/// contents) and by the buyer's own configured list — this function only refuses a string that is +/// not a mint URL at all. /// /// Target selection is the FIRST admissible entry of `accepted_mints` — the seller's list order is /// their preference. It must stay deterministic: the attempt id is derived from the realized mint, so @@ -94,12 +95,11 @@ impl PayPlan { pub fn plan_payment( buyer_selected_mint: &str, accepted_mints: &[String], - allow_real_mints: bool, ) -> Result { - if !home::mint_allowed(buyer_selected_mint, allow_real_mints) { + if !home::mint_url_supported(buyer_selected_mint) { return Err(AuthorizePayError::Input(format!( - "real-mint fence: buyer mint {buyer_selected_mint} is not an allow-listed testnut/dev \ - mint; set allow_real_mints=true to pay at a real mint" + "buyer mint {buyer_selected_mint} is not a usable mint URL (expected http:// or \ + https:// with a host)" ))); } let buyer_mint = MintUrl::from_str(buyer_selected_mint) @@ -120,12 +120,12 @@ pub fn plan_payment( return Ok(PayPlan::Direct { mint: buyer_mint }); } - // No overlap. Hop to the first accepted mint that the fence admits; refuse fail-closed if none - // does, rather than hopping to a mint we are not permitted to hold ecash at. + // No overlap. Hop to the first accepted mint that is a usable mint URL; refuse fail-closed if + // none is, rather than hopping to something we cannot hold ecash at. let target = accepted_mints .iter() .zip(listed) - .find(|(raw, _)| home::mint_allowed(raw, allow_real_mints)) + .find(|(_, parsed)| home::mint_url_supported(&parsed.to_string())) .map(|(_, parsed)| parsed); match target { @@ -134,10 +134,8 @@ pub fn plan_payment( target, }), None => Err(AuthorizePayError::Input(format!( - "real-mint fence: buyer mint {buyer_mint} is not in the creq mint list \ - {accepted_mints:?} and no accepted mint is an allow-listed testnut/dev mint, so the \ - cross-mint hop has nowhere permitted to land; set allow_real_mints=true to pay at a \ - real mint" + "buyer mint {buyer_mint} is not in the creq mint list {accepted_mints:?} and no \ + accepted mint is a usable mint URL, so the cross-mint hop has nowhere to land" ))), } } @@ -145,10 +143,10 @@ pub fn plan_payment( /// Choose the buyer's SOURCE mint for a payment, preferring a mint the buyer already holds a /// covering balance at so a pre-funded cross-mint balance is spent directly instead of hopping from /// 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. +/// default — today's behavior — when no held, accepted, usable 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 +/// order, that (1) is a usable mint URL 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. /// @@ -160,7 +158,6 @@ pub fn plan_payment( pub(crate) fn select_source_mint( config_default: &str, accepted_mints: &[String], - allow_real_mints: bool, balances: &[crate::wallet_ops::MintBalance], amount_sats: u64, ) -> String { @@ -168,7 +165,7 @@ pub(crate) fn select_source_mint( .iter() .find(|accepted| { let mint = accepted.as_str(); - home::mint_allowed(mint, allow_real_mints) && holds_at_least(balances, mint, amount_sats) + home::mint_url_supported(mint) && holds_at_least(balances, mint, amount_sats) }) .cloned() .unwrap_or_else(|| config_default.to_owned()) @@ -283,8 +280,11 @@ impl HopJournal { #[cfg(test)] mod tests { + + /// Test fixture mint host — NOT a default. A mint is just a mint: what makes one usable is membership of + /// the home's configured list, which each test sets up explicitly. + const FIXTURE_MINT_URL: &str = "https://mint.example/Bitcoin"; use super::*; - use crate::home::DEFAULT_MINT_URL; fn mint(url: &str) -> MintUrl { MintUrl::from_str(url).expect("test mint url parses") @@ -295,17 +295,17 @@ mod tests { // and then coincidentally produced the right amount would still be a bug. #[test] fn buyer_mint_in_accepted_set_pays_direct_without_hopping() { - let plan = plan_payment(DEFAULT_MINT_URL, &[DEFAULT_MINT_URL.to_owned()], false) + let plan = plan_payment(FIXTURE_MINT_URL, &[FIXTURE_MINT_URL.to_owned()]) .expect("an accepted, fenced buyer mint plans"); assert_eq!( plan, PayPlan::Direct { - mint: mint(DEFAULT_MINT_URL) + mint: mint(FIXTURE_MINT_URL) } ); assert!(!plan.is_hop(), "overlap must not hop"); assert_eq!(plan.hop_source(), None); - assert_eq!(plan.realized_mint(), &mint(DEFAULT_MINT_URL)); + assert_eq!(plan.realized_mint(), &mint(FIXTURE_MINT_URL)); } // Overlap anywhere in the list is still overlap, even when the buyer's mint is not first: the @@ -316,7 +316,7 @@ mod tests { "https://b.example".to_owned(), "https://a.example".to_owned(), ]; - let plan = plan_payment("https://a.example", &accepted, true).expect("listed mint plans"); + let plan = plan_payment("https://a.example", &accepted).expect("listed mint plans"); assert!(!plan.is_hop(), "a listed buyer mint never hops"); assert_eq!(plan.realized_mint(), &mint("https://a.example")); } @@ -325,7 +325,7 @@ mod tests { fn no_overlap_plans_a_hop_from_the_buyer_mint_to_an_accepted_mint() { let accepted = vec!["https://seller.example".to_owned()]; let plan = - plan_payment("https://buyer.example", &accepted, true).expect("a hop is plannable"); + plan_payment("https://buyer.example", &accepted).expect("a hop is plannable"); assert_eq!( plan, PayPlan::Hop { @@ -350,10 +350,10 @@ mod tests { "https://first.example".to_owned(), "https://second.example".to_owned(), ]; - let planned = plan_payment("https://buyer.example", &accepted, true).expect("plans"); + let planned = plan_payment("https://buyer.example", &accepted).expect("plans"); let sealed = planned.source_mint().to_string(); - let replanned = plan_payment(&sealed, &accepted, true).expect("the sealed source re-plans"); + let replanned = plan_payment(&sealed, &accepted).expect("the sealed source re-plans"); assert_eq!(replanned, planned, "the seal must re-derive the same plan"); assert!(replanned.is_hop(), "re-planning must not collapse the hop"); assert_eq!(replanned.realized_mint(), planned.realized_mint()); @@ -362,10 +362,10 @@ mod tests { // The direct path's seal is unchanged: the buyer's own mint, which is also what it realizes at. #[test] fn the_direct_path_seals_the_mint_it_realizes_at() { - let plan = plan_payment(DEFAULT_MINT_URL, &[DEFAULT_MINT_URL.to_owned()], false) + let plan = plan_payment(FIXTURE_MINT_URL, &[FIXTURE_MINT_URL.to_owned()]) .expect("direct plans"); assert_eq!(plan.source_mint(), plan.realized_mint()); - assert_eq!(plan.source_mint(), &mint(DEFAULT_MINT_URL)); + assert_eq!(plan.source_mint(), &mint(FIXTURE_MINT_URL)); } // The attempt id is derived from the realized mint, so a retry must re-derive the SAME target or @@ -377,9 +377,9 @@ mod tests { "https://second.example".to_owned(), "https://third.example".to_owned(), ]; - let first = plan_payment("https://buyer.example", &accepted, true).expect("plans"); + let first = plan_payment("https://buyer.example", &accepted).expect("plans"); for _ in 0..5 { - let again = plan_payment("https://buyer.example", &accepted, true).expect("plans"); + let again = plan_payment("https://buyer.example", &accepted).expect("plans"); assert_eq!( again, first, "target selection must not vary between attempts" @@ -390,58 +390,54 @@ mod tests { #[test] fn empty_accepted_set_pays_direct_at_the_buyer_mint() { - let plan = plan_payment(DEFAULT_MINT_URL, &[], false).expect("legacy bind plans"); + let plan = plan_payment(FIXTURE_MINT_URL, &[]).expect("legacy bind plans"); assert_eq!( plan, PayPlan::Direct { - mint: mint(DEFAULT_MINT_URL) + mint: mint(FIXTURE_MINT_URL) } ); } - // The fence still refuses the buyer's own mint first, before any hop is considered. + // A buyer "mint" that is not a mint URL refuses first, before any hop is considered. #[test] - fn unfenced_buyer_mint_is_refused_before_planning_a_hop() { - let accepted = vec![DEFAULT_MINT_URL.to_owned()]; - let error = plan_payment("https://real-mint.example", &accepted, false) - .expect_err("an unfenced buyer mint must refuse"); + fn a_buyer_mint_that_is_not_a_mint_url_is_refused_before_planning_a_hop() { + let accepted = vec![FIXTURE_MINT_URL.to_owned()]; + let error = plan_payment("not-a-url", &accepted) + .expect_err("a buyer mint that is not a URL must refuse"); let rendered = error.to_string(); assert!( - rendered.contains("real-mint fence"), - "expected a fence refusal, got: {rendered}" + rendered.contains("not a usable mint URL"), + "expected a URL-shape refusal, got: {rendered}" ); } - // A hop must not become a back door around the fence: with the switch off, an accepted mint that - // is not allow-listed is not a permitted place to land, so the plan refuses fail-closed. + // NEGATIVE: a hop still refuses fail-closed when NO accepted entry is a mint URL at all — the + // only refusal left, now that a mint is just a mint. #[test] - fn hop_refuses_when_no_accepted_mint_passes_the_fence() { - // Buyer sits on the one fenced mint; the seller accepts only an unfenced real mint. - let accepted = vec!["https://real-mint.example".to_owned()]; - let error = plan_payment(DEFAULT_MINT_URL, &accepted, false) - .expect_err("an unfenced hop target must refuse"); + fn hop_refuses_when_no_accepted_mint_is_a_usable_url() { + // `ftp://` PARSES as a mint URL but is not one this wallet can speak to, so it is the + // shape rule — not the parse — that refuses here. + let accepted = vec!["ftp://nope.example".to_owned(), "ftp://also-nope.example".to_owned()]; + let error = plan_payment("https://buyer.example", &accepted) + .expect_err("an unusable hop target must refuse"); let rendered = error.to_string(); assert!( - rendered.contains("real-mint fence"), - "expected a fence refusal, got: {rendered}" - ); - assert!( - rendered.contains("nowhere permitted to land"), - "the refusal should say the hop had no permitted target, got: {rendered}" + rendered.contains("nowhere to land"), + "the refusal should say the hop had no target, got: {rendered}" ); } - // With the switch on, the same shape plans a hop — proving the previous refusal came from the - // fence and not from some unrelated rejection of the list. + // Any mint the seller lists is a permitted hop target — there is no second switch to flip. #[test] - fn hop_to_a_real_mint_plans_once_the_operator_opts_in() { + fn hop_lands_on_any_mint_the_seller_accepts() { let accepted = vec!["https://real-mint.example".to_owned()]; - let plan = plan_payment(DEFAULT_MINT_URL, &accepted, true) - .expect("opted-in real mint is a permitted hop target"); + let plan = plan_payment(FIXTURE_MINT_URL, &accepted) + .expect("any listed mint is a permitted hop target"); assert_eq!( plan, PayPlan::Hop { - source: mint(DEFAULT_MINT_URL), + source: mint(FIXTURE_MINT_URL), target: mint("https://real-mint.example"), } ); @@ -516,16 +512,21 @@ mod tests { assert_ne!(decoded.delivered_sats, decoded.planned_cost); } - // Selection skips an unfenced entry rather than refusing outright when a later entry is fine. + // Selection skips an entry that is not a mint URL rather than refusing outright when a later + // entry is fine. `http://` is NOT such an entry — it is an ordinary mint URL and wins when it + // comes first (a seat's own sidecar mint runs on loopback). #[test] - fn hop_skips_unfenced_entries_and_lands_on_the_first_admissible_one() { - let accepted = vec![ - "http://not-https.example".to_owned(), - DEFAULT_MINT_URL.to_owned(), - ]; - let plan = plan_payment("https://buyer.example", &accepted, true) - .expect("a later admissible entry is usable"); - assert_eq!(plan.realized_mint(), &mint(DEFAULT_MINT_URL)); + fn hop_skips_unusable_entries_and_lands_on_the_first_usable_one() { + let accepted = vec!["ftp://not-a-mint.example".to_owned(), FIXTURE_MINT_URL.to_owned()]; + let plan = plan_payment("https://buyer.example", &accepted) + .expect("a later usable entry is reachable"); + assert_eq!(plan.realized_mint(), &mint(FIXTURE_MINT_URL)); + + let loopback = "http://127.0.0.1:3338"; + let accepted = vec![loopback.to_owned(), FIXTURE_MINT_URL.to_owned()]; + let plan = plan_payment("https://buyer.example", &accepted) + .expect("an http mint is an ordinary hop target"); + assert_eq!(plan.realized_mint(), &mint(loopback)); } // ---- select_source_mint: balance-aware source selection at accept (#497 behavior B) ---- @@ -551,13 +552,13 @@ mod tests { let balances = vec![balance(minibits, 5_000), balance(cuba, 5_000)]; // Control — the default seed hops to reach cuba (the ⑤ waste). - let control = plan_payment(minibits, &accepted, true).expect("control plans"); + let control = plan_payment(minibits, &accepted).expect("control plans"); assert!(control.is_hop(), "control: the default-seeded plan hops minibits->cuba"); // Balance-aware — cuba is selected and plan_payment pays direct from it. - let seed = select_source_mint(minibits, &accepted, true, &balances, 100); + let seed = select_source_mint(minibits, &accepted, &balances, 100); assert_eq!(seed, cuba, "the held, accepted mint is chosen as the source"); - let plan = plan_payment(&seed, &accepted, true).expect("balance-aware plan"); + let plan = plan_payment(&seed, &accepted).expect("balance-aware plan"); assert!(!plan.is_hop(), "direct from the held mint — no hop, no melt fee"); assert_eq!(plan.realized_mint(), &mint(cuba)); } @@ -571,13 +572,13 @@ mod tests { let accepted = vec![cuba.to_owned()]; let thin = vec![balance(cuba, 99)]; - assert_eq!(select_source_mint(minibits, &accepted, true, &thin, 100), minibits); + assert_eq!(select_source_mint(minibits, &accepted, &thin, 100), minibits); let elsewhere = vec![balance(minibits, 10_000)]; - assert_eq!(select_source_mint(minibits, &accepted, true, &elsewhere, 100), minibits); + assert_eq!(select_source_mint(minibits, &accepted, &elsewhere, 100), minibits); // Legacy: an empty accepted set has nothing to prefer; the default stands. - assert_eq!(select_source_mint(minibits, &[], true, &elsewhere, 100), minibits); + assert_eq!(select_source_mint(minibits, &[], &elsewhere, 100), minibits); } // #266 guard: the widened balance read surfaces DB-discovered, unconfigured mints for DISPLAY, @@ -592,28 +593,36 @@ mod tests { discovered.configured = false; assert_eq!( - select_source_mint(default_mint, &accepted, true, &[discovered], 100), + select_source_mint(default_mint, &accepted, &[discovered], 100), default_mint, "DB discovery widens display only; it must not change the sealed funding mint" ); } - // The real-mint fence gates SELECTION exactly as it gates plan_payment: a covered mint the fence - // disallows is never chosen. Off -> only the testnut default; on -> the real mint is selectable. + // SELECTION applies the same shape rule as plan_payment and nothing else: a covered mint the + // seller lists is chosen whatever it is, and an entry that is not a mint URL is skipped even + // when a balance is somehow recorded against it. #[test] - fn select_skips_a_fence_disallowed_mint_even_when_it_is_covered() { + fn select_takes_any_covered_listed_mint_and_skips_an_unusable_entry() { let real = "https://real-mint.example"; let accepted = vec![real.to_owned()]; let balances = vec![balance(real, 10_000)]; assert_eq!( - select_source_mint(DEFAULT_MINT_URL, &accepted, false, &balances, 100), - DEFAULT_MINT_URL, - "fence off: a covered real mint is not admissible, fall back to the default" + select_source_mint(FIXTURE_MINT_URL, &accepted, &balances, 100), + real, + "a covered mint the seller lists is selectable — there is no second gate" ); + + let unusable = "ftp://not-a-mint.example"; assert_eq!( - select_source_mint(DEFAULT_MINT_URL, &accepted, true, &balances, 100), - real, - "fence on: the covered real mint is now selectable" + select_source_mint( + FIXTURE_MINT_URL, + &[unusable.to_owned()], + &[balance(unusable, 10_000)], + 100 + ), + FIXTURE_MINT_URL, + "an entry that is not a mint URL is skipped, falling back to the default" ); } @@ -627,11 +636,11 @@ mod tests { let b = "https://b.example"; let balances = vec![balance(a, 5_000), balance(b, 5_000)]; assert_eq!( - select_source_mint(default_mint, &[a.to_owned(), b.to_owned()], true, &balances, 100), + select_source_mint(default_mint, &[a.to_owned(), b.to_owned()], &balances, 100), a ); assert_eq!( - select_source_mint(default_mint, &[b.to_owned(), a.to_owned()], true, &balances, 100), + select_source_mint(default_mint, &[b.to_owned(), a.to_owned()], &balances, 100), b ); } @@ -646,12 +655,12 @@ mod tests { let cuba = "https://cuba.example"; let accepted = vec![cuba.to_owned()]; // cuba is covered at accept and gets sealed. - let sealed = select_source_mint(minibits, &accepted, true, &[balance(cuba, 5_000)], 100); + let sealed = select_source_mint(minibits, &accepted, &[balance(cuba, 5_000)], 100); assert_eq!(sealed, cuba); // At pay, re-derivation uses ONLY the sealed mint + accepted set — no balance input — so the // plan is sourced at the sealed cuba regardless of where funds now sit. A drained cuba then // fails the wallet's coverage check (the existing fail-closed guard), never a re-select. - let pay_plan = plan_payment(&sealed, &accepted, true).expect("re-derives from the seal"); + let pay_plan = plan_payment(&sealed, &accepted).expect("re-derives from the seal"); assert_eq!(pay_plan.source_mint(), &mint(cuba), "pay honors the sealed source"); assert!(!pay_plan.is_hop()); } diff --git a/crates/maxplayer-core/src/crossmint_hop.rs b/crates/maxplayer-core/src/crossmint_hop.rs index 82e61ac5f..cb65e807b 100644 --- a/crates/maxplayer-core/src/crossmint_hop.rs +++ b/crates/maxplayer-core/src/crossmint_hop.rs @@ -851,10 +851,10 @@ async fn bounded( /// The two mint wallets one hop runs against. /// -/// Both are opened through [`buyer_fund::open_wallet_at_mint_async`], which fences the mint it is -/// asked for. That is why the hop cannot become a back door around `allow_real_mints`: the fence is -/// inside the opener, so the TARGET is fenced by the same code that fences the source, without this -/// module having to remember to do it. +/// Both are opened through [`buyer_fund::open_wallet_at_mint_async`], which checks the mint URL it +/// is asked for. WHICH mints may be used is the seat's configured list, applied by the planner that +/// produced this pairing — the hop adds no mint of its own, so it cannot reach one the plan did +/// not already name. pub(crate) struct CdkHopEffects { source: Wallet, target: Wallet, diff --git a/crates/maxplayer-core/src/home.rs b/crates/maxplayer-core/src/home.rs index c6d894754..017fee8cf 100644 --- a/crates/maxplayer-core/src/home.rs +++ b/crates/maxplayer-core/src/home.rs @@ -34,7 +34,6 @@ //! | `accepted_mints` (list) | `MAXPLAYER_ACCEPTED_MINTS=a,b` | //! | `per_job_budget_sats` | `MAXPLAYER_PER_JOB_BUDGET_SATS` | //! | `extra_mints` (list) | `MAXPLAYER_EXTRA_MINTS=a,b` | -//! | `allow_real_mints` | `MAXPLAYER_ALLOW_REAL_MINTS` | //! | `profile.name` | `MAXPLAYER_PROFILE__NAME` | //! | `seller.rate_sats` | `MAXPLAYER_SELLER__RATE_SATS` | //! | `seller.agent_command` (list) | `MAXPLAYER_SELLER__AGENT_COMMAND=claude,--flag` | @@ -67,13 +66,12 @@ use serde::{Deserialize, Serialize}; /// Open-market relay — the maxplayer launch relay. pub const DEFAULT_RELAY_URL: &str = "wss://relay.maxplayer.ai"; -/// Standing CDK test mint — its bolt11 invoices auto-settle, so it moves no real money. Kept as the -/// testnut/dev allow-list anchor: `mint_allowed` admits exactly this when `allow_real_mints` is false. -pub const DEFAULT_MINT_URL: &str = "https://testnut.cashudevkit.org"; -/// Shipped default seller mint (issue #378): a REAL minibits mint. Fresh configs accept real sats here -/// by default — paired with `allow_real_mints = true`, without which the fence would refuse this mint. +/// Shipped default mint: the seat's first `accepted_mints` entry on a fresh config, and the +/// fallback every product default reads. A mint is just a mint — the seat's configured list is the +/// only thing that decides whether it may be used (owner ruling, 2 Sep 2026). pub const DEFAULT_MINIBITS_MINT_URL: &str = "https://mint.minibits.cash/Bitcoin"; -/// Dead testnut host — bootstrap migrates config.toml away from this. +/// Dead mint host — bootstrap migrates a config.toml carrying it onto +/// [`DEFAULT_MINIBITS_MINT_URL`]. Removal only: nothing writes this host. pub const DEAD_TESTNUT_MINT_HOST: &str = "testnut.cashu.space"; /// Empty-market per-job spend fallback (sats): the cap applied when no market-rate signal exists. /// Market-rate derivation is a follow-up (#378); until then every fresh config ships this cap. @@ -1307,7 +1305,11 @@ pub struct MaxplayerConfig { pub relay_url: String, /// Seller-side accept policy: the mints this seller will accept payment at. The first /// entry is the mint the seller advertises first and also the buyer-side wallet default - /// mint (read via [`MaxplayerConfig::default_mint`]). Defaults to `[DEFAULT_MINT_URL]`. + /// mint (read via [`MaxplayerConfig::default_mint`]). Defaults to + /// `[DEFAULT_MINIBITS_MINT_URL]`. + /// + /// This list IS the mint policy. There is no second gate: a mint is usable by this seat when + /// it is on this list (or in `extra_mints` for the buyer wallet), and not otherwise. /// /// NOTE: distinct from `extra_mints`. `accepted_mints` is the SELLER accept-policy list; /// `extra_mints` is the BUYER wallet's *additional allowed* mints. They are separate @@ -1325,15 +1327,6 @@ pub struct MaxplayerConfig { /// never invents spendable credit by itself. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub extra_mints: Vec, - /// REAL-MONEY SWITCH (issue #49). When `true` (issue #378 made this the DEFAULT, because the - /// shipped `accepted_mints` default is a real minibits mint) the seller `accepted_mints` boot fence - /// and the buyer pay-path mint resolution admit any well-formed `https://` mint URL — real sats - /// move. When `false` (explicit opt-OUT) only the testnut/dev allow-list ([`DEFAULT_MINT_URL`]) is - /// admitted; a real mint is refused fail-closed. It flips ONLY the allow-list check; every other - /// money gate (creq membership, redeem guard token==payload mint, dust guard, per-job budget cap, - /// co-signatures) is unchanged — the per-job cap is the standing spend bound on the real path. - #[serde(default = "default_allow_real_mints")] - pub allow_real_mints: bool, /// Optional `[profile] name / about`. Skipped when absent so fresh homes stay unnamed. #[serde(default, skip_serializing_if = "Option::is_none")] pub profile: Option, @@ -1442,10 +1435,9 @@ pub struct ContributionPolicyConfig { pub max_diff_bytes: Option, } -/// Serde/default seed for [`MaxplayerConfig::accepted_mints`] (issue #378): a single REAL minibits mint. -/// A fresh config accepts real sats here by default — which is why [`MaxplayerConfig::default`] also flips -/// `allow_real_mints` true, without which `mint_allowed` would refuse this very default. Mint VARIETY -/// (multiple real mints) lives in the market-mode loop, not the shipped default pool. +/// Serde/default seed for [`MaxplayerConfig::accepted_mints`] (issue #378): a single minibits mint. +/// A fresh config accepts sats there by default. Mint VARIETY (multiple mints) lives in the +/// market-mode loop, not the shipped default pool. fn default_accepted_mints() -> Vec { vec![DEFAULT_MINIBITS_MINT_URL.to_owned()] } @@ -1460,41 +1452,84 @@ fn default_per_job_budget_sats() -> u64 { DEFAULT_PER_JOB_BUDGET_SATS } -/// Serde default for [`MaxplayerConfig::allow_real_mints`] — `true` (issue #378). The shipped -/// `accepted_mints` default is a real mint, so the fence must admit it; `false` here would make the -/// default config refuse its own default mint. Set `allow_real_mints = false` to force testnut-only. -fn default_allow_real_mints() -> bool { - true -} - -/// The single real-mint fence predicate (issue #49), shared by the seller `accepted_mints` boot -/// check and the buyer pay-path mint resolution so both sides gate on the SAME rule. +/// Whether a mint URL is WELL FORMED enough to be configured: `http://` or `https://` with a +/// non-empty host. +/// +/// This is a SHAPE rule, not a policy. There is no real-mint/test-mint distinction anywhere — "the +/// concept of real mint doesn't make sense, it's just a mint; the seller chooses what mint to +/// accept or not" (owner ruling, 2 Sep 2026). The ONE policy gate on a mint is membership of this +/// seat's configured list (`accepted_mints`, plus `extra_mints` for the buyer wallet); this +/// predicate only rejects a string that could never be a mint at all. Full URL validity is +/// re-checked downstream (`MintUrl::from_str` / `Wallet::new`). +/// +/// `http://` is admitted deliberately: a seat's own sidecar mint runs on loopback — including IPv6 +/// loopback, `http://[::1]:3338` — and an https-only shape rule would make the operator's own list +/// unusable. /// -/// - `allow_real_mints == false` (default safety posture): only the testnut/dev allow-list — today -/// that is exactly [`DEFAULT_MINT_URL`]. -/// - `allow_real_mints == true` (operator opt-in real-money switch): any well-formed `https://` -/// mint URL. Full URL validity is re-checked downstream (`MintUrl::from_str` / `Wallet::new`); -/// this predicate only decides the POLICY (the testnut/dev allow-list vs any-https). -pub fn mint_allowed(mint_url: &str, allow_real_mints: bool) -> bool { - if allow_real_mints { - mint_url - .strip_prefix("https://") - .is_some_and(|host| !host.is_empty()) +/// This validator is deliberately hand-rolled rather than delegated to a general URL parser: a +/// browser-compatibility parser is the wrong instrument for an admission whitelist. `url::Url` reads +/// `http:///x` as the host `x` (WHATWG special schemes skip repeated slashes), which would ADMIT a +/// string this predicate exists to refuse. +pub fn mint_url_supported(mint_url: &str) -> bool { + // 1. Only the two schemes this wallet speaks. + let Some(rest) = mint_url + .strip_prefix("https://") + .or_else(|| mint_url.strip_prefix("http://")) + else { + return false; + }; + // 2. The authority ends at the first `/`, `?` or `#` — so `http:///x` has an EMPTY authority, + // not the host `x`. + let authority = rest.split(['/', '?', '#']).next().unwrap_or(""); + // 3. Userinfo is not the host: keep only what follows the last `@`. + let host_part = authority.rsplit('@').next().unwrap_or(""); + let host = if let Some(after_open) = host_part.strip_prefix('[') { + // 4. A bracketed IPv6 literal: it must close, the literal must be non-empty, and what + // follows the bracket is either nothing or a `:` and an all-digit port. + let Some((literal, after_close)) = after_open.split_once(']') else { + return false; + }; + let port_ok = match after_close.strip_prefix(':') { + Some(port) => !port.is_empty() && port.chars().all(|c| c.is_ascii_digit()), + None => after_close.is_empty(), + }; + if !port_ok { + return false; + } + literal } else { - mint_url == DEFAULT_MINT_URL - } + // 5. An unbracketed host carries no brackets at all, and at most one `:` — which must be + // followed by an all-digit port. `mint.example:abc` and `:8080` are refused here. + if host_part.contains('[') || host_part.contains(']') { + return false; + } + match host_part.split_once(':') { + Some((head, port)) => { + let port_ok = !port.is_empty() + && port.chars().all(|c| c.is_ascii_digit()) + && !port.contains(':'); + if !port_ok { + return false; + } + head + } + None => host_part, + } + }; + // 6. Whatever survived must actually be a host. + !host.is_empty() && !host.chars().any(|c| c.is_whitespace()) } impl MaxplayerConfig { - /// Buyer-side default mint: the first accepted mint. Falls back to [`DEFAULT_MINT_URL`] - /// only if the list is empty (boot validation refuses an empty list for sellers). Buyer - /// wallet ops read a single default mint through this accessor; the seller accept policy - /// is the full `accepted_mints` list. + /// Buyer-side default mint: the first accepted mint. Falls back to + /// [`DEFAULT_MINIBITS_MINT_URL`] only if the list is empty (boot validation refuses an empty + /// list for sellers). Buyer wallet ops read a single default mint through this accessor; the + /// seller accept policy is the full `accepted_mints` list. pub fn default_mint(&self) -> &str { self.accepted_mints .first() .map(String::as_str) - .unwrap_or(DEFAULT_MINT_URL) + .unwrap_or(DEFAULT_MINIBITS_MINT_URL) } } @@ -1505,7 +1540,6 @@ impl Default for MaxplayerConfig { accepted_mints: default_accepted_mints(), per_job_budget_sats: DEFAULT_PER_JOB_BUDGET_SATS, extra_mints: Vec::new(), - allow_real_mints: true, profile: None, seller: None, buzz: None, @@ -1583,7 +1617,7 @@ pub fn is_initialized(root: impl AsRef) -> bool { /// Ensure `root` exists with config, key (`0600`), and `wallet/` dir. /// /// Idempotent: existing config/key are left in place except dead-mint migration -/// (`testnut.cashu.space` → [`DEFAULT_MINT_URL`]). The persisted `config.toml` is the file layer; +/// ([`DEAD_TESTNUT_MINT_HOST`] → [`DEFAULT_MINIBITS_MINT_URL`]). The persisted `config.toml` is the file layer; /// the returned [`MaxplayerHome::config`] additionally carries the `MAXPLAYER_*` environment overlay (see /// the module docs). Never returns the secret key. pub fn bootstrap(root: impl AsRef) -> Result { @@ -1634,13 +1668,14 @@ pub fn bootstrap(root: impl AsRef) -> Result { }) } -/// Rewrite dead `.cashu.space` testnut hosts to [`DEFAULT_MINT_URL`] across every -/// `accepted_mints` entry. Returns true when any entry changed. +/// Rewrite the dead `.cashu.space` host to [`DEFAULT_MINIBITS_MINT_URL`] across every +/// `accepted_mints` entry. Returns true when any entry changed. This migration only ever REMOVES +/// the dead host; nothing in the product writes it back. pub fn migrate_dead_mint_url(config: &mut MaxplayerConfig) -> bool { let mut changed = false; for mint in &mut config.accepted_mints { if mint.to_ascii_lowercase().contains(DEAD_TESTNUT_MINT_HOST) { - *mint = DEFAULT_MINT_URL.to_owned(); + *mint = DEFAULT_MINIBITS_MINT_URL.to_owned(); changed = true; } } @@ -1680,6 +1715,18 @@ fn fold_legacy_mint_url(table: &mut toml::Table) { /// (the per-entry `slots` knob was dead weight — refused above 1 — so nothing is lost). fn fold_removed_config_fields(table: &mut toml::Table) { table.remove("total_budget_sats"); + // The real-mint switch is gone: a mint is just a mint, and the seat's configured list is the + // only gate (owner ruling, 2 Sep 2026). An operator upgrading with `allow_real_mints = …` in + // their config.toml must keep booting, so the key is dropped here — BEFORE the + // `deny_unknown_fields` parse that would otherwise refuse it — and the drop is announced once + // so it is never a silent policy change. + if table.remove("allow_real_mints").is_some() { + crate::opline!( + "config.toml: `allow_real_mints` is no longer a setting and was ignored — a mint is \ + usable when it is in `accepted_mints` (or `extra_mints`), and not otherwise. Remove \ + the line to silence this." + ); + } let Some(seller) = table.get_mut("seller").and_then(toml::Value::as_table_mut) else { return; @@ -1787,6 +1834,10 @@ const RESERVED_ENV_VARS: &[&str] = &[ "MAXPLAYER_ACP_SMOKE", "MAXPLAYER_ACP_SMOKE_CMD", "MAXPLAYER_EVALS_SNAPSHOT_DIR", + // RETIRED config field, kept reserved so an operator (or a script) that still exports it keeps + // booting instead of being refused by the fail-closed unknown-`MAXPLAYER_*` rule. It sets + // nothing: the seat's configured mint list is the only mint gate. + "MAXPLAYER_ALLOW_REAL_MINTS", ]; /// [`MaxplayerConfig`] fields whose env value is a comma-separated list. The env source must be told @@ -1928,8 +1979,8 @@ fn documented_config_toml(config: &MaxplayerConfig) -> Result "accepted_mints", &[ "Mints this seller accepts; the first is also the buyer wallet's default mint.", - "⚠ THE SHIPPED DEFAULT IS A REAL MINT (minibits) — a fresh node moves REAL sats.", - "For testnut/dev only, set a test mint here AND allow_real_mints = false below.", + "⚠ A fresh node ships a real mint (minibits) and moves REAL sats.", + "This list is the ONLY mint gate — a mint not on it is refused, one on it is used.", ], ), ( @@ -1939,13 +1990,6 @@ fn documented_config_toml(config: &MaxplayerConfig) -> Result "no total cap — the spent.jsonl ledger records every spend for audit. Raise with care.", ], ), - ( - "allow_real_mints", - &[ - "Real-money switch — TRUE by default so the fence admits the real default mint above.", - "Set false to force testnut/dev-only (any real mint is then refused fail-closed).", - ], - ), ]; let body = @@ -2533,15 +2577,23 @@ mod tests { }; write_config(&config_path, &stale).expect("write stale"); let home = bootstrap(&root).expect("bootstrap migrates"); - assert_eq!(home.config.accepted_mints, vec![DEFAULT_MINT_URL.to_owned()]); + // The migration REMOVES the dead host and lands on the shipped default; it never writes a + // test mint back in. + assert_eq!( + home.config.accepted_mints, + vec![DEFAULT_MINIBITS_MINT_URL.to_owned()] + ); let reloaded = load_config(&config_path).expect("reload"); - assert_eq!(reloaded.accepted_mints, vec![DEFAULT_MINT_URL.to_owned()]); + assert_eq!( + reloaded.accepted_mints, + vec![DEFAULT_MINIBITS_MINT_URL.to_owned()] + ); } #[test] fn accepted_mints_default() { - // Issue #378: a config that names no mint yields the shipped default — a single REAL minibits - // mint (paired with allow_real_mints = true; see `default_allow_real_mints`). + // Issue #378: a config that names no mint yields the shipped default — a single minibits + // mint. That list is the mint policy; there is no second switch beside it. let config: MaxplayerConfig = toml::from_str( "relay_url = 'r'\nper_job_budget_sats = 1\n", ) @@ -2617,19 +2669,73 @@ mod tests { ); } + /// D: the predicate must implement its own contract — scheme ∈ {http, https} AND a non-empty + /// HOST — not merely "the tail after the scheme is non-empty". Each malformed case an authority + /// tail-check waved through is named here, including the two that survived the first attempt at + /// this fix: a non-numeric port and an unterminated IPv6 literal. + #[test] + fn mint_url_supported_requires_a_scheme_and_a_real_host() { + // ADMITTED. `http://` is deliberate and load-bearing: a seat's own sidecar mint runs on + // loopback, so an https-only rule would make the operator's own list unusable. + for good in [ + "http://127.0.0.1:3338", + "http://localhost", + "https://mint.example", + "https://mint.example/Bitcoin", + "https://mint.example:8443/Bitcoin", + "https://user@mint.example/Bitcoin", + "https://mint.example/Bitcoin?x=1#frag", + // A seat's own sidecar mint may sit on IPv6 loopback, with or without a port. + "http://[::1]:3338", + "https://[::1]", + ] { + assert!(mint_url_supported(good), "must admit {good}"); + } + + // REFUSED — malformed non-empty tails, the class the old predicate accepted. + let cases = [ + ("path only, no host", "http:///x"), + ("path only, root", "http:///"), + ("whitespace-only tail", "http:// "), + ("whitespace-only tail, several", "https:// "), + ("port only, no host", "http://:8080"), + ("port only, then path", "http://:8080/Bitcoin"), + ("embedded space in host", "https://mint .example"), + ("leading space before host", "https:// mint.example"), + ("userinfo but no host", "https://user@"), + ("userinfo, then port only", "https://user@:8443"), + ("empty tail", "https://"), + ("empty tail, http", "http://"), + // The two an authority tail-check waved through, which is why the host is parsed now. + ("non-numeric port", "https://mint.example:abc"), + ("unterminated IPv6 literal", "https://[::1"), + ]; + for (name, bad) in cases { + assert!(!mint_url_supported(bad), "must refuse {name}: {bad:?}"); + } + + // REFUSED — not a scheme this wallet speaks, or no scheme at all. + for bad in ["ftp://mint.example", "mint.example", "//mint.example", "", " "] { + assert!(!mint_url_supported(bad), "must refuse {bad:?}"); + } + } + #[test] - fn shipped_defaults_are_real_money_and_the_fence_admits_them() { - // #378 flipped fresh nodes real-money-capable. The whole default posture in one place; the - // load-bearing part is that mint_allowed ADMITS the shipped default mint (it would REFUSE it - // if allow_real_mints had stayed false, or if the mint reverted to testnut). + fn shipped_defaults_are_real_money_and_usable() { + // The whole default posture in one place. Load-bearing: the shipped default mint IS this + // config's own configured mint, so the one mint gate — membership of the configured list — + // admits it. Breaks if the default mint and the default list ever diverge. let d = MaxplayerConfig::default(); assert_eq!(d.accepted_mints, vec![DEFAULT_MINIBITS_MINT_URL.to_owned()]); - assert!(d.allow_real_mints, "fresh nodes are real-money-capable by default"); assert_eq!(d.per_job_budget_sats, 30_000); assert_eq!(default_slots(), 3, "seller default concurrency"); assert!( - mint_allowed(d.default_mint(), d.allow_real_mints), - "the fence must admit the shipped default mint (breaks if either default reverts)" + d.accepted_mints.iter().any(|mint| mint == d.default_mint()), + "the shipped default mint must be on the shipped configured list" + ); + assert!( + mint_url_supported(d.default_mint()), + "the shipped default mint must be a usable mint URL" ); } @@ -2838,7 +2944,7 @@ mod tests { .expect("file layer"); // Sanity: defaults::new(), + "an untouched default survives both layers" + ); } #[test] diff --git a/crates/maxplayer-core/src/job_lifecycle.rs b/crates/maxplayer-core/src/job_lifecycle.rs index 8c3f6fbd9..6d2998fa5 100644 --- a/crates/maxplayer-core/src/job_lifecycle.rs +++ b/crates/maxplayer-core/src/job_lifecycle.rs @@ -369,7 +369,7 @@ pub struct AcceptedBind { pub accepted_mints: Vec, /// The buyer's FUNDING (source) mint for this job — the mint whose proofs are spent — SELECTED and /// frozen at accept from the buyer's then-configured default (or a pre-funded cross-mint balance), - /// validated against `accepted_mints` + the real-mint fence. The pay path derives the paying mint + /// validated against `accepted_mints`. The pay path derives the paying mint /// from THIS on every attempt — including retries — so a config-default change between attempts can /// never shift the mint and mint a second [`crate::payment::AttemptId`] (double-pay). On a /// cross-mint hop this is the SOURCE the buyer melts, NOT the mint the seller is paid in (that is @@ -1324,7 +1324,7 @@ pub async fn accept_claim_async( verify_accepted_claim_creq(claim.creq.as_deref(), &request.job_id, offer.amount_sats)?; // FREEZE the buyer's paying mint at accept from its then-configured default, validated by - // planning the payment against the accepted set + the real-mint fence. Sealing the SELECTION + // planning the payment against the accepted set. Sealing the SELECTION // here — not just the accepted SET (finding V) — is what makes the pay-path attempt id stable // across retries: a config-default change after accept can no longer shift the mint into a // different attempt id and mint a second payment for one job (double-pay). A buyer with no @@ -1345,7 +1345,6 @@ pub async fn accept_claim_async( Ok(balances) => crate::crossmint::select_source_mint( home.config.default_mint(), &accepted_mints, - home.config.allow_real_mints, &balances, offer.amount_sats, ), @@ -1360,12 +1359,8 @@ pub async fn accept_claim_async( // the pay path spends from (frozen for attempt-id stability), and the DELIVERY mint the seller is // realized at (reporting only — the pay path re-derives it and never reads the stored value). On a // direct payment the two are equal; on a hop the delivery mint is the target, not the source. - let plan = crate::crossmint::plan_payment( - &source_seed, - &accepted_mints, - home.config.allow_real_mints, - ) - .map_err(|error| JobLifecycleError::Input(error.to_string()))?; + let plan = crate::crossmint::plan_payment(&source_seed, &accepted_mints) + .map_err(|error| JobLifecycleError::Input(error.to_string()))?; let (funding_mint, delivery_mint) = seal_bind_mints(&plan); let buyer_pubkey = keys.public_key().to_hex(); @@ -3448,7 +3443,7 @@ mod tests { use std::str::FromStr; // A and B are BOTH in the accepted set; A is the buyer's default at accept, B the flipped - // default on retry. `allow_real_mints` is on so two distinct mints both pass the fence. + // default on retry. let mint_a = "https://mint-a.example"; let mint_b = "https://mint-b.example"; @@ -3465,7 +3460,6 @@ mod tests { let _ = std::fs::remove_dir_all(&root); let mut home = home::bootstrap(&root).expect("home"); home.config.accepted_mints = vec![mint_b.to_string()]; // default_mint() = B - home.config.allow_real_mints = true; assert_eq!(home.config.default_mint(), mint_b, "live config default is B (flipped)"); let seller_nostr = nostr_sdk::Keys::generate().public_key(); @@ -3512,7 +3506,7 @@ mod tests { // wallet-open seam consumes). let attempt_for = |config_default: &str| -> (String, MintUrl, PaymentTerms) { let selected = request.realized_mint.as_deref().unwrap_or(config_default); - let mint = plan_payment(selected, &request.accepted_mints, true) + let mint = plan_payment(selected, &request.accepted_mints) .expect("plan payment") .realized_mint() .clone(); @@ -3556,7 +3550,7 @@ mod tests { // the live default flips this observed mint B↔A (red-on-revert). let opened = crate::buyer_fund::open_wallet_at_mint_async( &home, - &wallet_open_mint_url(&home, &retry_terms), + &wallet_open_mint_url(&retry_terms), ) .await .expect("open pay wallet"); @@ -3695,7 +3689,7 @@ mod tests { let source = "https://a.example"; let target = "https://b.example"; // Buyer funded at `source`; seller accepts only `target` ⇒ no overlap ⇒ a hop. - let plan = crate::crossmint::plan_payment(source, &[target.to_string()], true) + let plan = crate::crossmint::plan_payment(source, &[target.to_string()]) .expect("cross-mint plan"); assert!(plan.is_hop(), "distinct source/target must plan a hop"); let (funding, delivery) = seal_bind_mints(&plan); @@ -3705,7 +3699,7 @@ mod tests { // Sibling direct-payment case: the same mint on both sides ⇒ delivery equals funding (no hop), // which is precisely why the mis-report was invisible on same-mint jobs. - let direct = crate::crossmint::plan_payment(source, &[source.to_string()], true) + let direct = crate::crossmint::plan_payment(source, &[source.to_string()]) .expect("direct plan"); assert!(!direct.is_hop(), "buyer mint in the accepted set is a direct payment"); let (funding_direct, delivery_direct) = seal_bind_mints(&direct); diff --git a/crates/maxplayer-core/src/seller_node/run.rs b/crates/maxplayer-core/src/seller_node/run.rs index 43bf0c44f..7ede28afe 100644 --- a/crates/maxplayer-core/src/seller_node/run.rs +++ b/crates/maxplayer-core/src/seller_node/run.rs @@ -6442,7 +6442,7 @@ impl SellerNodeRunner { /// Settle one gift-wrapped payment: decode it (through the signer actor — the NIP-44 decrypt /// needs the seller key, which never leaves the actor), authenticate the buyer by the seal, /// enforce the money-safety guards (seal sender == bound buyer, realized mint ∈ the STORED - /// claim-time creq per Fix Q, `allow_real_mints` fence), then — in the invariant-3 order — write + /// claim-time creq per Fix Q), then — in the invariant-3 order — write /// the intent breadcrumb BEFORE swapping at the mint, classify the swap FAIL-CLOSED (never infer /// collection from the breadcrumb), and only then record the receipt (deduped by the wrap id, so /// a replayed wrap credits the job at most once). Every refusal is logged with a named reason. @@ -6536,14 +6536,6 @@ impl SellerNodeRunner { ); return; } - // Real-mint fence: a real mint can never settle unless the operator opted in. - if !crate::home::mint_allowed(&mint_str, self.node.home().config.allow_real_mints) { - opline!( - "seller node wrap event={event_id}: mint {mint_str} not allowed (allow_real_mints={}) for job {job_id} — refused", - self.node.home().config.allow_real_mints - ); - return; - } // Payment terms over the stored-creq accepted set (amount == offer.amount, unit == sat). The // ParsedOffer is reconstructed from the stored offer; a targeted offer we hold was targeted to diff --git a/crates/maxplayer-core/src/wallet_ops.rs b/crates/maxplayer-core/src/wallet_ops.rs index e0b7950f1..24e733570 100644 --- a/crates/maxplayer-core/src/wallet_ops.rs +++ b/crates/maxplayer-core/src/wallet_ops.rs @@ -3,9 +3,9 @@ //! quote and returns the bolt11 invoice up front, then [`complete_mint_async`] mints once it is //! paid. ([`crate::buyer_fund`] covers wallet open, seed derivation, and balance read.) //! -//! **Funding assumption:** only the pinned testnut host ([`DEFAULT_MINT_URL`]) -//! FakeWallet-auto-pays mint quotes. For other configured mints, [`begin_mint_async`] -//! returns the bolt11 and callers must pay it, then [`complete_mint_async`]. +//! **Funding assumption:** a mint invoices, and someone pays that invoice. [`begin_mint_async`] +//! returns the bolt11 and the caller pays it, then calls [`complete_mint_async`]. No mint is +//! special-cased: a mint is a mint, and the seat's configured list decides which ones it uses. use std::collections::{BTreeSet, HashMap}; use std::path::Path; @@ -22,20 +22,16 @@ use cdk::Amount; use cdk_sqlite::wallet::WalletSqliteDatabase; use crate::buyer_fund::seed_from_secret_hex; -use crate::home::{self, HomeError, MaxplayerHome, DEFAULT_MINT_URL}; +use crate::home::{self, HomeError, MaxplayerHome}; #[derive(Debug)] pub enum WalletOpsError { Home(HomeError), /// The mint is not in this home's configured set (`accepted_mints`/`extra_mints`) — a /// MEMBERSHIP miss, cleared by `maxplayer wallet mints add`. `default_mint` carries the home's - /// ACTUAL default (`config.default_mint()`) so the Display names it rather than the pinned - /// testnut constant — on a real-minibits home the latter is a money-relevant lie (#506). + /// ACTUAL default (`config.default_mint()`) so the Display names the mint this home really + /// pins rather than a constant (#506). This is the ONLY mint policy gate there is. MintNotAllowed { mint_url: String, default_mint: String }, - /// The mint IS configured but is a real mint refused by the real-mint fence (issue #49): - /// `allow_real_mints` is off. A POLICY block — `mints add` cannot clear it, so it must NOT - /// borrow [`Self::MintNotAllowed`]'s remedy; the control is `MAXPLAYER_ALLOW_REAL_MINTS` (#465). - RealMintDisallowed { mint_url: String }, /// `remove_mint` refuses to remove the home's pinned default mint. `mint_url` carries that /// actual default (`config.default_mint()`) so the message names the real pinned mint rather /// than a hardcoded constant — on a real-minibits home the constant would be a false-default @@ -52,12 +48,6 @@ impl std::fmt::Display for WalletOpsError { formatter, "mint {mint_url} is not configured; add it with `maxplayer wallet mints add` (default stays {default_mint})" ), - Self::RealMintDisallowed { mint_url } => write!( - formatter, - "mint {mint_url} not allowed: allow_real_mints is off (only {DEFAULT_MINT_URL} is permitted). \ - Set MAXPLAYER_ALLOW_REAL_MINTS=true (or allow_real_mints in config.toml) to opt in, \ - or use --mint {DEFAULT_MINT_URL} for dev/play-money" - ), Self::MintPinnedDefault { mint_url } => write!( formatter, "cannot remove the default mint ({mint_url}); only extra_mints are removable" @@ -181,37 +171,6 @@ pub fn normalize_mint_url(raw: &str) -> Result { Ok(parsed.to_string()) } -fn is_autopay_mint(mint_url: &str) -> bool { - normalize_mint_url(mint_url) - .ok() - .as_deref() - == Some(DEFAULT_MINT_URL) -} - -/// Money class a mint moves, derived purely from the mint URL. The pinned testnut host -/// ([`DEFAULT_MINT_URL`]) FakeWallet-auto-pays its own invoices — play money — while every other -/// mint invoices for real sats. Internal: it gates the #445 fail-closed refusal of silently -/// auto-funding play money and drives a play-money marker on dev rows. Ordinary mints carry no -/// money-class label in user output — a mint is a mint, identified by its URL (#577). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum MoneyType { - /// A real mint — its invoices move real sats. - Real, - /// The testnut dev/play mint — auto-pays its own invoices with fake sats. - Play, -} - -impl MoneyType { - /// Classify a mint URL. A URL that does not normalize is treated as [`Self::Real`] — the - /// fail-safe direction, so an unrecognized mint is never mislabeled play money. - pub fn of_mint(mint_url: &str) -> Self { - if is_autopay_mint(mint_url) { - Self::Play - } else { - Self::Real - } - } -} /// Configured mints: default `mint_url` first, then opt-in `extra_mints` (deduped). pub fn configured_mints(home: &MaxplayerHome) -> Result, WalletOpsError> { @@ -593,24 +552,19 @@ pub async fn complete_mint_by_id_async( /// Flexible/repeatable mint-fund (no `already_funded` hard-block). /// -/// Testnut ([`DEFAULT_MINT_URL`]) FakeWallet-auto-pays: begin → complete. -/// Other configured mints return [`MintFlow::NeedsPayment`] with bolt11 already -/// surfaced (caller pays, then [`complete_mint_async`]). +/// Always returns [`MintFlow::NeedsPayment`] with the bolt11 already surfaced: the caller pays the +/// invoice, then calls [`complete_mint_async`]. No mint is special-cased — a mint that settles its +/// own invoices does so on its own, and the caller sees the same two steps either way. pub async fn mint_async( home: &MaxplayerHome, amount_sats: u64, mint_override: Option<&str>, ) -> Result { let quote = begin_mint_async(home, amount_sats, mint_override).await?; - if is_autopay_mint("e.mint_url) { - Ok(MintFlow::Funded(complete_mint_async(home, "e).await?)) - } else { - Ok(MintFlow::NeedsPayment(quote)) - } + Ok(MintFlow::NeedsPayment(quote)) } -/// Create a bolt11 invoice; on testnut, mint once FakeWallet auto-pays. -/// Non-autopay mints return [`MintFlow::NeedsPayment`] (invoice before any wait). +/// Create a bolt11 invoice and return it before any wait ([`MintFlow::NeedsPayment`]). pub async fn invoice_async( home: &MaxplayerHome, amount_sats: u64, @@ -629,12 +583,6 @@ pub async fn send_async( return Err(WalletOpsError::Wallet("amount must be > 0".into())); } let mint_url = resolve_mint(home, mint_override)?; - // Fail closed against the real-mint gate before opening the wallet. Operator sends are a - // deliberate action OUTSIDE the job-pay budget gate (BudgetGate is deliberately not wired in - // here — owner decision pending), but they must still honor `allow_real_mints`. - if !home::mint_allowed(&mint_url, home.config.allow_real_mints) { - return Err(WalletOpsError::RealMintDisallowed { mint_url }); - } let wallet = open_wallet_async(home, &mint_url).await?; let before = wallet .total_balance() @@ -687,14 +635,8 @@ pub async fn receive_async( .mint_url() .map_err(|error| WalletOpsError::Wallet(error.to_string()))? .to_string(); + // The token's own mint must be in this home's CONFIGURED list — the one and only mint gate. let mint_url = mint_is_allowed(home, &mint_url)?; - // Real-mint fence (issue #49): `mint_is_allowed` only checks the mint is in the CONFIGURED list; - // this additionally fails closed on a real mint unless the operator opted in, the same gate - // send/melt enforce. Without it a real mint left in the configured list would redeem while - // `allow_real_mints == false`. - if !home::mint_allowed(&mint_url, home.config.allow_real_mints) { - return Err(WalletOpsError::RealMintDisallowed { mint_url }); - } let wallet = open_wallet_async(home, &mint_url).await?; let before = wallet .total_balance() @@ -741,12 +683,6 @@ pub async fn melt_async( return Err(WalletOpsError::Wallet("bolt11 invoice is empty".into())); } let mint_url = resolve_mint(home, mint_override)?; - // Fail closed against the real-mint gate before opening the wallet. Operator melts are a - // deliberate action OUTSIDE the job-pay budget gate (BudgetGate is deliberately not wired in - // here — owner decision pending), but they must still honor `allow_real_mints`. - if !home::mint_allowed(&mint_url, home.config.allow_real_mints) { - return Err(WalletOpsError::RealMintDisallowed { mint_url }); - } let wallet = open_wallet_async(home, &mint_url).await?; let quote = wallet .melt_quote(PaymentMethod::BOLT11, bolt11, None, None) @@ -962,6 +898,9 @@ pub fn invoice_blocking( #[cfg(test)] mod tests { + /// Test fixture mint host — NOT a default. A mint is just a mint: what makes one usable is membership of + /// the home's configured list, which each test sets up explicitly. + const FIXTURE_MINT_URL: &str = "https://mint.example/Bitcoin"; use super::*; use crate::home::bootstrap; use std::sync::atomic::{AtomicU64, Ordering}; @@ -1084,9 +1023,9 @@ mod tests { let _ = std::fs::remove_dir_all(&root); } - // #579: MintPinnedDefault's message hardcoded the testnut DEFAULT_MINT_URL, so on a - // real-minibits-default home `wallet mints remove ` errored "cannot remove the default - // mint (https://testnut.cashudevkit.org)" — naming testnut as the default when the mint actually + // #579: MintPinnedDefault's message hardcoded one mint constant, so on a minibits-default home + // `wallet mints remove ` errored "cannot remove the default mint ()" — + // naming the wrong mint as the default when the mint actually // pinned is minibits (config.default_mint()). Display-only lie; the guard pins correctly. This // pins the message to the ACTUAL default. Reverting the Display fix REDS this. #[test] @@ -1098,7 +1037,7 @@ mod tests { // The shipped default is the real minibits mint, distinct from the testnut constant — so a // message naming testnut here is provably wrong, not a coincidental match. assert_eq!(default, crate::home::DEFAULT_MINIBITS_MINT_URL); - assert_ne!(default, crate::home::DEFAULT_MINT_URL); + assert_ne!(default, FIXTURE_MINT_URL); let message = remove_mint(&mut home, crate::home::DEFAULT_MINIBITS_MINT_URL) .expect_err("removing the pinned default must error") @@ -1108,7 +1047,7 @@ mod tests { "message must name the real pinned default ({default}): {message}" ); assert!( - !message.contains(crate::home::DEFAULT_MINT_URL), + !message.contains(FIXTURE_MINT_URL), "message must not name the testnut constant as the default: {message}" ); let _ = std::fs::remove_dir_all(&root); @@ -1136,23 +1075,21 @@ mod tests { let _ = std::fs::remove_dir_all(&root); } - // Finding T(3): the standalone receive path fails closed on a non-allowlisted REAL mint when - // allow_real_mints=false — even though the mint IS in the configured list (so `mint_is_allowed` - // passes) — the same real-mint fence send/melt enforce. Reached before any wallet open, so it - // holds offline. + // Finding T(3), one-mint form: the standalone receive path gates on the CONFIGURED LIST and on + // nothing else — a token whose own mint this home has not configured is refused before any + // wallet open (so it holds offline), and the same token at a configured mint gets past that gate. #[tokio::test(flavor = "current_thread")] - async fn receive_refuses_real_mint_when_disallowed() { + async fn receive_refuses_a_token_from_an_unconfigured_mint() { use std::str::FromStr; use cashu::secret::Secret; use cashu::{Amount, CurrencyUnit, Id, MintUrl, Proof, SecretKey, Token}; - let real_mint = "https://real-mint.example/"; - let root = temp_home("receive-real-mint-fence"); + let foreign_mint = "https://not-configured.example/"; + let root = temp_home("receive-unconfigured-mint"); let _ = std::fs::remove_dir_all(&root); let mut home = bootstrap(&root).expect("bootstrap"); - home.config.accepted_mints = vec![real_mint.into()]; - home.config.allow_real_mints = false; + home.config.accepted_mints = vec!["https://configured.example/".into()]; let proof = Proof::new( Amount::from(5), @@ -1161,7 +1098,7 @@ mod tests { SecretKey::generate().public_key(), ); let token = Token::new( - MintUrl::from_str(real_mint).expect("mint url"), + MintUrl::from_str(foreign_mint).expect("mint url"), vec![proof], None, CurrencyUnit::Sat, @@ -1169,54 +1106,65 @@ mod tests { let err = receive_async(&home, &token.to_string()) .await - .expect_err("real mint must refuse under allow_real_mints=false"); + .expect_err("a token from an unconfigured mint must refuse"); assert!( - matches!(&err, WalletOpsError::RealMintDisallowed { mint_url } if mint_url.contains("real-mint.example")), - "expected RealMintDisallowed (policy fence, not a membership miss), got {err:?}" + matches!(&err, WalletOpsError::MintNotAllowed { mint_url, .. } if mint_url.contains("not-configured.example")), + "expected MintNotAllowed (the one mint gate), got {err:?}" ); - // #465: the policy refusal must name the ACTUAL control and never the membership remedy — - // `mints add` cannot clear an allow_real_mints=false fence. + // The refusal names the remedy that actually clears it — there is no other control left. let message = err.to_string(); assert!( - message.contains("MAXPLAYER_ALLOW_REAL_MINTS"), - "policy refusal must name the real control (MAXPLAYER_ALLOW_REAL_MINTS), got: {message}" + message.contains("mints add"), + "the membership refusal must name `mints add`, got: {message}" ); assert!( - !message.contains("mints add"), - "policy refusal must NOT borrow the membership `mints add` remedy, got: {message}" + !message.to_ascii_lowercase().contains("allow_real_mints"), + "no real-mint switch exists any more, got: {message}" + ); + + // Configure that same mint and the membership gate no longer refuses: the call now fails + // later, at the (unreachable) mint, which is what proves the gate was the only thing in the + // way. + home.config.extra_mints = vec![foreign_mint.to_owned()]; + let err = receive_async(&home, &token.to_string()) + .await + .expect_err("the example mint is unreachable, so the receive still fails"); + assert!( + matches!(&err, WalletOpsError::Wallet(_)), + "a configured mint must get past the membership gate, got {err:?}" ); let _ = std::fs::remove_dir_all(&root); } - // #500: a funding op must persist ONLY its own change, never the in-memory, env-widened real-mint - // fence. `save_config` writes the FILE-only view (re-reads config.toml, edits that), so an - // `allow_real_mints = true` that exists only because MAXPLAYER_ALLOW_REAL_MINTS opened it in-process - // can never leak to disk. The write-back class was fixed by #84 (fix/save-config-env-promotion); - // this pins the FENCE field on the FUNDING path — which the scalar-only, direct-save - // `save_does_not_persist_env_override_values` (home.rs) did not cover. + // #500: a funding op must persist ONLY its own change, never an in-memory value that exists + // just because an env override set it. `save_config` writes the FILE-only view (re-reads + // config.toml, edits that), so a `MAXPLAYER_*` value can never leak to disk. The write-back + // class was fixed by #84 (fix/save-config-env-promotion); this pins it on the FUNDING path — + // which the scalar-only, direct-save `save_does_not_persist_env_override_values` (home.rs) did + // not cover. #[test] - fn funding_op_never_writes_back_the_env_widened_real_mint_fence() { - let root = temp_home("500-funding-no-gate-writeback"); + fn funding_op_never_writes_back_an_env_only_value() { + let root = temp_home("500-funding-no-env-writeback"); let _ = std::fs::remove_dir_all(&root); let mut home = bootstrap(&root).expect("bootstrap"); - // Durable fence CLOSED on disk — the operator's explicit opt-out. - home::save_config(&mut home, |config| config.allow_real_mints = false) - .expect("seed the fence closed on disk"); - - // Simulate the daemon launcher's MAXPLAYER_ALLOW_REAL_MINTS=true: the env overlay opens the - // fence IN-MEMORY only (`home.config`), while config.toml on disk stays false. - home.config.allow_real_mints = true; + // Durable relay on disk — the operator's explicit setting. + home::save_config(&mut home, |config| { + config.relay_url = "wss://on-disk.example".to_owned() + }) + .expect("seed the relay on disk"); + // Simulate an env overlay (MAXPLAYER_RELAY_URL): in-memory only, config.toml unchanged. + home.config.relay_url = "wss://from-env.example".to_owned(); // A funding op (adds an extra mint) — persists through `save_config`. - let added = add_mint(&mut home, "https://real-mint.example/").expect("add an extra mint"); + let added = add_mint(&mut home, "https://extra-mint.example/").expect("add an extra mint"); let raw = std::fs::read_to_string(root.join("config.toml")).expect("read config.toml"); let on_disk = home::parse_config_toml(&raw).expect("parse config.toml"); - // The durable fence is untouched: the env-widened in-memory value did NOT leak to disk... - assert!( - !on_disk.allow_real_mints, - "a funding op must not write the env-widened real-mint fence back to disk (#500); config.toml = {raw}" + // The durable value is untouched: the env-only in-memory value did NOT leak to disk... + assert_eq!( + on_disk.relay_url, "wss://on-disk.example", + "a funding op must not write an env-only value back to disk (#500); config.toml = {raw}" ); // ...while the funding op's OWN change DID persist. assert!( @@ -1312,42 +1260,62 @@ mod tests { #[test] fn normalize_mint_url_trims_and_strips_trailing_slash() { - let normalized = - normalize_mint_url(" https://testnut.cashudevkit.org/ ").expect("normalize"); - assert_eq!(normalized, DEFAULT_MINT_URL); + let normalized = normalize_mint_url(&format!(" {FIXTURE_MINT_URL}/ ")).expect("normalize"); + assert_eq!(normalized, FIXTURE_MINT_URL); let err = normalize_mint_url(" ").expect_err("empty"); assert!(matches!(err, WalletOpsError::Wallet(_))); } - // #506/#577 money class: `of_mint` classifies PURELY from the mint URL — the testnut play mint is - // Play, every other mint (including the shipped minibits default) is Real. The classification is - // internal: it gates the #445 refusal and the play-money marker, never a surfaced money-class label. + // #577, one-mint form: a mint is a mint. NEGATIVE — no money class survives anywhere on this + // path: `mint_is_allowed` answers membership of the configured list and nothing else, for the + // shipped default and for any other mint alike, over either scheme. #[test] - fn of_mint_classifies_testnut_play_and_others_real() { - assert_eq!(MoneyType::of_mint(DEFAULT_MINT_URL), MoneyType::Play); - // Trailing slash / surrounding whitespace still classify as the testnut mint (normalized). - assert_eq!( - MoneyType::of_mint(" https://testnut.cashudevkit.org/ "), - MoneyType::Play - ); - assert_eq!( - MoneyType::of_mint(crate::home::DEFAULT_MINIBITS_MINT_URL), - MoneyType::Real - ); - assert_eq!( - MoneyType::of_mint("https://real-mint.example"), - MoneyType::Real - ); - // Fail-safe: an unparseable URL is never classified play money. - assert_eq!(MoneyType::of_mint("not a url"), MoneyType::Real); + fn membership_of_the_configured_list_is_the_only_mint_gate() { + let root = temp_home("one-mint-gate"); + let _ = std::fs::remove_dir_all(&root); + let mut home = bootstrap(&root).expect("bootstrap"); + + // The shipped default is admitted because it is CONFIGURED, not because of what it is. + let default_mint = home.config.default_mint().to_owned(); + assert_eq!(default_mint, crate::home::DEFAULT_MINIBITS_MINT_URL); + assert!(mint_is_allowed(&home, &default_mint).is_ok()); + + // Not on the list ⇒ refused, whatever the mint looks like. + for outsider in [ + "https://some-other-mint.example", + "http://127.0.0.1:3338", + "https://testnut.cashudevkit.org", + ] { + assert!( + matches!( + mint_is_allowed(&home, outsider), + Err(WalletOpsError::MintNotAllowed { .. }) + ), + "an unconfigured mint must refuse: {outsider}" + ); + } + + // On the list ⇒ admitted, over EITHER scheme (http is deliberate: a seat's own sidecar mint + // runs on loopback). + home.config.extra_mints = vec![ + "https://some-other-mint.example".to_owned(), + "http://127.0.0.1:3338".to_owned(), + ]; + for insider in ["https://some-other-mint.example", "http://127.0.0.1:3338"] { + assert!( + mint_is_allowed(&home, insider).is_ok(), + "a configured mint must be admitted: {insider}" + ); + } + let _ = std::fs::remove_dir_all(&root); } // #506-A: `MintNotAllowed` must name the home's ACTUAL default (`config.default_mint()`), never - // the pinned testnut constant. On the shipped real-minibits default home, "(default stays - // testnut)" was a money-relevant lie (`wallet mints list` correctly shows minibits as default). - // Red-on-revert: interpolating DEFAULT_MINT_URL again names testnut on a minibits home. + // a compile-time constant. On the shipped minibits-default home, naming any other mint as "the + // default" was a money-relevant lie (`wallet mints list` correctly shows minibits as default). + // Red-on-revert: interpolating a fixture constant again names the wrong mint on a minibits home. #[test] - fn mint_not_allowed_names_home_default_not_testnut_constant() { + fn mint_not_allowed_names_home_default_not_a_constant() { let root = temp_home("506a-default-name"); let _ = std::fs::remove_dir_all(&root); let home = bootstrap(&root).expect("bootstrap"); @@ -1364,7 +1332,7 @@ mod tests { "MintNotAllowed must name the home's real default: {message}" ); assert!( - !message.contains(DEFAULT_MINT_URL), + !message.contains(FIXTURE_MINT_URL), "MintNotAllowed must NOT name the testnut constant as the default on a minibits home: {message}" ); let _ = std::fs::remove_dir_all(&root); diff --git a/crates/maxplayer-core/tests/collect_integrity.rs b/crates/maxplayer-core/tests/collect_integrity.rs index 4a26f690e..6b591c8c9 100644 --- a/crates/maxplayer-core/tests/collect_integrity.rs +++ b/crates/maxplayer-core/tests/collect_integrity.rs @@ -413,9 +413,9 @@ async fn collect_passes_the_sentinel_gate_for_a_valid_delivery() { // (`gate.spent()==charged`, and a journal is written) before the preflight ever runs, so every ZERO // assertion below flips and this test goes RED. That is the exact budget leak Option A closes. // -// `allow_real_mints=true` is ISOLATED to this test home so the pay path will resolve + open the wallet -// at the (dead) real mint; 127.0.0.1:1 refuses the connect, so NO real mint is contacted and no money -// can move. The cosig is unaffected — the `ReceiptPreimage` binds no mint +// The dead mint is SEALED into this test's bind and accepted set, so the pay path resolves + opens +// the wallet at it; 127.0.0.1:1 refuses the connect, so NO mint is contacted and no money can move. +// The cosig is unaffected — the `ReceiptPreimage` binds no mint // (`receipt_preimage_digest_is_independent_of_realized_mint`, authorize_pay.rs). #[tokio::test(flavor = "current_thread")] async fn collect_refuses_dead_mint_at_preflight_before_the_budget_reserve() { @@ -441,11 +441,7 @@ async fn collect_refuses_dead_mint_at_preflight_before_the_budget_reserve() { let root = temp("home-deadmint"); let _ = fs::remove_dir_all(&root); - let mut home = home::bootstrap(&root).expect("home"); - // Real-mint opt-in ISOLATED to this test home: lets `plan_payment` + `open_wallet_at_mint_async` - // resolve/open the wallet at the (dead) real mint so the preflight is actually exercised. The mint - // is connection-refused loopback, so this opts in to no real money. - home.config.allow_real_mints = true; + let home = home::bootstrap(&root).expect("home"); let secret_hex = home::read_secret_key_hex(&home).expect("secret"); let pubkey_hex = home::public_key_hex(&home).expect("pubkey"); diff --git a/crates/maxplayer/src/doctor.rs b/crates/maxplayer/src/doctor.rs index 34db8f502..3448e8b01 100644 --- a/crates/maxplayer/src/doctor.rs +++ b/crates/maxplayer/src/doctor.rs @@ -1648,8 +1648,8 @@ mod checks { /// open AFTER bootstrap (an external chmod, a restored backup, a pre-#473 seat that never /// re-bootstrapped) rather than trusting the enforcement to be the only guard. /// - /// Access-exposure is orthogonal to transaction value — testnut vs real changes nothing about who - /// can read the key — so this never consults the mint. A too-open dir is a WARN for a seat only its + /// Access-exposure is orthogonal to transaction value — which mint a seat uses changes nothing + /// about who can read the key — so this never consults the mint. A too-open dir is a WARN for a seat only its /// named buyers can reach (single-user boxes are common, and there the exposure is nil) and a FAIL /// for one strangers can reach, whose higher exposure warrants the stricter posture. A no-op PASS /// where there is no POSIX mode to read (non-unix): the `too_open` list simply stays empty. diff --git a/crates/maxplayer/src/mcp.rs b/crates/maxplayer/src/mcp.rs index b1195106f..c5fb6b66a 100644 --- a/crates/maxplayer/src/mcp.rs +++ b/crates/maxplayer/src/mcp.rs @@ -429,8 +429,7 @@ fn with_prereq_hint(tool: &str, error: String) -> String { let lower = error.to_lowercase(); let funds_prereq = lower.contains("no balance at any accepted mint") || lower.contains("insufficient") - || lower.contains("mint_unreachable") - || lower.contains("real-mint fence"); + || lower.contains("mint_unreachable"); if funds_prereq { format!( "{error} — {tool} prerequisite: fund your wallet with `maxplayer wallet setup` or \ @@ -523,6 +522,9 @@ fn write_mcp_response(out: &mut dyn Write, value: &Value) -> Result<(), String> #[cfg(test)] mod tests { + /// Test fixture mint host — NOT a default. A mint is just a mint: what makes one usable is membership of + /// the home's configured list, which each test sets up explicitly. + const FIXTURE_MINT_URL: &str = "https://mint.example/Bitcoin"; use super::*; use std::io::Cursor; use std::sync::atomic::{AtomicU64, Ordering}; @@ -540,7 +542,7 @@ mod tests { fn post_job_award_filter_descriptions_match_enforcement() { use maxplayer_core::buyer::lifecycle::{select_awardable_claim, AwardFilters}; use maxplayer_core::gateway::creq::build_seller_creq; - use maxplayer_core::home::DEFAULT_MINT_URL; + use FIXTURE_MINT_URL; use maxplayer_core::job_lifecycle::{ClaimView, JobView}; let listed_tools = tools(); @@ -593,7 +595,7 @@ mod tests { &job_id, 10, "sat", - &[DEFAULT_MINT_URL.to_owned()], + &[FIXTURE_MINT_URL.to_owned()], seller_pubkey, ) .expect("payable creq"); @@ -620,8 +622,7 @@ mod tests { let neutral = AwardFilters { offer_amount_sats: 10, max_sats: 10, - buyer_mint: DEFAULT_MINT_URL, - allow_real_mints: false, + buyer_mint: FIXTURE_MINT_URL, requested_agent: None, requested_harness_family: None, requested_model: None, diff --git a/crates/maxplayer/src/sell.rs b/crates/maxplayer/src/sell.rs index 02ece60f9..4ada731be 100644 --- a/crates/maxplayer/src/sell.rs +++ b/crates/maxplayer/src/sell.rs @@ -16,7 +16,8 @@ use std::path::PathBuf; use maxplayer_core::delivery_transport::is_relay_git_locator; use maxplayer_core::home::{ - self, MaxplayerHome, SellerConfig, DEFAULT_MINT_URL, DEFAULT_RATE_SATS, DEFAULT_RELAY_URL, + self, MaxplayerHome, SellerConfig, DEFAULT_MINIBITS_MINT_URL, DEFAULT_RATE_SATS, + DEFAULT_RELAY_URL, }; use maxplayer_core::profile::{self, SetProfileRequest}; @@ -135,7 +136,7 @@ fn run_sell(options: SellOptions, out: &mut dyn Write, err: &mut dyn Write) -> R config.relay_url = DEFAULT_RELAY_URL.to_owned(); } if needs_mints { - config.accepted_mints = vec![DEFAULT_MINT_URL.to_owned()]; + config.accepted_mints = vec![DEFAULT_MINIBITS_MINT_URL.to_owned()]; } }) .map_err(|error| { diff --git a/crates/maxplayer/src/wallet_cli.rs b/crates/maxplayer/src/wallet_cli.rs index 6074fe571..1f453e61b 100644 --- a/crates/maxplayer/src/wallet_cli.rs +++ b/crates/maxplayer/src/wallet_cli.rs @@ -6,7 +6,7 @@ use std::io::Write; use std::path::PathBuf; -use maxplayer_core::home::{self, MaxplayerHome, DEFAULT_MINIBITS_MINT_URL, DEFAULT_MINT_URL}; +use maxplayer_core::home::{self, MaxplayerHome, DEFAULT_MINIBITS_MINT_URL}; #[cfg(feature = "wallet")] use maxplayer_core::wallet_ops; @@ -73,8 +73,8 @@ fn wallet_usage(err: &mut dyn Write) { \x20\x20\x20# operator: complete ONE payment wedged at Locked by proof-gated REUSE of the already-minted token (never re-mints; STOPS + alarms if the token reads spent)\n" ); // The mint line interpolates the shipped default constant rather than restating it, so this help - // can never drift from the mint the code actually ships — a hardcoded name once let it keep - // naming "testnut" after the default had already moved (#378, #447). + // can never drift from the mint the code actually ships — a hardcoded name once let the help keep + // naming a mint the default had already moved away from (#378, #447). let _ = writeln!( err, "Default mint is {DEFAULT_MINIBITS_MINT_URL}. `setup` and `mint` print a Lightning invoice \ @@ -294,13 +294,6 @@ fn cmd_setup(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 { Ok(home) => home, Err(code) => return code, }; - // #445 (Option 1, fail-closed): never SILENTLY auto-fund play money from invisible home state. - // If setup resolves to the testnut play mint without the user naming it via `--mint`, refuse and - // force the money-type decision into the open — a money act must be affirmed, not defaulted. - if let Some(reason) = refuse_silent_play_money(opts.mint.as_deref(), home.config.default_mint()) { - let _ = writeln!(err, "{reason}"); - return RUNTIME_ERROR; - } // #506-C: make the advertised one-command form work on a fresh home — `wallet setup --mint ` // auto-adds an unconfigured mint (identical to `wallet mints add `) instead of exiting 2 // ("not configured"). Idempotent, and never changes the default (adds to extra_mints only). @@ -316,8 +309,7 @@ fn cmd_setup(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 { SUCCESS } Ok(wallet_ops::MintFlow::NeedsPayment(quote)) => { - // The ordinary path on the shipped default: a real mint invoices, and the sats are the - // user's. Only a dev test mint settles its own invoice and lands in `Funded` above. + // The only path: a mint invoices, and the sats are the user's to pay. let _ = writeln!(err, "{}", setup_needs_payment_summary("e)); let _ = writeln!(out, "{}", quote.invoice); SUCCESS @@ -329,75 +321,40 @@ fn cmd_setup(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 { } } -/// #445 (Option 1): decide whether `wallet setup` must REFUSE rather than silently auto-fund play -/// money. Returns `Some(reason)` to refuse — exactly when the mint would resolve to the testnut play -/// mint AND the user did not name a mint via `--mint`, so play money would be funded from invisible -/// home state. A mint named explicitly (real OR testnut) is an affirmation and always proceeds. Pure -/// so every branch is unit-tested without a live mint. -#[cfg(feature = "wallet")] -fn refuse_silent_play_money(explicit_mint: Option<&str>, default_mint: &str) -> Option { - if explicit_mint.is_some() { - return None; - } - if wallet_ops::MoneyType::of_mint(default_mint) != wallet_ops::MoneyType::Play { - return None; - } - Some(format!( - "refusing to auto-fund PLAY money: this home's default mint is the testnut dev/play mint \ - ({DEFAULT_MINT_URL}), which silently self-funds fake sats. To fund play money, affirm it \ - explicitly with `--mint {DEFAULT_MINT_URL}`; for real money, `maxplayer wallet mints add \ - ` (or set a real default). Nothing was funded." - )) -} - -/// A loud marker for play (testnut dev) money, empty for every ordinary mint. The testnut dev mint -/// self-mints fake sats; surfacing that keeps a dev home from mistaking play money for real funds. -/// Ordinary mints carry no class label — a mint is a mint, identified by its URL (#577). Returns a -/// leading-space field so callers append it directly; empty means the field is simply absent. -#[cfg(feature = "wallet")] -fn play_money_marker(mint_url: &str) -> &'static str { - match wallet_ops::MoneyType::of_mint(mint_url) { - wallet_ops::MoneyType::Play => " play_money=true", - wallet_ops::MoneyType::Real => "", - } -} -/// Summary line for a `wallet setup` that FUNDED. Only the testnut dev mint auto-pays its own invoice, -/// so this row is play money in practice and carries the play-money marker; an ordinary mint would -/// carry none (#577). +/// Summary line for a `wallet setup` that FUNDED. A mint is a mint, identified by its URL (#577): +/// no row carries a money-class label. #[cfg(feature = "wallet")] fn setup_funded_summary(home_root: &std::path::Path, outcome: &wallet_ops::MintOutcome) -> String { format!( - "status=funded home={} funded_sats={} balance_sats={} mint={}{}", + "status=funded home={} funded_sats={} balance_sats={} mint={}", home_root.display(), outcome.funded_sats, outcome.balance_sats, outcome.mint_url, - play_money_marker(&outcome.mint_url), ) } -/// Summary line for a `wallet setup` that returned an invoice to pay (the ordinary path: the mint -/// invoices and the sats are the user's). A play-money marker appears only for a dev play mint; -/// ordinary mints carry no class label (#577). +/// Summary line for a `wallet setup` that returned an invoice to pay (the only path: the mint +/// invoices and the sats are the user's). Mints carry no class label — the URL is the identity +/// (#577). #[cfg(feature = "wallet")] fn setup_needs_payment_summary(quote: &wallet_ops::MintQuote) -> String { format!( - "status=needs_payment amount_sats={} mint={}{} quote_id={} (pay the invoice below, then `maxplayer wallet mint-complete {}`)", + "status=needs_payment amount_sats={} mint={} quote_id={} (pay the invoice below, then `maxplayer wallet mint-complete {}`)", quote.amount_sats, quote.mint_url, - play_money_marker("e.mint_url), quote.quote_id, quote.quote_id, ) } -/// One `wallet balance` row: mint URL, role, balance. A play-money marker trails the row only for a -/// dev play mint; ordinary mints carry no class label — the URL is the mint's identity (#577). +/// One `wallet balance` row: mint URL, role, balance. Mints carry no class label — the URL is the +/// mint's identity (#577). #[cfg(feature = "wallet")] fn balance_row_line(row: &wallet_ops::MintBalance) -> String { format!( - "mint={} role={} balance_sats={}{}", + "mint={} role={} balance_sats={}", row.mint_url, if !row.configured { "unconfigured" @@ -407,7 +364,6 @@ fn balance_row_line(row: &wallet_ops::MintBalance) -> String { "extra" }, row.balance_sats, - play_money_marker(&row.mint_url), ) } @@ -415,10 +371,9 @@ fn balance_row_line(row: &wallet_ops::MintBalance) -> String { #[cfg(feature = "wallet")] fn mints_list_row_line(row: &wallet_ops::MintBalance) -> String { format!( - "mint={} role={}{}", + "mint={} role={}", row.mint_url, if row.is_default { "default" } else { "extra" }, - play_money_marker(&row.mint_url), ) } @@ -746,13 +701,6 @@ fn cmd_mint(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 { Ok(home) => home, Err(code) => return code, }; - // #445 (Option 1, fail-closed): `wallet mint` tops up via the same auto-funding mint_blocking - // path as `setup`, so a SILENT testnut auto-fund is refused identically — a money act is affirmed - // with `--mint`, never defaulted from invisible home state. Same predicate, same placement. - if let Some(reason) = refuse_silent_play_money(opts.mint.as_deref(), home.config.default_mint()) { - let _ = writeln!(err, "{reason}"); - return RUNTIME_ERROR; - } match wallet_ops::mint_blocking(&home, amount, opts.mint.as_deref()) { Ok(wallet_ops::MintFlow::Funded(outcome)) => { let _ = writeln!( @@ -963,13 +911,6 @@ fn cmd_invoice(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 Ok(home) => home, Err(code) => return code, }; - // #445 (Option 1, fail-closed): `wallet invoice` auto-funds via invoice_blocking (the testnut - // dev mint self-settles), so a SILENT testnut auto-fund is refused identically to `setup`/`mint` — - // the money-type decision is forced open with `--mint`, never defaulted. Same predicate, same placement. - if let Some(reason) = refuse_silent_play_money(opts.mint.as_deref(), home.config.default_mint()) { - let _ = writeln!(err, "{reason}"); - return RUNTIME_ERROR; - } match wallet_ops::invoice_blocking(&home, amount, opts.mint.as_deref()) { Ok(wallet_ops::MintFlow::Funded(outcome)) => { let _ = writeln!( @@ -1080,6 +1021,16 @@ fn cmd_mints(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 { #[cfg(test)] mod tests { + + + + + + + + /// Test fixture mint host — NOT a default. A mint is just a mint: what makes one usable is membership of + /// the home's configured list, which each test sets up explicitly. + const FIXTURE_MINT_URL: &str = "https://mint.example/Bitcoin"; use super::*; // #447 + #595: the help once named testnut as the default for a whole release whose default had @@ -1235,17 +1186,18 @@ mod tests { )) } - /// Seed a home whose DEFAULT mint is the testnut PLAY mint — the "reused testnut home" of #445 — - /// on disk, so the CLI's own bootstrap loads it. + /// Seed a home whose configured default is an ORDINARY mint — the shape every test that is + /// about output grammar wants. #[cfg(feature = "wallet")] - fn seed_testnut_default_home(root: &std::path::Path) { + fn seed_fixture_default_home(root: &std::path::Path) { let mut home = home::bootstrap(root).expect("bootstrap seed home"); home::save_config(&mut home, |config| { - config.accepted_mints = vec![DEFAULT_MINT_URL.to_owned()]; + config.accepted_mints = vec![FIXTURE_MINT_URL.to_owned()]; }) - .expect("seed testnut default on disk"); + .expect("seed the fixture mint as default on disk"); } + /// Seed `amount` unspent sats at `mint_url` in the on-disk wallet db. The mint may be configured /// or not — the db does not care, and the `configured`/`role` split comes from the config alone. #[cfg(feature = "wallet")] @@ -1292,7 +1244,7 @@ mod tests { fn balance_reports_unconfigured_db_proofs_and_both_totals() { let root = ux_test_home("balance-db-truth"); let _ = std::fs::remove_dir_all(&root); - seed_testnut_default_home(&root); + seed_fixture_default_home(&root); seed_mint_balance(&root, "https://stray-mint.example/", 37); let mut out = Vec::new(); @@ -1324,7 +1276,7 @@ mod tests { fn balance_mint_filter_distinguishes_discovered_from_never_seen() { let root = ux_test_home("balance-filter-db-truth"); let _ = std::fs::remove_dir_all(&root); - seed_testnut_default_home(&root); + seed_fixture_default_home(&root); seed_mint_balance(&root, "https://stray-mint.example/", 41); let home_arg = root.to_string_lossy().into_owned(); @@ -1382,9 +1334,9 @@ mod tests { fn balance_mint_filter_totals_count_only_the_matched_rows() { let root = ux_test_home("balance-filter-totals"); let _ = std::fs::remove_dir_all(&root); - seed_testnut_default_home(&root); + seed_fixture_default_home(&root); // configured (the default) 23 + unconfigured 41 + unconfigured 17 = 81 whole-wallet. - seed_mint_balance(&root, DEFAULT_MINT_URL, 23); + seed_mint_balance(&root, FIXTURE_MINT_URL, 23); seed_mint_balance(&root, "https://stray-a.example/", 41); seed_mint_balance(&root, "https://stray-b.example/", 17); let home_arg = root.to_string_lossy().into_owned(); @@ -1407,7 +1359,7 @@ mod tests { assert_eq!( whole, format!( - "mint={DEFAULT_MINT_URL} role=default balance_sats=23 play_money=true\n\ + "mint={FIXTURE_MINT_URL} role=default balance_sats=23\n\ mint=https://stray-a.example role=unconfigured balance_sats=41\n\ mint=https://stray-b.example role=unconfigured balance_sats=17\n\ configured_total_sats=23\n\ @@ -1432,11 +1384,11 @@ mod tests { // Filtered to the CONFIGURED default: 23 for both totals, so the configured line collapses by // the existing `configured_total != total` grammar. The pre-fix code printed // `configured_total_sats=23` (equal by luck) followed by the whole-wallet `total_sats=81`. - let default_only = balance(&["--mint", DEFAULT_MINT_URL]); + let default_only = balance(&["--mint", FIXTURE_MINT_URL]); assert_eq!( default_only, format!( - "mint={DEFAULT_MINT_URL} role=default balance_sats=23 play_money=true\ntotal_sats=23\n" + "mint={FIXTURE_MINT_URL} role=default balance_sats=23\ntotal_sats=23\n" ), "{default_only}" ); @@ -1450,7 +1402,7 @@ mod tests { fn balance_configured_only_output_keeps_the_existing_grammar() { let root = ux_test_home("balance-configured-grammar"); let _ = std::fs::remove_dir_all(&root); - seed_testnut_default_home(&root); + seed_fixture_default_home(&root); let mut out = Vec::new(); let mut err = Vec::new(); @@ -1467,372 +1419,18 @@ mod tests { assert_eq!( String::from_utf8(out).expect("utf8"), format!( - "mint={DEFAULT_MINT_URL} role=default balance_sats=0 play_money=true\ntotal_sats=0\n" + "mint={FIXTURE_MINT_URL} role=default balance_sats=0\ntotal_sats=0\n" ) ); let _ = std::fs::remove_dir_all(&root); } - // #445 (Option 1, fail-closed): `wallet setup` on a home whose default is the testnut PLAY mint, - // with NO explicit `--mint`, must REFUSE rather than silently auto-fund fake sats. The refusal is - // reached before any mint round-trip, so this holds offline. RED-ON-REVERT: without the gate the - // command proceeds to the mint path (auto-funding testnut / erroring on the network) — never this - // refusal with empty stdout. - #[cfg(feature = "wallet")] - #[test] - fn setup_refuses_silent_play_money_without_explicit_mint() { - let home = ux_test_home("refuse-silent-play"); - let _ = std::fs::remove_dir_all(&home); - seed_testnut_default_home(&home); - - let mut out = Vec::new(); - let mut err = Vec::new(); - let code = run( - &[ - "setup".into(), - "--home".into(), - home.to_string_lossy().into_owned(), - ], - &mut out, - &mut err, - ); - let out = String::from_utf8(out).expect("utf8"); - let err = String::from_utf8(err).expect("utf8"); - - assert_eq!( - code, RUNTIME_ERROR, - "must refuse, not fund:\nstdout={out}\nstderr={err}" - ); - assert!(out.is_empty(), "nothing funded => empty stdout:\n{out}"); - assert!( - err.contains("PLAY money"), - "refusal must name play money:\n{err}" - ); - assert!( - err.contains(DEFAULT_MINT_URL), - "refusal must name the testnut mint (the --mint affirmation):\n{err}" - ); - assert!( - err.contains("mints add"), - "refusal must offer the real-money remedy:\n{err}" - ); - let _ = std::fs::remove_dir_all(&home); - } - - // #445 (extension): `wallet mint ` tops up via the same auto-funding mint_blocking path as - // `setup`. On a testnut-default home with NO explicit `--mint` it must REFUSE the silent play-money - // auto-fund, reached before any mint round-trip (offline). RED-ON-REVERT: drop the guard and it - // proceeds to mint_blocking (auto-funds testnut / networks) instead of this empty-stdout refusal. - // The explicit `--mint` arm shows the guard does NOT over-block: it passes through to the mint - // layer (which then fails for a DIFFERENT reason — an unconfigured mint — never the play refusal). - #[cfg(feature = "wallet")] - #[test] - fn mint_refuses_silent_play_money_without_explicit_mint() { - let home = ux_test_home("mint-refuse-silent-play"); - let _ = std::fs::remove_dir_all(&home); - seed_testnut_default_home(&home); - let home_str = home.to_string_lossy().into_owned(); - - let mut out = Vec::new(); - let mut err = Vec::new(); - let code = run( - &[ - "mint".into(), - "100".into(), - "--home".into(), - home_str.clone(), - ], - &mut out, - &mut err, - ); - let out = String::from_utf8(out).expect("utf8"); - let err = String::from_utf8(err).expect("utf8"); - assert_eq!( - code, RUNTIME_ERROR, - "must refuse, not fund:\nstdout={out}\nstderr={err}" - ); - assert!(out.is_empty(), "nothing funded => empty stdout:\n{out}"); - assert!( - err.contains("PLAY money"), - "refusal must name play money:\n{err}" - ); - assert!( - err.contains(DEFAULT_MINT_URL), - "refusal must name the testnut mint:\n{err}" - ); - assert!( - err.contains("mints add"), - "refusal must offer the other-mint remedy:\n{err}" - ); - - // Allowed path: an explicit `--mint` affirms the choice, so the guard passes through. - let mut out = Vec::new(); - let mut err = Vec::new(); - let _ = run( - &[ - "mint".into(), - "100".into(), - "--mint".into(), - "https://real-mint.example/".into(), - "--home".into(), - home_str, - ], - &mut out, - &mut err, - ); - let err = String::from_utf8(err).expect("utf8"); - assert!( - !err.contains("PLAY money"), - "explicit --mint must pass the guard:\n{err}" - ); - let _ = std::fs::remove_dir_all(&home); - } - - // #445 (extension): `wallet invoice ` auto-funds via invoice_blocking (the testnut dev mint - // self-settles), so the same silent play-money refusal applies. RED-ON-REVERT: drop the guard and - // it proceeds to invoice_blocking instead of this empty-stdout refusal. The explicit `--mint` arm - // shows pass-through (fails later on the unconfigured mint, never the play refusal). - #[cfg(feature = "wallet")] - #[test] - fn invoice_refuses_silent_play_money_without_explicit_mint() { - let home = ux_test_home("invoice-refuse-silent-play"); - let _ = std::fs::remove_dir_all(&home); - seed_testnut_default_home(&home); - let home_str = home.to_string_lossy().into_owned(); - - let mut out = Vec::new(); - let mut err = Vec::new(); - let code = run( - &[ - "invoice".into(), - "100".into(), - "--home".into(), - home_str.clone(), - ], - &mut out, - &mut err, - ); - let out = String::from_utf8(out).expect("utf8"); - let err = String::from_utf8(err).expect("utf8"); - assert_eq!( - code, RUNTIME_ERROR, - "must refuse, not fund:\nstdout={out}\nstderr={err}" - ); - assert!(out.is_empty(), "nothing funded => empty stdout:\n{out}"); - assert!( - err.contains("PLAY money"), - "refusal must name play money:\n{err}" - ); - assert!( - err.contains(DEFAULT_MINT_URL), - "refusal must name the testnut mint:\n{err}" - ); - assert!( - err.contains("mints add"), - "refusal must offer the other-mint remedy:\n{err}" - ); - - // Allowed path: explicit `--mint` passes the guard (then fails on the unconfigured mint). - let mut out = Vec::new(); - let mut err = Vec::new(); - let _ = run( - &[ - "invoice".into(), - "100".into(), - "--mint".into(), - "https://real-mint.example/".into(), - "--home".into(), - home_str, - ], - &mut out, - &mut err, - ); - let err = String::from_utf8(err).expect("utf8"); - assert!( - !err.contains("PLAY money"), - "explicit --mint must pass the guard:\n{err}" - ); - let _ = std::fs::remove_dir_all(&home); - } - - // The #445 refuse predicate, unit-tested on every branch without a live mint. The integration - // test above can only exercise the refuse branch offline; here the AFFIRMED-play and real-default - // branches are deterministic too. - #[cfg(feature = "wallet")] - #[test] - fn setup_money_gate_refuses_only_silent_play() { - // testnut default + no --mint => the silent-play surprise => refuse. - assert!(refuse_silent_play_money(None, DEFAULT_MINT_URL).is_some()); - // testnut default + explicit --mint testnut => affirmed => proceed. - assert!(refuse_silent_play_money(Some(DEFAULT_MINT_URL), DEFAULT_MINT_URL).is_none()); - // real minibits default + no --mint => a real invoice, never silent play => proceed. - assert!(refuse_silent_play_money(None, DEFAULT_MINIBITS_MINT_URL).is_none()); - // explicit real --mint (whatever the default) => proceed. - assert!( - refuse_silent_play_money(Some("https://real-mint.example"), DEFAULT_MINT_URL).is_none() - ); - // explicit --mint testnut on a real-default home => affirmed play => proceed. - assert!( - refuse_silent_play_money(Some(DEFAULT_MINT_URL), DEFAULT_MINIBITS_MINT_URL).is_none() - ); - } - // #577: a play-money marker appears ONLY on a testnut (play) row; an ordinary/real row carries no - // money-class label at all. Pure formatters so both a testnut-resolving (play) and a - // minibits-resolving (real) setup are asserted without a live mint. The `!contains("money_type")` - // / `!contains("REAL")` / `!contains("real sats")` asserts are the red-on-revert for gudnuf's - // ruling: re-adding the class fork or the "real sats" qualifier reds them. - #[cfg(feature = "wallet")] - #[test] - fn setup_summaries_mark_play_money_only() { - use maxplayer_core::wallet_ops::{MintOutcome, MintQuote}; - // Funded via testnut auto-pay => play money => marker present, alongside the mint URL. - let play = setup_funded_summary( - std::path::Path::new("/tmp/h"), - &MintOutcome { - mint_url: DEFAULT_MINT_URL.to_owned(), - invoice: String::new(), - quote_id: "q".to_owned(), - funded_sats: 21, - balance_sats: 21, - }, - ); - assert!(play.contains("play_money=true"), "{play}"); - assert!(play.contains(&format!("mint={DEFAULT_MINT_URL}")), "{play}"); - // A real mint in the Funded arm carries NO class label — no marker, no money_type, no "REAL". - let real_funded = setup_funded_summary( - std::path::Path::new("/tmp/h"), - &MintOutcome { - mint_url: DEFAULT_MINIBITS_MINT_URL.to_owned(), - invoice: String::new(), - quote_id: "q".to_owned(), - funded_sats: 21, - balance_sats: 21, - }, - ); - assert!(!real_funded.contains("play_money"), "{real_funded}"); - assert!(!real_funded.contains("money_type"), "{real_funded}"); - assert!(!real_funded.contains("REAL"), "{real_funded}"); - // NeedsPayment is the ordinary invoice path: no class label, and no "real sats" qualifier. - let real = setup_needs_payment_summary(&MintQuote { - mint_url: DEFAULT_MINIBITS_MINT_URL.to_owned(), - invoice: "lnbc-invoice".to_owned(), - quote_id: "q".to_owned(), - amount_sats: 21, - }); - assert!(!real.contains("play_money"), "{real}"); - assert!(!real.contains("money_type"), "{real}"); - assert!(!real.contains("real sats"), "{real}"); - assert!( - real.contains(&format!("mint={DEFAULT_MINIBITS_MINT_URL}")), - "{real}" - ); - } - // #577: `wallet balance` and `wallet mints list` rows mark play money ONLY on a testnut (play) - // row; an ordinary/real row carries no money-class label. Pure row formatters, asserted for a - // play (testnut) and a real row. The absence asserts red-on-revert if the class fork returns. - #[cfg(feature = "wallet")] - #[test] - fn wallet_rows_mark_play_money_only() { - use maxplayer_core::wallet_ops::MintBalance; - let testnut_default = MintBalance { - mint_url: DEFAULT_MINT_URL.to_owned(), - balance_sats: 5, - is_default: true, - configured: true, - }; - let real_extra = MintBalance { - mint_url: "https://real-mint.example".to_owned(), - balance_sats: 0, - is_default: false, - configured: true, - }; - let balance = balance_row_line(&testnut_default); - assert!( - balance.contains("role=default") - && balance.contains("play_money=true") - && balance.contains("balance_sats=5"), - "{balance}" - ); - let real_balance = balance_row_line(&real_extra); - assert!( - !real_balance.contains("play_money") - && !real_balance.contains("money_type") - && !real_balance.contains("REAL"), - "{real_balance}" - ); - let listed = mints_list_row_line(&testnut_default); - assert!( - listed.contains("role=default") && listed.contains("play_money=true"), - "{listed}" - ); - let real_listed = mints_list_row_line(&real_extra); - assert!( - !real_listed.contains("play_money") - && !real_listed.contains("money_type") - && !real_listed.contains("REAL"), - "{real_listed}" - ); - } - // End-to-end, offline (mints list never opens a wallet): a testnut-default home with a real extra - // mint marks the default (play) row and leaves the real extra row unmarked — the play marker is - // per-row and appears in real command output, not only the pure formatter. - #[cfg(feature = "wallet")] - #[test] - fn mints_list_command_marks_play_money_per_row() { - let home = ux_test_home("mints-list-labels"); - let _ = std::fs::remove_dir_all(&home); - seed_testnut_default_home(&home); - let home_str = home.to_string_lossy().into_owned(); - let mut out = Vec::new(); - let mut err = Vec::new(); - let add = run( - &[ - "mints".into(), - "add".into(), - "https://real-mint.example/".into(), - "--home".into(), - home_str.clone(), - ], - &mut out, - &mut err, - ); - assert_eq!(add, SUCCESS, "add extra mint: {}", String::from_utf8_lossy(&err)); - let mut out = Vec::new(); - let mut err = Vec::new(); - let code = run( - &["mints".into(), "list".into(), "--home".into(), home_str], - &mut out, - &mut err, - ); - let out = String::from_utf8(out).expect("utf8"); - assert_eq!(code, SUCCESS, "stderr: {}", String::from_utf8_lossy(&err)); - let default_row = out - .lines() - .find(|line| line.contains("role=default")) - .expect("a default row"); - assert!( - default_row.contains(&format!("mint={DEFAULT_MINT_URL}")) - && default_row.contains("play_money=true"), - "default row:\n{default_row}\nfull:\n{out}" - ); - let extra_row = out - .lines() - .find(|line| line.contains("role=extra")) - .expect("an extra row"); - assert!( - !extra_row.contains("play_money") - && !extra_row.contains("money_type") - && !extra_row.contains("REAL"), - "extra row:\n{extra_row}\nfull:\n{out}" - ); - let _ = std::fs::remove_dir_all(&home); - } // #506-C: `wallet setup --mint ` on a fresh home must AUTO-ADD the mint (so the advertised // one-command form works) instead of exiting 2 "is not configured". The add persists before any diff --git a/docs/BUYER-QUICKSTART.md b/docs/BUYER-QUICKSTART.md index 0e3fb4afb..1a2e0974e 100644 --- a/docs/BUYER-QUICKSTART.md +++ b/docs/BUYER-QUICKSTART.md @@ -66,10 +66,10 @@ waits for you to pay it out-of-band. Minting the ecash is a second command: "$MAXPLAYER_BIN" wallet balance ``` -The shipped mint is `https://mint.minibits.cash/Bitcoin` and `allow_real_mints` is `true`, so -`"$MAXPLAYER_BIN" wallet setup` provisions the wallet there and prints a Lightning invoice you fund -yourself — it does not auto-fund. Buyers spend from that wallet, bounded by the -per-job budget cap in `config.toml`. +The shipped mint is `https://mint.minibits.cash/Bitcoin` — a mint is usable when it is in +`accepted_mints` (or `extra_mints`), and that list is the only mint gate. `"$MAXPLAYER_BIN" wallet +setup` provisions the wallet there and prints a Lightning invoice you fund yourself — it does not +auto-fund. Buyers spend from that wallet, bounded by the per-job budget cap in `config.toml`. ## 3. Add the MCP to your agent diff --git a/docs/DOCKER.md b/docs/DOCKER.md index 07aaa8e65..81f735d74 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -13,8 +13,8 @@ git delivery runs in-process and TLS roots are bundled. - **User:** unprivileged (`uid 10001`). - **Defaults baked in:** relay `wss://relay.maxplayer.ai` (the open-market relay; override in `config.toml` or via `MAXPLAYER_RELAY_URL` to sell against your - own), and the default mint `https://mint.minibits.cash/Bitcoin` with - `allow_real_mints = true`. + own), and the default mint `https://mint.minibits.cash/Bitcoin`. A mint is usable when it is in + `accepted_mints` (or `extra_mints`) — that list is the only mint gate. ## Build diff --git a/scripts/crossmint-smoke.sh b/scripts/crossmint-smoke.sh index 16447a0d7..2e0bee667 100755 --- a/scripts/crossmint-smoke.sh +++ b/scripts/crossmint-smoke.sh @@ -221,17 +221,18 @@ authorized. This is real money: it is authorized by a human naming those three v agent and never by a retry." } -# Config is supplied ENTIRELY by environment. Measured: env overrides are never persisted — a home -# booted with MAXPLAYER_ALLOW_REAL_MINTS=true still has `allow_real_mints = false` in its config.toml. -# So the real-mint fence is process-scoped and evaporates on exit. There is no fence to restore and -# no EXIT trap that could fail to restore it. +# Config is supplied ENTIRELY by environment. Measured: env overrides are never persisted, so the +# mint lists below are process-scoped and evaporate on exit. There is nothing to restore and no EXIT +# trap that could fail to restore it. +# +# A mint is usable when it is on the seat's configured list, and not otherwise — that list is what +# these variables set. # # These are EXPORTED rather than prefixed onto each call: maxplayer_at is a shell function, and `env # VAR=x maxplayer_at ...` cannot work — env execs a binary and would never see the function. Exporting -# also means the fence and the cap apply to every invocation in the stage, not just the ones a +# also means the mint lists and the cap apply to every invocation in the stage, not just the ones a # caller remembered to prefix. -arm_real_mints() { - export MAXPLAYER_ALLOW_REAL_MINTS=true +arm_mint_lists() { export MAXPLAYER_ACCEPTED_MINTS="$SOURCE_MINT" export MAXPLAYER_EXTRA_MINTS="$TARGET_MINT" # The exposure cap as a RUNTIME gate rather than a matter of discipline: the budget gate @@ -246,7 +247,7 @@ arm_real_mints() { # `status=needs_payment` branch is exercised — the branch no test mint can produce. fund() { require_auth - arm_real_mints + arm_mint_lists local home="$RUN_DIR/probe" rule "FUND — raise a ${FUND_SATS} sat mint quote at the SOURCE (${SOURCE_MINT})" ensure_home "$home" @@ -273,7 +274,7 @@ If it auto-funded instead, it is a TEST mint and this is not the real-sats path. fund_complete() { # require_auth - arm_real_mints + arm_mint_lists local home="$RUN_DIR/probe" quote_id="${1:-}" [ -n "$quote_id" ] || die "usage: $0 --fund-complete " rule "FUND-COMPLETE — issue the ecash at the source" @@ -293,7 +294,7 @@ the mint and recoverable — re-run this exact command. Do not re-pay the invoic # ── Stage A: routing + fee probe ──────────────────────────────────────────────────────────────── stage_a() { require_auth - arm_real_mints + arm_mint_lists local home="$RUN_DIR/probe" rule "STAGE A — routing + fee probe: ${PROBE_SATS} sats ${SOURCE_MINT} -> ${TARGET_MINT}" ensure_home "$home" @@ -346,7 +347,7 @@ unpaid. STOP HERE and report. Do not run mint-complete." ${TARGET_MINT} failed for quote ${quote_id}. The money left the source and is not yet ecash at the target. It is RECOVERABLE and no sats are lost — re-run exactly: - MAXPLAYER_HOME=${home} MAXPLAYER_ALLOW_REAL_MINTS=true MAXPLAYER_ACCEPTED_MINTS=${SOURCE_MINT} \\ + MAXPLAYER_HOME=${home} MAXPLAYER_ACCEPTED_MINTS=${SOURCE_MINT} \\ MAXPLAYER_EXTRA_MINTS=${TARGET_MINT} ${MAXPLAYER} wallet mint-complete ${quote_id} \\ --amount ${PROBE_SATS} --mint ${TARGET_MINT} --home ${home} diff --git a/web/app/.well-known/skills/buyer-operate/skill.md b/web/app/.well-known/skills/buyer-operate/skill.md index f90be5a9d..e9996ca76 100644 --- a/web/app/.well-known/skills/buyer-operate/skill.md +++ b/web/app/.well-known/skills/buyer-operate/skill.md @@ -8,8 +8,9 @@ description: Set up and operate a Maxplayer buyer from nothing — install the b You post jobs, other agents do them, you pay in ecash. This is the setup-to-first-paid-delivery path. Five steps, then the things that will cost you money if you skip them. -The shipped wallet provisions on `https://mint.minibits.cash/Bitcoin` and `allow_real_mints` is -`true`. `wallet setup` prints a Lightning invoice you fund yourself; nothing is auto-funded. +The shipped wallet provisions on `https://mint.minibits.cash/Bitcoin`, which is the one mint a +fresh config accepts. A mint is usable when it is on this seat's accepted list, and not otherwise. +`wallet setup` prints a Lightning invoice you fund yourself; nothing is auto-funded. --- diff --git a/web/app/.well-known/skills/debug-buying/skill.md b/web/app/.well-known/skills/debug-buying/skill.md index 299209975..2d23937fd 100644 --- a/web/app/.well-known/skills/debug-buying/skill.md +++ b/web/app/.well-known/skills/debug-buying/skill.md @@ -149,7 +149,7 @@ and the per-mint breakdown from `maxplayer wallet mints`. ## Symptom: which mint am I actually spending at? A fresh config accepts bitcoin-denominated ecash at a minibits mint -(`allow_real_mints = true`, mint `https://mint.minibits.cash/Bitcoin`). +(`https://mint.minibits.cash/Bitcoin`) — the one entry on its accepted list. `maxplayer wallet setup` with no `--mint` targets that mint and returns a Lightning invoice you must pay. It does not hand you free test sats. Ask the wallet rather than @@ -191,31 +191,21 @@ the seller's advertised `accepted_mints`. --- -## Symptom: a claim is on a mint I can't pay (mint mismatch / real-mint fence) +## Symptom: a claim is on a mint I cannot pay (mint mismatch) Your wallet settles at specific mints. When a seller's mint differs from yours, the buyer -does **not** immediately give up: if real mints are allowed it tries a **cross-mint -Lightning hop** to land the payment on a mint the seller accepts. A mismatch only blocks -the trade when there is **no permitted route** — most often because you have pinned -yourself to test-only mints. +does **not** immediately give up: it tries a **cross-mint Lightning hop** to land the +payment on a mint the seller accepts. A mismatch only blocks the trade when there is **no +route** — most often because the seller lists no mint your wallet can reach. **Check:** - your side: `maxplayer buyer status` → `wallet.mint`, and `maxplayer wallet mints` for every mint you can pay from -- your fence: `allow_real_mints` in `~/.maxplayer/config.toml` (default `true`) +- your list: `accepted_mints` (and `extra_mints`) in `~/.maxplayer/config.toml` -**Read it:** -- `allow_real_mints = true` (default) → hops to real mints are permitted, so a mismatch - usually still settles. If it fails, the hop's target mint was likely unreachable. -- `allow_real_mints = false` → you are pinned to the built-in dev allow-list, and a - seller who accepts only a real mint **cannot be paid**. Auto-award skips the claim; a - manual pay fails closed with a message like: - -``` -real-mint fence: buyer mint is not in the creq mint list [...] 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 -``` +**Read it:** a mint is admitted iff it is on this seat's accepted list. There is no second +switch. A mismatch with the seller usually still settles, because the buyer hops to a mint +the seller's `creq` names; if it fails, that target mint was likely unreachable. **A real trap when judging the counterparty:** a seller's *advertised* mints are self-reported. The announce no longer carries a single (once hardcoded) mint — the @@ -225,12 +215,12 @@ given trade uses. The payable mint for one trade is the one carried by that trad `creq`, so do not infer the settlement mint from the advert. **Fix:** -- to pay a seller on a non-dev mint, keep `allow_real_mints = true` -- add a mint the seller accepts to `extra_mints` in `~/.maxplayer/config.toml` +- add a mint the seller accepts to `extra_mints` in `~/.maxplayer/config.toml` — adding it + to the list is what admits it - or trade with a seller on a mint you already hold -**Dead end → report it:** if a mismatch fails even with `allow_real_mints = true` (a hop -that should land but doesn't), or a seller's advertised `accepted_mints` do not match +**Dead end → report it:** if a mismatch fails even with the seller's mint on your list (a +hop that should land but doesn't), or a seller's advertised `accepted_mints` do not match what they actually accept, file on **MakePrisms/maxplayerai** with your `wallet.mint`, the seller pubkey, and the exact error — or raise it on the buzz market channel.