From bd182e10b653c54d53470872799e7be537004377 Mon Sep 17 00:00:00 2001 From: w-ecash-mutual-credit Date: Thu, 3 Sep 2026 08:14:44 -0700 Subject: [PATCH 1/6] one mint: the seat's configured list is the only gate on a mint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner ruling (Bob, 2 Sep 2026): "the concept of real mint doesn't make sense - its just a mint. the seller chooses what mint to accept or not" and "there should be no 'testmint' concept". One rule is left: a mint is usable by this seat when it is on this seat's configured list, and not otherwise. - `allow_real_mints` is gone: the config field, its serde default, the env row, and the config-template block. A config.toml still carrying the key LOADS — it is dropped before the `deny_unknown_fields` parse and the drop is announced once, so no operator's boot breaks. `MAXPLAYER_ALLOW_REAL_MINTS` joins the reserved env seams for the same reason. - `home::mint_allowed(url, switch)` becomes `home::mint_url_supported(url)`: a SHAPE rule (http:// or https:// with a host), not a policy. `http://` is admitted deliberately — a seat's own sidecar mint runs on loopback. - `WalletOpsError::RealMintDisallowed` is gone with the duplicate fences that raised it: send/receive/melt already gate on the configured list, and the seller's redeem path already gates on the stored creq's accepted set. `plan_payment` / `select_source_mint` / `AwardFilters` lose the parameter, so their callers change with them. - `DEFAULT_MINT_URL` loses its name. Nothing in the product is called the default mint except the configured list (shipped default: minibits), which is now what `default_mint()`'s fallback, the seller setup seed and the dead-host migration read. Test fixtures use `FIXTURE_MINT_URL`, a plain example host. - Product docs that taught the removed concept are rewritten to the one rule: the crossmint smoke script (its dead env export was already a no-op), both served skill.md files, README's play-money paragraph, docs/DOCKER.md and docs/BUYER-QUICKSTART.md. The money class (`MoneyType`, `is_autopay_mint`, `refuse_silent_play_money` and the play-money row marker) is still PRESENT here, re-anchored on `LEGACY_TESTNUT_MINT_URL` so this commit compiles and the split is reviewable one change at a time. The next commit deletes it. --- README.md | 4 - crates/maxplayer-core/src/authorize_pay.rs | 154 +- crates/maxplayer-core/src/buyer/lifecycle.rs | 79 +- crates/maxplayer-core/src/buyer/mod.rs | 7 +- crates/maxplayer-core/src/buyer_fund.rs | 74 +- crates/maxplayer-core/src/collect.rs | 5 +- crates/maxplayer-core/src/crossmint.rs | 183 +-- crates/maxplayer-core/src/crossmint_hop.rs | 8 +- crates/maxplayer-core/src/home.rs | 166 ++- crates/maxplayer-core/src/job_lifecycle.rs | 20 +- crates/maxplayer-core/src/seller_node/run.rs | 10 +- crates/maxplayer-core/src/wallet_ops.rs | 291 ++-- .../maxplayer-core/tests/collect_integrity.rs | 12 +- crates/maxplayer/src/doctor.rs | 4 +- crates/maxplayer/src/mcp.rs | 10 +- crates/maxplayer/src/sell.rs | 5 +- crates/maxplayer/src/wallet_cli.rs | 1233 +++++++++-------- docs/BUYER-QUICKSTART.md | 8 +- docs/DOCKER.md | 4 +- scripts/crossmint-smoke.sh | 15 +- .../.well-known/skills/buyer-operate/skill.md | 5 +- .../.well-known/skills/debug-buying/skill.md | 29 +- 22 files changed, 1171 insertions(+), 1155 deletions(-) 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..1aa7e986c 100644 --- a/crates/maxplayer-core/src/authorize_pay.rs +++ b/crates/maxplayer-core/src/authorize_pay.rs @@ -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 } @@ -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..1b085779c 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. @@ -85,13 +83,11 @@ 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..3671b2987 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` @@ -22,7 +23,9 @@ 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 +36,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 +89,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 +156,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 +181,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 +194,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 +239,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..a02b47a88 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,18 @@ 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. +/// The historical dev mint. **Never a default and never seeded**: nothing in the product writes it +/// into a config, and no code path prefers it. It survives under this name only so the two places +/// that must still recognise the host by sight can say which host they mean — the money-class +/// check in [`crate::wallet_ops`] (held pending a ruling) and this module's own migration prose. +/// If you are looking for "the default mint", it is the seat's `accepted_mints` list. +pub const LEGACY_TESTNUT_MINT_URL: &str = "https://testnut.cashudevkit.org"; +/// 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 +1311,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 +1333,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 +1441,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 +1458,35 @@ 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. /// -/// - `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()) - } else { - mint_url == DEFAULT_MINT_URL - } +/// 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, and an +/// https-only shape rule would make the operator's own list unusable. +pub fn mint_url_supported(mint_url: &str) -> bool { + let host = mint_url + .strip_prefix("https://") + .or_else(|| mint_url.strip_prefix("http://")); + host.is_some_and(|host| !host.is_empty()) } 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 +1497,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 +1574,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 +1625,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 +1672,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 +1791,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 +1936,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 +1947,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 +2534,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", ) @@ -2618,18 +2627,21 @@ mod tests { } #[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 +2850,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..7df7d2488 100644 --- a/crates/maxplayer-core/src/job_lifecycle.rs +++ b/crates/maxplayer-core/src/job_lifecycle.rs @@ -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..904ff7136 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, LEGACY_TESTNUT_MINT_URL}; #[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> { @@ -377,6 +336,38 @@ async fn open_balance_store(home: &MaxplayerHome) -> Result bool { + normalize_mint_url(mint_url) + .ok() + .as_deref() + == Some(LEGACY_TESTNUT_MINT_URL) +} + +/// Money class a mint moves, derived purely from the mint URL. The historical dev mint +/// ([`LEGACY_TESTNUT_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 historical dev 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 + } + } +} + /// Balance per configured or wallet-database-discovered mint. The sqlite store is shared across /// every mint, and proofs legally land at mints outside the configured set (seller redemption at /// `accepted_mints[1..]`, cross-mint hop residue) — so the read enumerates the DB truth (proof @@ -593,9 +584,9 @@ 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`]). +/// A mint that FakeWallet-auto-pays its own invoices ([`LEGACY_TESTNUT_MINT_URL`]) goes begin → +/// complete; every other mint returns [`MintFlow::NeedsPayment`] with the bolt11 already surfaced +/// (caller pays, then [`complete_mint_async`]). pub async fn mint_async( home: &MaxplayerHome, amount_sats: u64, @@ -609,8 +600,8 @@ pub async fn mint_async( } } -/// 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; an auto-paying mint mints once it settles. Other mints return +/// [`MintFlow::NeedsPayment`] (invoice before any wait). pub async fn invoice_async( home: &MaxplayerHome, amount_sats: u64, @@ -629,12 +620,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 +672,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 +720,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 +935,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 +1060,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 +1074,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 +1084,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 +1112,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 +1135,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 +1143,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 +1297,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 +1369,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..1b1d19f88 100644 --- a/crates/maxplayer/src/mcp.rs +++ b/crates/maxplayer/src/mcp.rs @@ -523,6 +523,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 +543,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 +596,7 @@ mod tests { &job_id, 10, "sat", - &[DEFAULT_MINT_URL.to_owned()], + &[FIXTURE_MINT_URL.to_owned()], seller_pubkey, ) .expect("payable creq"); @@ -620,8 +623,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..682f16343 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, LEGACY_TESTNUT_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 \ @@ -295,8 +295,8 @@ fn cmd_setup(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 { 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 setup resolves to the auto-paying dev 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; @@ -316,8 +316,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,8 +328,9 @@ 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 +/// money. Returns `Some(reason)` to refuse — exactly when the mint would resolve to the auto-paying dev /// 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. @@ -344,13 +344,13 @@ fn refuse_silent_play_money(explicit_mint: Option<&str>, default_mint: &str) -> } 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 \ + ({LEGACY_TESTNUT_MINT_URL}), which silently self-funds fake sats. To fund play money, affirm it \ + explicitly with `--mint {LEGACY_TESTNUT_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 +/// A loud marker for play (auto-paying dev) money, empty for every ordinary mint. That 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. @@ -362,7 +362,7 @@ fn play_money_marker(mint_url: &str) -> &'static str { } } -/// Summary line for a `wallet setup` that FUNDED. Only the testnut dev mint auto-pays its own invoice, +/// Summary line for a `wallet setup` that FUNDED. Only the auto-paying dev mint 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). #[cfg(feature = "wallet")] @@ -378,7 +378,7 @@ fn setup_funded_summary(home_root: &std::path::Path, outcome: &wallet_ops::MintO } /// 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; +/// invoices and the sats are the user's). A play-money marker appears only for that mint; /// ordinary mints carry no class label (#577). #[cfg(feature = "wallet")] fn setup_needs_payment_summary(quote: &wallet_ops::MintQuote) -> String { @@ -392,8 +392,8 @@ fn setup_needs_payment_summary(quote: &wallet_ops::MintQuote) -> String { ) } -/// 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. A play-money marker trails the row only for that +/// mint; ordinary 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!( @@ -746,9 +746,8 @@ 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. + // #445 (Option 1, fail-closed): the same silent-play refusal as `setup` — same predicate, same + // placement; a money act is affirmed with `--mint`, never defaulted from invisible home state. if let Some(reason) = refuse_silent_play_money(opts.mint.as_deref(), home.config.default_mint()) { let _ = writeln!(err, "{reason}"); return RUNTIME_ERROR; @@ -963,9 +962,8 @@ 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. + // #445 (Option 1, fail-closed): the same silent-play refusal as `setup` — same predicate, same + // placement; a money act is affirmed with `--mint`, never defaulted from invisible home state. if let Some(reason) = refuse_silent_play_money(opts.mint.as_deref(), home.config.default_mint()) { let _ = writeln!(err, "{reason}"); return RUNTIME_ERROR; @@ -1080,760 +1078,783 @@ fn cmd_mints(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 { #[cfg(test)] mod tests { - use super::*; - // #447 + #595: the help once named testnut as the default for a whole release whose default had - // already moved (#378) — a string nothing checks. This binds the wording to the shipped constant - // AND, per gudnuf's mints-are-mints ruling, forbids forking mint classes or saying "real" in the - // copy. RED-ON-REVERT: re-adding "REAL"/"real sats" (or naming testnut as the default) reds this. + // #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 wallet_usage_names_the_shipped_default_mint_and_never_calls_it_testnut() { + 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(); - wallet_usage(&mut err); - let text = String::from_utf8(err).expect("utf8"); + 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!( - text.contains(DEFAULT_MINIBITS_MINT_URL), - "help must name the mint a fresh config actually uses:\n{text}" + err.contains("PLAY money"), + "refusal must name play money:\n{err}" ); assert!( - !text.contains("Default mint is testnut") && !text.contains("default testnut"), - "help still presents testnut as the default:\n{text}" + err.contains(LEGACY_TESTNUT_MINT_URL), + "refusal must name the testnut mint (the --mint affirmation):\n{err}" ); - // #595 (mints-are-mints): a mint is a mint — the help must not fork classes or say "real". assert!( - !text.contains("REAL") && !text.contains("real sats") && !text.contains("real money"), - "help must not fork mint classes or say 'real':\n{text}" + 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 parse_common_accepts_amount_flag_alongside_positional() { - let (opts, positional) = - parse_common(&["--amount".into(), "21".into(), "quote-123".into()]).expect("parse"); - assert_eq!(opts.amount, Some(21)); - assert_eq!(positional, vec!["quote-123".to_owned()]); - } - - #[test] - fn parse_common_amount_flag_rejects_non_numeric() { - let err = parse_common(&["--amount".into(), "abc".into()]).expect_err("reject"); - assert!(err.contains("invalid amount")); - } - - #[test] - fn parse_common_amount_flag_requires_value() { - let err = parse_common(&["--amount".into()]).expect_err("reject"); - assert!(err.contains("--amount requires a value")); - } + 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(); - #[test] - fn mint_complete_without_quote_id_is_usage_error() { - // No positional quote_id => usage error, before any wallet op runs. let mut out = Vec::new(); let mut err = Vec::new(); - let code = run(&["mint-complete".into()], &mut out, &mut err); - assert_eq!(code, USAGE_ERROR); - } - - #[cfg(feature = "wallet")] - #[test] - fn parse_complete_locked_requires_the_identity_flags() { - // Missing --amount (a required identity flag) refuses at parse — before any home/gate/wallet. - let args: Vec = [ - "--job-id", "j", "--result-id", "r", "--delivery-integrity-hash", "aa", - "--job-hash", "bb", "--seller-pubkey", "cc", "--seller-signature", "dd", - ] - .iter() - .map(|s| (*s).to_owned()) - .collect(); - let err = parse_complete_locked(&args).expect_err("must refuse without --amount"); - assert!(err.contains("--amount"), "error should name the missing flag, got: {err}"); - } + 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(LEGACY_TESTNUT_MINT_URL), + "refusal must name the testnut mint:\n{err}" + ); + assert!( + err.contains("mints add"), + "refusal must offer the other-mint remedy:\n{err}" + ); - #[cfg(feature = "wallet")] - #[test] - fn parse_complete_locked_collects_repeated_accepted_mints() { - let args: Vec = [ - "--job-id", "job1", "--result-id", "res1", "--delivery-integrity-hash", "11", - "--job-hash", "22", "--seller-pubkey", "ab", "--amount", "100", - "--seller-signature", "ff", "--accepted-mint", "https://m1", "--accepted-mint", - "https://m2", "--realized-mint", "https://m1", "--home", "/tmp/x", - ] - .iter() - .map(|s| (*s).to_owned()) - .collect(); - let (home, request) = parse_complete_locked(&args).expect("parse"); - assert_eq!(home, Some(PathBuf::from("/tmp/x"))); - assert_eq!(request.job_id, "job1"); - assert_eq!(request.amount_sats, 100); - assert_eq!(request.seller_signature, "ff"); - assert_eq!(request.accepted_mints, vec!["https://m1".to_owned(), "https://m2".to_owned()]); - assert_eq!(request.realized_mint, Some("https://m1".to_owned())); + // 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); } - // RED-PROVE (operator surface): a spent-token completion outcome emits a loud ALARM to stderr and - // returns the DISTINCT alarm exit code, with nothing on stdout. Pairs with the core red-prove - // (payment.rs) that the entrypoint refuses and leaves the journal unchanged. + // #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 spent_completion_emits_alarm_and_distinct_exit_code() { - use maxplayer_core::authorize_pay::AuthorizePayError; - use maxplayer_core::payment::PaymentError; + 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 = report_complete_locked( - Err(AuthorizePayError::Payment(PaymentError::LockedTokenSpent( - "attempt 247ad2e6: a proof reads Spent at the mint".into(), - ))), + 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, COMPLETE_LOCKED_SPENT_ALARM, - "spent must return the distinct alarm exit code, not the generic runtime error" + 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(LEGACY_TESTNUT_MINT_URL), + "refusal must name the testnut mint:\n{err}" + ); + assert!( + err.contains("mints add"), + "refusal must offer the other-mint remedy:\n{err}" ); - let err_text = String::from_utf8(err).expect("utf8"); - assert!(err_text.starts_with("ALARM"), "must lead with a greppable ALARM, got: {err_text}"); - assert!(err_text.contains("NOT resending"), "alarm must state it is not resending"); - assert!(err_text.contains("Journal left unchanged")); - assert!(out.is_empty(), "no success output on the alarm path"); - } - - #[cfg(feature = "wallet")] - #[test] - fn missing_token_completion_refuses_without_the_spent_alarm() { - use maxplayer_core::authorize_pay::AuthorizePayError; - use maxplayer_core::payment::PaymentError; + // Allowed path: explicit `--mint` passes the guard (then fails on the unconfigured mint). let mut out = Vec::new(); let mut err = Vec::new(); - let code = report_complete_locked( - Err(AuthorizePayError::Payment(PaymentError::LockedTokenMissing( - "no confirmed send transaction".into(), - ))), + let _ = run( + &[ + "invoice".into(), + "100".into(), + "--mint".into(), + "https://real-mint.example/".into(), + "--home".into(), + home_str, + ], &mut out, &mut err, ); - assert_eq!(code, RUNTIME_ERROR, "missing is a refuse, not the spent alarm"); - let err_text = String::from_utf8(err).expect("utf8"); - assert!(!err_text.starts_with("ALARM"), "missing must NOT masquerade as the accounting-gap alarm"); - assert!(err_text.contains("refusing to remint"), "got: {err_text}"); + 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 + #506 setup-UX fold ---- - + // 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")] - fn ux_test_home(label: &str) -> PathBuf { - use std::sync::atomic::{AtomicU64, Ordering}; - static NEXT: AtomicU64 = AtomicU64::new(0); - let id = NEXT.fetch_add(1, Ordering::SeqCst); - std::env::temp_dir().join(format!( - "maxplayer-setupux-{label}-{}-{id}", - std::process::id() - )) + #[test] + fn setup_money_gate_refuses_only_silent_play() { + // auto-paying dev mint default + no --mint => the silent-play surprise => refuse. + assert!(refuse_silent_play_money(None, LEGACY_TESTNUT_MINT_URL).is_some()); + // auto-paying dev mint default + explicit --mint testnut => affirmed => proceed. + assert!(refuse_silent_play_money(Some(LEGACY_TESTNUT_MINT_URL), LEGACY_TESTNUT_MINT_URL).is_none()); + // 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"), LEGACY_TESTNUT_MINT_URL).is_none() + ); + // explicit --mint testnut on a real-default home => affirmed play => proceed. + assert!( + refuse_silent_play_money(Some(LEGACY_TESTNUT_MINT_URL), DEFAULT_MINIBITS_MINT_URL).is_none() + ); } - /// 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. + // #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")] - fn seed_testnut_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()]; - }) - .expect("seed testnut default on disk"); + #[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: LEGACY_TESTNUT_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={LEGACY_TESTNUT_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}" + ); } - /// 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. + // #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")] - fn seed_mint_balance(root: &std::path::Path, mint_url: &str, amount: u64) { - use std::str::FromStr; - - use cashu::secret::Secret; - use cashu::{Amount, CurrencyUnit, Id, MintUrl, Proof, SecretKey, State}; - use cdk::cdk_database::WalletDatabase; - use cdk::wallet::types::ProofInfo; - use cdk_sqlite::wallet::WalletSqliteDatabase; - - let home = home::bootstrap(root).expect("bootstrap proof home"); - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("test runtime"); - runtime.block_on(async { - let store = WalletSqliteDatabase::new(home.wallet_dir.join("cdk-wallet.sqlite")) - .await - .expect("open on-disk wallet database"); - let proof = Proof::new( - Amount::from(amount), - Id::from_str("009a1f293253e41e").expect("keyset id"), - Secret::new(format!("wallet-cli-issue-266-{amount}-{mint_url}")), - SecretKey::generate().public_key(), - ); - let info = ProofInfo::new( - proof, - MintUrl::from_str(mint_url).expect("mint URL"), - State::Unspent, - CurrencyUnit::Sat, - ) - .expect("proof info"); - store - .update_proofs(vec![info], vec![]) - .await - .expect("seed unconfigured proof"); - }); + #[test] + fn wallet_rows_mark_play_money_only() { + use maxplayer_core::wallet_ops::MintBalance; + let testnut_default = MintBalance { + mint_url: LEGACY_TESTNUT_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 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_mint_balance(&root, "https://stray-mint.example/", 37); + 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 code = run( + let add = run( &[ - "balance".into(), + "mints".into(), + "add".into(), + "https://real-mint.example/".into(), "--home".into(), - root.to_string_lossy().into_owned(), + 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!( - out.contains("mint=https://stray-mint.example role=unconfigured balance_sats=37"), - "{out}" + default_row.contains(&format!("mint={LEGACY_TESTNUT_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!( - out.contains("configured_total_sats=0\ntotal_sats=37\n"), - "{out}" + !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(&root); + let _ = std::fs::remove_dir_all(&home); } + /// 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::*; - #[cfg(feature = "wallet")] + // #447 + #595: the help once named testnut as the default for a whole release whose default had + // already moved (#378) — a string nothing checks. This binds the wording to the shipped constant + // AND, per gudnuf's mints-are-mints ruling, forbids forking mint classes or saying "real" in the + // copy. RED-ON-REVERT: re-adding "REAL"/"real sats" (or naming testnut as the default) reds this. #[test] - 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_mint_balance(&root, "https://stray-mint.example/", 41); - let home_arg = root.to_string_lossy().into_owned(); - - let mut out = Vec::new(); + fn wallet_usage_names_the_shipped_default_mint_and_never_calls_it_testnut() { let mut err = Vec::new(); - let code = run( - &[ - "balance".into(), - "--mint".into(), - "https://stray-mint.example/".into(), - "--home".into(), - home_arg.clone(), - ], - &mut out, - &mut err, - ); - let out = String::from_utf8(out).expect("utf8"); - assert_eq!(code, SUCCESS, "stderr: {}", String::from_utf8_lossy(&err)); - assert!(out.contains("role=unconfigured balance_sats=41"), "{out}"); + wallet_usage(&mut err); + let text = String::from_utf8(err).expect("utf8"); - let mut missing_out = Vec::new(); - let mut missing_err = Vec::new(); - let missing_code = run( - &[ - "balance".into(), - "--mint".into(), - "https://never-seen.example".into(), - "--home".into(), - home_arg, - ], - &mut missing_out, - &mut missing_err, + assert!( + text.contains(DEFAULT_MINIBITS_MINT_URL), + "help must name the mint a fresh config actually uses:\n{text}" ); - let missing_err = String::from_utf8(missing_err).expect("utf8"); - assert_eq!(missing_code, RUNTIME_ERROR); assert!( - missing_err.contains("queried wallet db and configured mints"), - "{missing_err}" + !text.contains("Default mint is testnut") && !text.contains("default testnut"), + "help still presents testnut as the default:\n{text}" ); + // #595 (mints-are-mints): a mint is a mint — the help must not fork classes or say "real". assert!( - !missing_err.contains("configured mints only"), - "{missing_err}" + !text.contains("REAL") && !text.contains("real sats") && !text.contains("real money"), + "help must not fork mint classes or say 'real':\n{text}" ); - assert!(missing_out.is_empty()); - let _ = std::fs::remove_dir_all(&root); } - /// #679: under `--mint ` every printed total must sum the printed rows ONLY. The fixture holds - /// sats at three mints with three DIFFERENT amounts, so the filtered totals and the whole-wallet - /// totals cannot be byte-identical — that is the whole point, and is what the older filter test - /// (one stray mint, both totals equal either way) could not see when #668 hoisted the sums above - /// the filter and made them whole-wallet. - #[cfg(feature = "wallet")] #[test] - 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); - // configured (the default) 23 + unconfigured 41 + unconfigured 17 = 81 whole-wallet. - seed_mint_balance(&root, DEFAULT_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(); + fn parse_common_accepts_amount_flag_alongside_positional() { + let (opts, positional) = + parse_common(&["--amount".into(), "21".into(), "quote-123".into()]).expect("parse"); + assert_eq!(opts.amount, Some(21)); + assert_eq!(positional, vec!["quote-123".to_owned()]); + } - let balance = |args: &[&str]| { - let mut argv: Vec = vec!["balance".into()]; - argv.extend(args.iter().map(|arg| (*arg).to_owned())); - argv.push("--home".into()); - argv.push(home_arg.clone()); - let mut out = Vec::new(); - let mut err = Vec::new(); - let code = run(&argv, &mut out, &mut err); - assert_eq!(code, SUCCESS, "stderr: {}", String::from_utf8_lossy(&err)); - String::from_utf8(out).expect("utf8") - }; + #[test] + fn parse_common_amount_flag_rejects_non_numeric() { + let err = parse_common(&["--amount".into(), "abc".into()]).expect_err("reject"); + assert!(err.contains("invalid amount")); + } - // Whole-wallet form: unchanged by this fix, and the baseline the filtered totals must differ - // from. Rows are configured-first, then discovered mints in sorted order. - let whole = balance(&[]); - assert_eq!( - whole, - format!( - "mint={DEFAULT_MINT_URL} role=default balance_sats=23 play_money=true\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\ - total_sats=81\n" - ), - "{whole}" - ); + #[test] + fn parse_common_amount_flag_requires_value() { + let err = parse_common(&["--amount".into()]).expect_err("reject"); + assert!(err.contains("--amount requires a value")); + } - // Filtered to one UNCONFIGURED mint: 41 is the only row, so 41 is the only defensible total, - // and the configured subset OF THAT ROW is 0 — not the wallet-wide 23. - let stray = balance(&["--mint", "https://stray-a.example/"]); - assert_eq!( - stray, - "mint=https://stray-a.example role=unconfigured balance_sats=41\n\ - configured_total_sats=0\n\ - total_sats=41\n", - "{stray}" - ); - assert!(!stray.contains("total_sats=81"), "{stray}"); - assert!(!stray.contains("stray-b"), "{stray}"); + #[test] + fn mint_complete_without_quote_id_is_usage_error() { + // No positional quote_id => usage error, before any wallet op runs. + let mut out = Vec::new(); + let mut err = Vec::new(); + let code = run(&["mint-complete".into()], &mut out, &mut err); + assert_eq!(code, USAGE_ERROR); + } - // 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]); - assert_eq!( - default_only, - format!( - "mint={DEFAULT_MINT_URL} role=default balance_sats=23 play_money=true\ntotal_sats=23\n" - ), - "{default_only}" - ); - assert!(!default_only.contains("total_sats=81"), "{default_only}"); + #[cfg(feature = "wallet")] + #[test] + fn parse_complete_locked_requires_the_identity_flags() { + // Missing --amount (a required identity flag) refuses at parse — before any home/gate/wallet. + let args: Vec = [ + "--job-id", "j", "--result-id", "r", "--delivery-integrity-hash", "aa", + "--job-hash", "bb", "--seller-pubkey", "cc", "--seller-signature", "dd", + ] + .iter() + .map(|s| (*s).to_owned()) + .collect(); + let err = parse_complete_locked(&args).expect_err("must refuse without --amount"); + assert!(err.contains("--amount"), "error should name the missing flag, got: {err}"); + } - let _ = std::fs::remove_dir_all(&root); + #[cfg(feature = "wallet")] + #[test] + fn parse_complete_locked_collects_repeated_accepted_mints() { + let args: Vec = [ + "--job-id", "job1", "--result-id", "res1", "--delivery-integrity-hash", "11", + "--job-hash", "22", "--seller-pubkey", "ab", "--amount", "100", + "--seller-signature", "ff", "--accepted-mint", "https://m1", "--accepted-mint", + "https://m2", "--realized-mint", "https://m1", "--home", "/tmp/x", + ] + .iter() + .map(|s| (*s).to_owned()) + .collect(); + let (home, request) = parse_complete_locked(&args).expect("parse"); + assert_eq!(home, Some(PathBuf::from("/tmp/x"))); + assert_eq!(request.job_id, "job1"); + assert_eq!(request.amount_sats, 100); + assert_eq!(request.seller_signature, "ff"); + assert_eq!(request.accepted_mints, vec!["https://m1".to_owned(), "https://m2".to_owned()]); + assert_eq!(request.realized_mint, Some("https://m1".to_owned())); } + // RED-PROVE (operator surface): a spent-token completion outcome emits a loud ALARM to stderr and + // returns the DISTINCT alarm exit code, with nothing on stdout. Pairs with the core red-prove + // (payment.rs) that the entrypoint refuses and leaves the journal unchanged. #[cfg(feature = "wallet")] #[test] - 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); + fn spent_completion_emits_alarm_and_distinct_exit_code() { + use maxplayer_core::authorize_pay::AuthorizePayError; + use maxplayer_core::payment::PaymentError; let mut out = Vec::new(); let mut err = Vec::new(); - let code = run( - &[ - "balance".into(), - "--home".into(), - root.to_string_lossy().into_owned(), - ], + let code = report_complete_locked( + Err(AuthorizePayError::Payment(PaymentError::LockedTokenSpent( + "attempt 247ad2e6: a proof reads Spent at the mint".into(), + ))), &mut out, &mut err, ); - assert_eq!(code, SUCCESS, "stderr: {}", String::from_utf8_lossy(&err)); 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" - ) + code, COMPLETE_LOCKED_SPENT_ALARM, + "spent must return the distinct alarm exit code, not the generic runtime error" ); - let _ = std::fs::remove_dir_all(&root); + let err_text = String::from_utf8(err).expect("utf8"); + assert!(err_text.starts_with("ALARM"), "must lead with a greppable ALARM, got: {err_text}"); + assert!(err_text.contains("NOT resending"), "alarm must state it is not resending"); + assert!(err_text.contains("Journal left unchanged")); + assert!(out.is_empty(), "no success output on the alarm path"); } - // #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); + fn missing_token_completion_refuses_without_the_spent_alarm() { + use maxplayer_core::authorize_pay::AuthorizePayError; + use maxplayer_core::payment::PaymentError; let mut out = Vec::new(); let mut err = Vec::new(); - let code = run( - &[ - "setup".into(), - "--home".into(), - home.to_string_lossy().into_owned(), - ], + let code = report_complete_locked( + Err(AuthorizePayError::Payment(PaymentError::LockedTokenMissing( + "no confirmed send transaction".into(), + ))), &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, "missing is a refuse, not the spent alarm"); + let err_text = String::from_utf8(err).expect("utf8"); + assert!(!err_text.starts_with("ALARM"), "missing must NOT masquerade as the accounting-gap alarm"); + assert!(err_text.contains("refusing to remint"), "got: {err_text}"); + } - 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 + #506 setup-UX fold ---- + + #[cfg(feature = "wallet")] + fn ux_test_home(label: &str) -> PathBuf { + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT: AtomicU64 = AtomicU64::new(0); + let id = NEXT.fetch_add(1, Ordering::SeqCst); + std::env::temp_dir().join(format!( + "maxplayer-setupux-{label}-{}-{id}", + std::process::id() + )) } - // #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). + /// 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. #[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(); + /// Seed a home whose configured default is an ORDINARY mint — the shape every test that is + /// about output grammar rather than the held play-money class wants. + #[cfg(feature = "wallet")] + 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![FIXTURE_MINT_URL.to_owned()]; + }) + .expect("seed the fixture mint as default on disk"); + } - 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}" - ); + /// Seed a home whose configured default is the auto-paying dev mint — the only input that makes + /// the held play-money class (`MoneyType::Play`) fire. + fn seed_testnut_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![LEGACY_TESTNUT_MINT_URL.to_owned()]; + }) + .expect("seed the auto-paying dev mint as default on disk"); + } - // 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); + /// 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")] + fn seed_mint_balance(root: &std::path::Path, mint_url: &str, amount: u64) { + use std::str::FromStr; + + use cashu::secret::Secret; + use cashu::{Amount, CurrencyUnit, Id, MintUrl, Proof, SecretKey, State}; + use cdk::cdk_database::WalletDatabase; + use cdk::wallet::types::ProofInfo; + use cdk_sqlite::wallet::WalletSqliteDatabase; + + let home = home::bootstrap(root).expect("bootstrap proof home"); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime"); + runtime.block_on(async { + let store = WalletSqliteDatabase::new(home.wallet_dir.join("cdk-wallet.sqlite")) + .await + .expect("open on-disk wallet database"); + let proof = Proof::new( + Amount::from(amount), + Id::from_str("009a1f293253e41e").expect("keyset id"), + Secret::new(format!("wallet-cli-issue-266-{amount}-{mint_url}")), + SecretKey::generate().public_key(), + ); + let info = ProofInfo::new( + proof, + MintUrl::from_str(mint_url).expect("mint URL"), + State::Unspent, + CurrencyUnit::Sat, + ) + .expect("proof info"); + store + .update_proofs(vec![info], vec![]) + .await + .expect("seed unconfigured proof"); + }); } - // #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(); + 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_mint_balance(&root, "https://stray-mint.example/", 37); let mut out = Vec::new(); let mut err = Vec::new(); let code = run( &[ - "invoice".into(), - "100".into(), + "balance".into(), "--home".into(), - home_str.clone(), + root.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_eq!(code, SUCCESS, "stderr: {}", String::from_utf8_lossy(&err)); assert!( - err.contains(DEFAULT_MINT_URL), - "refusal must name the testnut mint:\n{err}" + out.contains("mint=https://stray-mint.example role=unconfigured balance_sats=37"), + "{out}" ); assert!( - err.contains("mints add"), - "refusal must offer the other-mint remedy:\n{err}" + out.contains("configured_total_sats=0\ntotal_sats=37\n"), + "{out}" ); + let _ = std::fs::remove_dir_all(&root); + } + + #[cfg(feature = "wallet")] + #[test] + 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_mint_balance(&root, "https://stray-mint.example/", 41); + let home_arg = root.to_string_lossy().into_owned(); - // 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( + let code = run( &[ - "invoice".into(), - "100".into(), + "balance".into(), "--mint".into(), - "https://real-mint.example/".into(), + "https://stray-mint.example/".into(), "--home".into(), - home_str, + home_arg.clone(), ], &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); - } + let out = String::from_utf8(out).expect("utf8"); + assert_eq!(code, SUCCESS, "stderr: {}", String::from_utf8_lossy(&err)); + assert!(out.contains("role=unconfigured balance_sats=41"), "{out}"); - // 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() + let mut missing_out = Vec::new(); + let mut missing_err = Vec::new(); + let missing_code = run( + &[ + "balance".into(), + "--mint".into(), + "https://never-seen.example".into(), + "--home".into(), + home_arg, + ], + &mut missing_out, + &mut missing_err, ); - // explicit --mint testnut on a real-default home => affirmed play => proceed. + let missing_err = String::from_utf8(missing_err).expect("utf8"); + assert_eq!(missing_code, RUNTIME_ERROR); 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, - }, + missing_err.contains("queried wallet db and configured mints"), + "{missing_err}" ); - 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}" + !missing_err.contains("configured mints only"), + "{missing_err}" ); + assert!(missing_out.is_empty()); + let _ = std::fs::remove_dir_all(&root); } - // #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. + /// #679: under `--mint ` every printed total must sum the printed rows ONLY. The fixture holds + /// sats at three mints with three DIFFERENT amounts, so the filtered totals and the whole-wallet + /// totals cannot be byte-identical — that is the whole point, and is what the older filter test + /// (one stray mint, both totals equal either way) could not see when #668 hoisted the sums above + /// the filter and made them whole-wallet. #[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, + 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_fixture_default_home(&root); + // configured (the default) 23 + unconfigured 41 + unconfigured 17 = 81 whole-wallet. + 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(); + + let balance = |args: &[&str]| { + let mut argv: Vec = vec!["balance".into()]; + argv.extend(args.iter().map(|arg| (*arg).to_owned())); + argv.push("--home".into()); + argv.push(home_arg.clone()); + let mut out = Vec::new(); + let mut err = Vec::new(); + let code = run(&argv, &mut out, &mut err); + assert_eq!(code, SUCCESS, "stderr: {}", String::from_utf8_lossy(&err)); + String::from_utf8(out).expect("utf8") }; - 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}" + + // Whole-wallet form: unchanged by this fix, and the baseline the filtered totals must differ + // from. Rows are configured-first, then discovered mints in sorted order. + let whole = balance(&[]); + assert_eq!( + whole, + format!( + "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\ + total_sats=81\n" + ), + "{whole}" ); - let listed = mints_list_row_line(&testnut_default); - assert!( - listed.contains("role=default") && listed.contains("play_money=true"), - "{listed}" + + // Filtered to one UNCONFIGURED mint: 41 is the only row, so 41 is the only defensible total, + // and the configured subset OF THAT ROW is 0 — not the wallet-wide 23. + let stray = balance(&["--mint", "https://stray-a.example/"]); + assert_eq!( + stray, + "mint=https://stray-a.example role=unconfigured balance_sats=41\n\ + configured_total_sats=0\n\ + total_sats=41\n", + "{stray}" ); - 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}" + assert!(!stray.contains("total_sats=81"), "{stray}"); + assert!(!stray.contains("stray-b"), "{stray}"); + + // 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", FIXTURE_MINT_URL]); + assert_eq!( + default_only, + format!( + "mint={FIXTURE_MINT_URL} role=default balance_sats=23\ntotal_sats=23\n" + ), + "{default_only}" ); + assert!(!default_only.contains("total_sats=81"), "{default_only}"); + + let _ = std::fs::remove_dir_all(&root); } - // 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(); + 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_fixture_default_home(&root); let mut out = Vec::new(); let mut err = Vec::new(); - let add = run( + let code = run( &[ - "mints".into(), - "add".into(), - "https://real-mint.example/".into(), + "balance".into(), "--home".into(), - home_str.clone(), + root.to_string_lossy().into_owned(), ], &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}" + assert_eq!( + String::from_utf8(out).expect("utf8"), + format!( + "mint={FIXTURE_MINT_URL} role=default balance_sats=0\ntotal_sats=0\n" + ) ); - let _ = std::fs::remove_dir_all(&home); + let _ = std::fs::remove_dir_all(&root); } + + + + + + + // #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 // network mint round-trip, so we assert the durable side effect via `mints list` (offline) and 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..0468b2fde 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 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 @@ -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..f37fa29bd 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,7 +191,7 @@ 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 @@ -202,20 +202,11 @@ yourself to test-only mints. **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 +216,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. From 0e80e32aec8ebabd129534d9aa839f882df33aee Mon Sep 17 00:00:00 2001 From: w-ecash-mutual-credit Date: Thu, 3 Sep 2026 08:17:55 -0700 Subject: [PATCH 2/6] one mint: delete the play-money class and the legacy dev-mint constant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Removed by the owner's one-mint ruling Bob, thread messages 1544914875195723838 (2026-09-02 03:40:09Z) and 1544916134657269771 (03:45:09Z): a mint is just a mint, the seller chooses what mint to accept, and there is no "testmint" concept. This removes the #445 `refuse_silent_play_money` refusal and the `MoneyType` class, both dead once the default host is gone — the class could only ever answer `Play` for one hardcoded URL, and the refusal could only ever fire for a home whose default was that URL, telling the operator to affirm play money at a host nothing seeds any more. Gone with them: `is_autopay_mint`, the auto-complete branch of `mint_async` (every mint now returns the invoice for the caller to pay), the `play_money=true` row marker on `wallet balance` / `wallet mints list` / the setup summaries, the seven tests that covered them, and `pub const LEGACY_TESTNUT_MINT_URL` — the host it named is not dead and not special, so it gets no constant. `DEAD_TESTNUT_MINT_HOST` and its bootstrap migration are untouched: that host really is dead and the migration only ever removes it. Two consequential edits, named so the scope is legible: the two balance tests that only assert on a stray mint's row and the totals line needed *a* configured default rather than *the* auto-paying one, so each has one token changed, `seed_testnut_default_home` -> `seed_fixture_default_home`; and a stale doc line plus a duplicate `#[cfg(feature = "wallet")]` left above that fixture helper by an earlier edit go here too. --- crates/maxplayer-core/src/home.rs | 6 - crates/maxplayer-core/src/wallet_ops.rs | 49 +-- crates/maxplayer/src/wallet_cli.rs | 451 +----------------------- 3 files changed, 20 insertions(+), 486 deletions(-) diff --git a/crates/maxplayer-core/src/home.rs b/crates/maxplayer-core/src/home.rs index a02b47a88..f8cb11127 100644 --- a/crates/maxplayer-core/src/home.rs +++ b/crates/maxplayer-core/src/home.rs @@ -70,12 +70,6 @@ pub const DEFAULT_RELAY_URL: &str = "wss://relay.maxplayer.ai"; /// 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"; -/// The historical dev mint. **Never a default and never seeded**: nothing in the product writes it -/// into a config, and no code path prefers it. It survives under this name only so the two places -/// that must still recognise the host by sight can say which host they mean — the money-class -/// check in [`crate::wallet_ops`] (held pending a ruling) and this module's own migration prose. -/// If you are looking for "the default mint", it is the seat's `accepted_mints` list. -pub const LEGACY_TESTNUT_MINT_URL: &str = "https://testnut.cashudevkit.org"; /// 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"; diff --git a/crates/maxplayer-core/src/wallet_ops.rs b/crates/maxplayer-core/src/wallet_ops.rs index 904ff7136..24e733570 100644 --- a/crates/maxplayer-core/src/wallet_ops.rs +++ b/crates/maxplayer-core/src/wallet_ops.rs @@ -22,7 +22,7 @@ use cdk::Amount; use cdk_sqlite::wallet::WalletSqliteDatabase; use crate::buyer_fund::seed_from_secret_hex; -use crate::home::{self, HomeError, MaxplayerHome, LEGACY_TESTNUT_MINT_URL}; +use crate::home::{self, HomeError, MaxplayerHome}; #[derive(Debug)] pub enum WalletOpsError { @@ -336,38 +336,6 @@ async fn open_balance_store(home: &MaxplayerHome) -> Result bool { - normalize_mint_url(mint_url) - .ok() - .as_deref() - == Some(LEGACY_TESTNUT_MINT_URL) -} - -/// Money class a mint moves, derived purely from the mint URL. The historical dev mint -/// ([`LEGACY_TESTNUT_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 historical dev 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 - } - } -} - /// Balance per configured or wallet-database-discovered mint. The sqlite store is shared across /// every mint, and proofs legally land at mints outside the configured set (seller redemption at /// `accepted_mints[1..]`, cross-mint hop residue) — so the read enumerates the DB truth (proof @@ -584,24 +552,19 @@ pub async fn complete_mint_by_id_async( /// Flexible/repeatable mint-fund (no `already_funded` hard-block). /// -/// A mint that FakeWallet-auto-pays its own invoices ([`LEGACY_TESTNUT_MINT_URL`]) goes begin → -/// complete; every other mint returns [`MintFlow::NeedsPayment`] with the 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; an auto-paying mint mints once it settles. Other 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, diff --git a/crates/maxplayer/src/wallet_cli.rs b/crates/maxplayer/src/wallet_cli.rs index 682f16343..f3a7f0e93 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, LEGACY_TESTNUT_MINT_URL}; +use maxplayer_core::home::{self, MaxplayerHome, DEFAULT_MINIBITS_MINT_URL}; #[cfg(feature = "wallet")] use maxplayer_core::wallet_ops; @@ -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 auto-paying dev 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). @@ -329,75 +322,39 @@ 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 auto-paying dev -/// 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 \ - ({LEGACY_TESTNUT_MINT_URL}), which silently self-funds fake sats. To fund play money, affirm it \ - explicitly with `--mint {LEGACY_TESTNUT_MINT_URL}`; for real money, `maxplayer wallet mints add \ - ` (or set a real default). Nothing was funded." - )) -} - -/// A loud marker for play (auto-paying dev) money, empty for every ordinary mint. That 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 auto-paying dev mint 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 that 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 that -/// 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,12 +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): the same silent-play refusal as `setup` — same predicate, same - // placement; a money act is affirmed with `--mint`, never defaulted from invisible home state. - 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!( @@ -962,12 +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): the same silent-play refusal as `setup` — same predicate, same - // placement; a money act is affirmed with `--mint`, never defaulted from invisible home state. - 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!( @@ -1079,366 +1022,12 @@ fn cmd_mints(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 { #[cfg(test)] mod tests { - // #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(LEGACY_TESTNUT_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(LEGACY_TESTNUT_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(LEGACY_TESTNUT_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() { - // auto-paying dev mint default + no --mint => the silent-play surprise => refuse. - assert!(refuse_silent_play_money(None, LEGACY_TESTNUT_MINT_URL).is_some()); - // auto-paying dev mint default + explicit --mint testnut => affirmed => proceed. - assert!(refuse_silent_play_money(Some(LEGACY_TESTNUT_MINT_URL), LEGACY_TESTNUT_MINT_URL).is_none()); - // 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"), LEGACY_TESTNUT_MINT_URL).is_none() - ); - // explicit --mint testnut on a real-default home => affirmed play => proceed. - assert!( - refuse_silent_play_money(Some(LEGACY_TESTNUT_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: LEGACY_TESTNUT_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={LEGACY_TESTNUT_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: LEGACY_TESTNUT_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={LEGACY_TESTNUT_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); - } /// 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"; @@ -1597,9 +1186,6 @@ 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. - #[cfg(feature = "wallet")] /// Seed a home whose configured default is an ORDINARY mint — the shape every test that is /// about output grammar rather than the held play-money class wants. #[cfg(feature = "wallet")] @@ -1611,15 +1197,6 @@ mod tests { .expect("seed the fixture mint as default on disk"); } - /// Seed a home whose configured default is the auto-paying dev mint — the only input that makes - /// the held play-money class (`MoneyType::Play`) fire. - fn seed_testnut_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![LEGACY_TESTNUT_MINT_URL.to_owned()]; - }) - .expect("seed the auto-paying dev 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. @@ -1667,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(); @@ -1699,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(); From 4673cfac84ebf5f46614b8b72706f926d584fb1c Mon Sep 17 00:00:00 2001 From: w-ecash-mutual-credit Date: Thu, 3 Sep 2026 10:11:49 -0700 Subject: [PATCH 3/6] one mint: parse the mint URL properly, and finish retiring the concept in prose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Advisor DENY of 0e80e32 on checks D and F; appended, not rewritten. D — `mint_url_supported` called "host" everything after the scheme and only checked that tail was non-empty, so `http:///x`, `http://:8080` and `http:// ` were all waved through. It now splits the authority at the first `/`, `?` or `#`, drops any `user@` prefix and any numeric `:port`, and requires a non-empty host with no whitespace. `http://` STAYS ADMITTED — a seat's own sidecar mint runs on loopback, and the doc comment saying so is unchanged. New test `mint_url_supported_requires_a_scheme_and_a_real_host` names all twelve malformed cases plus the admitted forms and the non-http schemes. F — fourteen survivor lines still taught the retired real-mint / play-money concept, in prose the G1-G4 census never searched for: - `web/app/.well-known/skills/debug-buying/skill.md` — SERVED to operators, said the hop happens "if real mints are allowed" and that a mismatch means being pinned to "test-only mints". Both are gone; the paragraph now says a mismatch blocks only when there is no route. - doc comments in `authorize_pay.rs` (x3), `buyer_fund.rs`, `buyer/lifecycle.rs`, `job_lifecycle.rs` (x2) that named a fence which no longer exists, and one in `wallet_cli.rs` naming the "held play-money class" that the previous commit deleted. - `scripts/crossmint-smoke.sh`: `arm_real_mints` is renamed `arm_mint_lists`, for what it actually does — export the accepted and extra mint lists and the budget cap. All three call sites updated. The parked testnut dry-run lines in the smoke script are untouched. --- crates/maxplayer-core/src/authorize_pay.rs | 6 +- crates/maxplayer-core/src/buyer/lifecycle.rs | 2 +- crates/maxplayer-core/src/buyer_fund.rs | 1 - crates/maxplayer-core/src/home.rs | 68 ++++++++++++++++++- crates/maxplayer-core/src/job_lifecycle.rs | 4 +- crates/maxplayer/src/wallet_cli.rs | 2 +- scripts/crossmint-smoke.sh | 8 +-- .../.well-known/skills/debug-buying/skill.md | 7 +- 8 files changed, 79 insertions(+), 19 deletions(-) diff --git a/crates/maxplayer-core/src/authorize_pay.rs b/crates/maxplayer-core/src/authorize_pay.rs index 1aa7e986c..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. @@ -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)] diff --git a/crates/maxplayer-core/src/buyer/lifecycle.rs b/crates/maxplayer-core/src/buyer/lifecycle.rs index 1b085779c..86cdb1ead 100644 --- a/crates/maxplayer-core/src/buyer/lifecycle.rs +++ b/crates/maxplayer-core/src/buyer/lifecycle.rs @@ -78,7 +78,7 @@ 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, diff --git a/crates/maxplayer-core/src/buyer_fund.rs b/crates/maxplayer-core/src/buyer_fund.rs index 3671b2987..81ce5bfa0 100644 --- a/crates/maxplayer-core/src/buyer_fund.rs +++ b/crates/maxplayer-core/src/buyer_fund.rs @@ -22,7 +22,6 @@ 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 /// 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. diff --git a/crates/maxplayer-core/src/home.rs b/crates/maxplayer-core/src/home.rs index f8cb11127..addef7789 100644 --- a/crates/maxplayer-core/src/home.rs +++ b/crates/maxplayer-core/src/home.rs @@ -1465,10 +1465,28 @@ fn default_per_job_budget_sats() -> u64 { /// `http://` is admitted deliberately: a seat's own sidecar mint runs on loopback, and an /// https-only shape rule would make the operator's own list unusable. pub fn mint_url_supported(mint_url: &str) -> bool { - let host = mint_url + // Split the authority off properly rather than calling "host" everything after the scheme: the + // authority ends at the first `/`, `?` or `#`, and a userinfo prefix (`user@host`) is not the + // host. Then require a non-empty HOST — not merely a non-empty tail — so `http:///path`, + // `http://:8080` and `http:// ` are refused instead of waved through. + let Some(rest) = mint_url .strip_prefix("https://") - .or_else(|| mint_url.strip_prefix("http://")); - host.is_some_and(|host| !host.is_empty()) + .or_else(|| mint_url.strip_prefix("http://")) + else { + return false; + }; + let authority = rest.split(['/', '?', '#']).next().unwrap_or(""); + // `user@host` and `host:port` both keep the host in the middle; an empty host on either side of + // those separators is the malformed case this predicate exists to catch. + let after_userinfo = authority.rsplit('@').next().unwrap_or(""); + let host = match after_userinfo.rsplit_once(':') { + // A trailing `:port` is only a port when what follows it is digits; otherwise the colon + // belongs to the host text (an IPv6 literal keeps its brackets and is handled below). + Some((head, port)) if !port.is_empty() && port.chars().all(|c| c.is_ascii_digit()) => head, + Some((head, "")) => head, + _ => after_userinfo, + }; + !host.is_empty() && !host.chars().any(|c| c.is_whitespace()) } impl MaxplayerConfig { @@ -2621,6 +2639,50 @@ mod tests { } #[test] + /// 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 the old + /// tail-check waved through is named here. + #[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", + ] { + 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://"), + ]; + 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:?}"); + } + } + 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 — diff --git a/crates/maxplayer-core/src/job_lifecycle.rs b/crates/maxplayer-core/src/job_lifecycle.rs index 7df7d2488..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 diff --git a/crates/maxplayer/src/wallet_cli.rs b/crates/maxplayer/src/wallet_cli.rs index f3a7f0e93..1f453e61b 100644 --- a/crates/maxplayer/src/wallet_cli.rs +++ b/crates/maxplayer/src/wallet_cli.rs @@ -1187,7 +1187,7 @@ mod tests { } /// Seed a home whose configured default is an ORDINARY mint — the shape every test that is - /// about output grammar rather than the held play-money class wants. + /// about output grammar wants. #[cfg(feature = "wallet")] fn seed_fixture_default_home(root: &std::path::Path) { let mut home = home::bootstrap(root).expect("bootstrap seed home"); diff --git a/scripts/crossmint-smoke.sh b/scripts/crossmint-smoke.sh index 0468b2fde..2e0bee667 100755 --- a/scripts/crossmint-smoke.sh +++ b/scripts/crossmint-smoke.sh @@ -232,7 +232,7 @@ agent and never by a retry." # VAR=x maxplayer_at ...` cannot work — env execs a binary and would never see the function. Exporting # 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() { +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 @@ -247,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" @@ -274,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" @@ -294,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" diff --git a/web/app/.well-known/skills/debug-buying/skill.md b/web/app/.well-known/skills/debug-buying/skill.md index f37fa29bd..2d23937fd 100644 --- a/web/app/.well-known/skills/debug-buying/skill.md +++ b/web/app/.well-known/skills/debug-buying/skill.md @@ -194,10 +194,9 @@ the seller's advertised `accepted_mints`. ## 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 From f719f1e251ac9e16da634e9abb31b9d5b57d203a Mon Sep 17 00:00:00 2001 From: w-ecash-mutual-credit Date: Thu, 3 Sep 2026 10:29:59 -0700 Subject: [PATCH 4/6] one mint: drop the dead real-mint-fence disjunct from the MCP prereq hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Advisor's ninth F line (maxie's ruling, 2026-09-03 17:08Z), appended. `with_prereq_hint` matched `real-mint fence` to decide that a tool failure was a funding prerequisite. Both producers of that string — crossmint.rs's two fence refusals — went with the fence itself, so at this head the phrase had exactly one hit tree-wide: this disjunct. It was an unreachable branch asserting in product code that the retired class still gates a payment. Deleted; the `;` moves onto the `mint_unreachable` disjunct. The other three disjuncts are untouched. No test referenced the phrase — the funds-prereq test drives the "no balance at any accepted mint" disjunct — and the tree-wide count for `real-mint fence` is now 0. --- crates/maxplayer/src/mcp.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/maxplayer/src/mcp.rs b/crates/maxplayer/src/mcp.rs index 1b1d19f88..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 \ From 5e7c68e6d1ebff71f71a6bc3a568a1713c65589e Mon Sep 17 00:00:00 2001 From: w-ecash-mutual-credit Date: Thu, 3 Sep 2026 13:12:39 -0700 Subject: [PATCH 5/6] one mint: register the shipped-defaults test that lost its attribute Advisor check H. `home.rs` carried two `#[test]` attributes stacked on `mint_url_supported_requires_a_scheme_and_a_real_host` and none on `shipped_defaults_are_real_money_and_usable`, so that test was compiled but never run. The defect entered with the D commit `4673cfa`. The net `#[test]` count concealed it: 50 -> 51 across that commit reads as "one test added", but it was +2 (double registration) and -1 (lost registration). Removing the duplicate and restoring the missing attribute leaves the count at 51 and every suite total unchanged, which is exactly why the count is not the instrument. The compiler had already said so: `duplicate_macro_attributes`, and "function `shipped_defaults_are_real_money_and_usable` is never used". Exactly one attribute on each function; nothing else in this commit. --- crates/maxplayer-core/src/home.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/maxplayer-core/src/home.rs b/crates/maxplayer-core/src/home.rs index addef7789..5b89e8bdf 100644 --- a/crates/maxplayer-core/src/home.rs +++ b/crates/maxplayer-core/src/home.rs @@ -2215,6 +2215,7 @@ fn write_new_key(path: &Path) -> Result<(), HomeError> { #[cfg(test)] mod tests { + use super::*; use std::sync::atomic::{AtomicU64, Ordering}; @@ -2638,7 +2639,6 @@ mod tests { ); } - #[test] /// 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 the old /// tail-check waved through is named here. @@ -2683,6 +2683,7 @@ mod tests { } } + #[test] 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 — From 14e149b3c473ad62e0761bbf23132b240f854ca6 Mon Sep 17 00:00:00 2001 From: w-ecash-mutual-credit Date: Thu, 3 Sep 2026 13:37:15 -0700 Subject: [PATCH 6/6] one mint: make the mint-URL host validator correct, by hand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Advisor check D, on the route addendum 13 corrects. The predicate still hand-split the authority and called a non-numeric port part of the host, so `https://mint.example:abc` and `https://[::1` both returned true. The remedy first ordered — `url::Url::parse` — is withdrawn, on two grounds maxie names: `url` is a DEV-dependency of this crate, so product code cannot reach it without changing a money-adjacent crate's shipped dependency set; and the parser would break the contract it was ordered to enforce, because `Url::parse("http:///x")` yields the host `x` (WHATWG special schemes skip repeated slashes) and would ADMIT a case this predicate refuses today. No manifest edit is in this commit. So the validator stays hand-rolled and becomes correct, in the six steps of addendum 13 §2: scheme, authority to the first `/`, `?` or `#`, host after the last `@`, a bracketed IPv6 literal that must close and may carry only an all-digit port, an unbracketed host with no brackets and at most one `:` before an all-digit port, and finally a non-empty host with no whitespace. Two new admissions with a product reason: a seat's own sidecar mint may sit on IPv6 loopback, so `http://[::1]:3338` and `https://[::1]` are admitted. `http://` stays admitted. The 7 previously admitted cases are all still admitted and all 17 previously refused are all still refused. Also drops the gratuitous blank line the H commit added after `mod tests {`, per addendum 13 §4. --- crates/maxplayer-core/src/home.rs | 73 +++++++++++++++++++++++-------- 1 file changed, 55 insertions(+), 18 deletions(-) diff --git a/crates/maxplayer-core/src/home.rs b/crates/maxplayer-core/src/home.rs index 5b89e8bdf..017fee8cf 100644 --- a/crates/maxplayer-core/src/home.rs +++ b/crates/maxplayer-core/src/home.rs @@ -1462,30 +1462,61 @@ fn default_per_job_budget_sats() -> u64 { /// 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, and an -/// https-only shape rule would make the operator's own list unusable. +/// `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. +/// +/// 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 { - // Split the authority off properly rather than calling "host" everything after the scheme: the - // authority ends at the first `/`, `?` or `#`, and a userinfo prefix (`user@host`) is not the - // host. Then require a non-empty HOST — not merely a non-empty tail — so `http:///path`, - // `http://:8080` and `http:// ` are refused instead of waved through. + // 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(""); - // `user@host` and `host:port` both keep the host in the middle; an empty host on either side of - // those separators is the malformed case this predicate exists to catch. - let after_userinfo = authority.rsplit('@').next().unwrap_or(""); - let host = match after_userinfo.rsplit_once(':') { - // A trailing `:port` is only a port when what follows it is digits; otherwise the colon - // belongs to the host text (an IPv6 literal keeps its brackets and is handled below). - Some((head, port)) if !port.is_empty() && port.chars().all(|c| c.is_ascii_digit()) => head, - Some((head, "")) => head, - _ => after_userinfo, + // 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 { + // 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()) } @@ -2215,7 +2246,6 @@ fn write_new_key(path: &Path) -> Result<(), HomeError> { #[cfg(test)] mod tests { - use super::*; use std::sync::atomic::{AtomicU64, Ordering}; @@ -2640,8 +2670,9 @@ 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 the old - /// tail-check waved through is named here. + /// 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 @@ -2654,6 +2685,9 @@ mod tests { "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}"); } @@ -2672,6 +2706,9 @@ mod tests { ("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:?}");