From 7547e5aedbfe6103c8b30b3b614ff9f380143d4a Mon Sep 17 00:00:00 2001 From: w-ecash-mutual-credit Date: Wed, 2 Sep 2026 15:58:28 -0700 Subject: [PATCH 1/6] protocol: optional `issuer_mint` tag on the kind-30340 seat announcement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 1 of the ecash mutual-credit build. One new OPTIONAL tag on the seat announcement, `["issuer_mint", url, cap, outstanding, retired, last_seen]`, carrying the seat's own mint URL and its issuance counters. Emitted from `HeartbeatDraft` (`with_issuer_mint`), parsed into `ParsedHeartbeat.issuer_mint`. Absent or malformed reads as unstated, never a rejection. Announcement only, never on the kind-3402 claim (protocol-v1 §4.2 "Issuer mint"). No version bump: a reader MUST ignore unrecognised tags (§2.1). No production publish path sets it yet; stage 3 wires live counters. Co-Authored-By: Claude Fable 5.1 --- crates/maxplayer-core/src/heartbeat.rs | 317 ++++++++++++++++++++++++- docs/protocol-v1.md | 37 +++ 2 files changed, 351 insertions(+), 3 deletions(-) diff --git a/crates/maxplayer-core/src/heartbeat.rs b/crates/maxplayer-core/src/heartbeat.rs index 932094033..d69ff1c8a 100644 --- a/crates/maxplayer-core/src/heartbeat.rs +++ b/crates/maxplayer-core/src/heartbeat.rs @@ -280,6 +280,90 @@ pub const ADMITS_POOL_TAG: &str = "admits_pool"; /// nothing about its list. pub const ADMITS_TARGETED_TAG: &str = "admits_targeted"; +/// `["issuer_mint", url, cap, outstanding, retired, last_seen]` — the seat runs its OWN Cashu mint +/// and issues tokens that are an IOU for its own future work (§4.2 "Issuer mint"). The unit stays +/// `sat`: one token is one sat of the issuer's work at its published `rate`, and the mint URL is the +/// sole thing that distinguishes this currency from any other `sat` on the wire. +/// +/// ONE tag, FIVE positional values, all required when the tag is present — see [`IssuerMintAd`] for +/// what each one means. Absent means the seat states no issuer mint. Malformed reads as UNSTATED, +/// never as a rejection: an optional tag must not be able to take a working seat off the market. +/// +/// BEAT ONLY — never on a kind-3402 claim. It is not an award filter: a buyer pays on the mint the +/// claim's `creq` names, and whether to accept an issuer's currency at all is a decision the +/// operator of the OTHER seat makes by hand, by adding this URL to its own `accepted_mints`, +/// before any offer is sent. Nothing in the protocol derives that decision from these values. +pub const ISSUER_MINT_TAG: &str = "issuer_mint"; + +/// The seat's issuer-mint advertisement (§4.2 "Issuer mint"), read off or written to +/// [`ISSUER_MINT_TAG`]. +/// +/// The counters are the ISSUER's own statement. The seat's signature on the beat covers them, so a +/// reader knows WHO said them — not that they are true. They exist for the operator of another seat +/// to read before extending credit; no code path acts on them automatically. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct IssuerMintAd { + /// The seat's own mint URL. MUST also appear in `accepted_mints`: a seat announcing a currency + /// it will not itself take back is not announcing an issuer mint, and the reader treats it so. + pub mint_url: String, + /// Ceiling on tokens OUTSTANDING the issuer enforces on its own minting, in sats. + pub cap_sats: u64, + /// Minted minus retired, in sats, as of `last_seen`. + pub outstanding_sats: u64, + /// Tokens the issuer has taken back and burned, in sats, as of `last_seen`. + pub retired_sats: u64, + /// Unix seconds at which the counters were read from the mint. + pub last_seen: u64, +} + +impl IssuerMintAd { + /// The wire tag: `["issuer_mint", url, cap, outstanding, retired, last_seen]`, counters as + /// plain decimal digit strings. + pub fn to_tag(&self) -> TagSpec { + TagSpec(vec![ + ISSUER_MINT_TAG.to_owned(), + self.mint_url.clone(), + self.cap_sats.to_string(), + self.outstanding_sats.to_string(), + self.retired_sats.to_string(), + self.last_seen.to_string(), + ]) + } + + /// Read the issuer-mint advertisement off a seat announcement's tags. `None` ⇒ the seat STATED + /// NOTHING. + /// + /// Absent is unstated. So is every malformed shape — wrong arity, a counter that is not plain + /// decimal digits, an empty URL, or a URL the seat does not list in `accepted_mints`. None of + /// those is an error: this tag is optional, and a reader that dropped a payable seat over an + /// optional tag it could not read would be stricter than §2.1 lets it be. The same rule + /// [`admission_from_tags`] applies to a half-stated policy. + pub fn from_tags(tags: &[TagSpec], accepted_mints: &[String]) -> Option { + let tag = first_tag(tags, ISSUER_MINT_TAG)?; + let [_, url, cap, outstanding, retired, last_seen] = tag.0.as_slice() else { + return None; + }; + if url.is_empty() || !accepted_mints.contains(url) { + return None; + } + Some(Self { + mint_url: url.clone(), + cap_sats: decimal_sats(cap)?, + outstanding_sats: decimal_sats(outstanding)?, + retired_sats: decimal_sats(retired)?, + last_seen: decimal_sats(last_seen)?, + }) + } +} + +/// A counter on the wire is plain ASCII decimal digits and nothing else. `str::parse::` also +/// takes a leading `+`, which would put two spellings of one number on the wire; this pins one. +fn decimal_sats(raw: &str) -> Option { + if raw.is_empty() || !raw.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + raw.parse().ok() +} /// Wire tag carrying operator colour about the machine (#784) — e.g. "mac studio, 64GB". Free text, /// single value. @@ -473,6 +557,10 @@ pub struct HeartbeatDraft { /// caller that has no [`crate::home::SellerConfig`] in hand emits the tag set it always did; /// the production publish paths always have one, so a real seat always answers. pub admission: Option, + /// The seat's issuer-mint advertisement (§4.2 "Issuer mint"), or `None` to state none. `None` + /// is the default and emits no tag: a seat that runs no mint of its own publishes exactly the + /// tag set it always did. + pub issuer_mint: Option, } impl HeartbeatDraft { @@ -490,6 +578,7 @@ impl HeartbeatDraft { agents: Vec::new(), capability: SeatCapability::default(), admission: None, + issuer_mint: None, } } @@ -511,9 +600,17 @@ impl HeartbeatDraft { self } + /// Advertise the seat's own issuer mint and its issuance counters on this heartbeat (§4.2 + /// "Issuer mint"). + pub fn with_issuer_mint(mut self, issuer_mint: IssuerMintAd) -> Self { + self.issuer_mint = Some(issuer_mint); + self + } + /// The §4.2 tag set, in the order the spec table lists it: `d`, `t`, `v`, `rate`, `accepting`, - /// `queue_depth`, `accepted_mints`, and `agents` when the seat states a roster — followed by the - /// #784 capability tags, filterable first then display-only. + /// `queue_depth`, `accepted_mints`, `agents` when the seat states a roster, the admission tags + /// when it states a policy, `issuer_mint` when it runs one — followed by the #784 capability + /// tags, filterable first then display-only. /// /// The beat emits BOTH capability sets; a claim emits only the filterable one. Both take them /// from [`SeatCapability`], so the two events cannot spell a shared field differently. @@ -537,6 +634,9 @@ impl HeartbeatDraft { if let Some(admission) = self.admission.as_ref() { tags.extend(admission_tags(admission)); } + if let Some(issuer_mint) = self.issuer_mint.as_ref() { + tags.push(issuer_mint.to_tag()); + } tags.extend(self.capability.filterable_tags()); tags.extend(self.capability.display_tags()); EventDraft::new(SELLER_HEARTBEAT_KIND, tags, "") @@ -925,6 +1025,10 @@ pub struct ParsedHeartbeat { /// ⛔ `None` is UNKNOWN, never a refusal. A seat that predates this tag publishes neither half, /// and a reader that treated that as "admits nobody" would drop every seat running today. pub admission: Option, + /// The seat's issuer-mint advertisement (§4.2 "Issuer mint"), or `None` when the seat stated + /// none — absent OR unreadable. Never a rejection: the tag is optional, and the counters are the + /// issuer's own unverified statement for an operator to read, not a value any code acts on. + pub issuer_mint: Option, /// The seat's #784 capability advertisement, read back off the same tags the beat emitted. /// Every field defaults to unstated, so a beat from a seat that predates #784 parses to a /// [`SeatCapability::default`] rather than failing — that is what lets emitters and readers ship @@ -1057,10 +1161,11 @@ pub fn parse_heartbeat(event: &EventDraft) -> Result IssuerMintAd { + IssuerMintAd { + mint_url: mints()[0].clone(), + cap_sats: 100_000, + outstanding_sats: 2_500, + retired_sats: 750, + last_seen: 1_788_390_000, + } + } + + /// The wire shape, pinned by hand in both directions: one tag, five positional values, counters + /// as plain decimal digits. + #[test] + fn issuer_mint_wire_shape_round_trips() { + let event = draft(true, 0, 5) + .with_issuer_mint(issuer()) + .to_event_draft(); + let tag = first_tag(&event.tags, ISSUER_MINT_TAG).expect("issuer_mint tag"); + assert_eq!( + tag.0, + vec![ + "issuer_mint", + "https://testnut.example/Bitcoin", + "100000", + "2500", + "750", + "1788390000", + ] + ); + let parsed = parse_heartbeat(&event).expect("round-trip"); + assert_eq!(parsed.issuer_mint, Some(issuer())); + assert_eq!( + IssuerMintAd::from_tags(&event.tags, &mints()), + Some(issuer()) + ); + + // The URL may sit anywhere in `accepted_mints`, not only first: a seat that lists a shared + // mint ahead of its own currency still states an issuer mint. + let two = vec![ + "https://shared.example/Bitcoin".to_owned(), + mints()[0].clone(), + ]; + let second = HeartbeatDraft::new(true, 0, 5, two) + .with_issuer_mint(issuer()) + .to_event_draft(); + assert_eq!( + parse_heartbeat(&second).expect("parses").issuer_mint, + Some(issuer()) + ); + } + + /// Absent is UNSTATED, and a seat stating none emits nothing new — the tag set it always did. + #[test] + fn an_absent_issuer_mint_tag_is_unstated_and_adds_nothing() { + let event = draft(true, 0, 5).to_event_draft(); + assert!( + first_tag(&event.tags, ISSUER_MINT_TAG).is_none(), + "a draft that states no issuer mint must emit no tag" + ); + let parsed = parse_heartbeat(&event).expect("a beat with no issuer_mint tag still parses"); + assert_eq!(parsed.issuer_mint, None); + assert_eq!(IssuerMintAd::from_tags(&event.tags, &mints()), None); + } + + /// Stating an issuer mint adds exactly ONE tag, leaves every other tag byte-identical, and + /// changes nothing a reader that predates the tag can see. That last clause is the "old reader + /// ignores it" proof: an old reader is this reader minus the field, and §2.1 has it skip a tag + /// it does not recognise — so its whole view is `parsed_with` with the new field blanked, which + /// must equal what it reads off a beat that never carried the tag. + #[test] + fn issuer_mint_is_additive_beat_only_and_invisible_to_an_old_reader() { + let without = draft(true, 2, 5) + .with_agents(vec!["claude".into()]) + .with_admission(TEST_POLICY) + .to_event_draft(); + let with = draft(true, 2, 5) + .with_agents(vec!["claude".into()]) + .with_admission(TEST_POLICY) + .with_issuer_mint(issuer()) + .to_event_draft(); + + let before = tag_names(&without); + let added: Vec<&str> = tag_names(&with) + .into_iter() + .filter(|name| !before.contains(name)) + .collect(); + assert_eq!( + added, + vec![ISSUER_MINT_TAG], + "stating an issuer mint must add exactly one tag and nothing else" + ); + + // Every tag an old reader knows is byte-identical, in the same order. + let known: Vec<&Vec> = with + .tags + .iter() + .filter(|tag| tag.first() != Some(ISSUER_MINT_TAG)) + .map(|tag| &tag.0) + .collect(); + let original: Vec<&Vec> = without.tags.iter().map(|tag| &tag.0).collect(); + assert_eq!(known, original); + + // The old reader's view: identical in every field it has. + let mut parsed_with = parse_heartbeat(&with).expect("parses with the tag"); + let parsed_without = parse_heartbeat(&without).expect("parses without the tag"); + assert_eq!(parsed_with.issuer_mint, Some(issuer())); + parsed_with.issuer_mint = None; + assert_eq!( + parsed_with, parsed_without, + "a reader that ignores issuer_mint must see exactly the beat it always saw" + ); + + // And the converse §2.1 property on THIS reader: a tag it does not know, sitting beside + // the one it does, changes nothing. + let mut with_stranger = with.clone(); + with_stranger + .tags + .push(TagSpec::new(["issuer_mint_v2", "https://x.example", "1"])); + let parsed_stranger = parse_heartbeat(&with_stranger).expect("an unknown tag is ignored"); + assert_eq!(parsed_stranger, parse_heartbeat(&with).expect("parses")); + + // Beat only: the claim carries `SeatCapability::filterable_tags` and nothing else from + // this module, so there is no path by which the tag reaches a kind-3402 claim. + assert!( + SeatCapability::default() + .filterable_tags() + .iter() + .all(|tag| tag.first() != Some(ISSUER_MINT_TAG)), + "issuer_mint must never be a filterable claim tag" + ); + } + + /// Every malformed shape is UNSTATED — never a rejection of the beat, never a guessed value. + #[test] + fn a_malformed_issuer_mint_tag_is_unstated_never_a_rejection() { + let good = issuer().to_tag().0; + let mut cases: Vec<(String, Vec)> = Vec::new(); + + let mut short = good.clone(); + short.pop(); + cases.push(("four values".to_owned(), short)); + + let mut long = good.clone(); + long.push("extra".to_owned()); + cases.push(("six values".to_owned(), long)); + + cases.push(("bare tag".to_owned(), vec![ISSUER_MINT_TAG.to_owned()])); + + let mut empty_url = good.clone(); + empty_url[1] = String::new(); + cases.push(("empty url".to_owned(), empty_url)); + + let mut unlisted = good.clone(); + unlisted[1] = "https://elsewhere.example/Bitcoin".to_owned(); + cases.push(("url not in accepted_mints".to_owned(), unlisted)); + + for (index, field) in [ + (2, "cap"), + (3, "outstanding"), + (4, "retired"), + (5, "last_seen"), + ] { + for bad in [ + "", + "-1", + "+5", + "1.5", + "1e3", + "abc", + " 5", + "5 ", + "99999999999999999999", + ] { + let mut case = good.clone(); + case[index] = bad.to_owned(); + cases.push((format!("{field}={bad:?}"), case)); + } + } + + for (label, tag) in cases { + let mut event = draft(true, 0, 5).to_event_draft(); + event.tags.push(TagSpec(tag.clone())); + let parsed = parse_heartbeat(&event).unwrap_or_else(|err| { + panic!("{label}: an optional tag must never reject the beat, got {err}") + }); + assert_eq!( + parsed.issuer_mint, None, + "{label}: {tag:?} must read as unstated, not as a value this reader invented" + ); + // The rest of the beat is untouched by the bad tag. + assert_eq!(parsed.accepted_mints, mints()); + assert_eq!(parsed.rate_sats, 5); + } + + // The well-formed tag, for contrast, on the same fixture. + let mut event = draft(true, 0, 5).to_event_draft(); + event.tags.push(TagSpec(good)); + assert_eq!( + parse_heartbeat(&event).expect("parses").issuer_mint, + Some(issuer()) + ); + } + #[test] fn heartbeat_addressable() { // Kind is in NIP-01's addressable range so the relay replaces it in place by (pubkey, d). diff --git a/docs/protocol-v1.md b/docs/protocol-v1.md index 10b8c5e82..d50479638 100644 --- a/docs/protocol-v1.md +++ b/docs/protocol-v1.md @@ -102,6 +102,7 @@ replaces it on every beat. Every fact below is current as of that beat, EXCEPT ` | `["agents", id, ...]` | 0..1 | no | Harnesses the seat can run | | `["admits_pool", "open"` or `"closed"]` | 0..1 | no | Whether the seat claims untargeted (open-pool) offers | | `["admits_targeted", "open"`, `"named"` or `"closed"]` | 0..1 | no | Who the seat admits on the targeted surface | +| `["issuer_mint", url, cap, outstanding, retired, last_seen]` | 0..1 | no | The seat's own mint and its issuance counters | | `["harness_family", family, ...]` | 0..1 | no | Harness families the seat serves | | `["harness_model", family, model]` | 0..N | no | One resolved model, paired to its family | | `["capabilities", token, ...]` | 0..1 | no | Capability tokens the seat proved | @@ -173,6 +174,42 @@ sets above, states no policy — a reader MUST NOT infer the missing or unrecogn These tags appear on the announcement ONLY. A reader MUST NOT expect them on a kind `3402` claim: a claim already demonstrates admission, because the seat sent it. +#### Issuer mint + +`issuer_mint` states that the seat runs its OWN Cashu mint and issues tokens that are an IOU for its +own future work. It pays other seats with them. Whoever holds them may later hire this seat and pay +with them, and the seat retires what comes back. No outside money enters or leaves such a mint. + +The unit stays `sat`. One token is one sat of this seat's work at its published `rate`. The mint URL +is the sole thing that distinguishes this currency from any other `sat` on the wire. + +It is one tag with five positional values, every one required when the tag is present: + +| Position | Value | Meaning | +|---|---|---| +| 1 | `url` | The seat's own mint. MUST also appear in the seat's `accepted_mints` | +| 2 | `cap` | Ceiling on tokens outstanding that the issuer enforces on its own minting, in sats | +| 3 | `outstanding` | Minted minus retired, in sats, as of `last_seen` | +| 4 | `retired` | Tokens the issuer has taken back and burned, in sats, as of `last_seen` | +| 5 | `last_seen` | Unix seconds at which the counters were read from the mint | + +`cap`, `outstanding`, `retired` and `last_seen` are strings of decimal digits and nothing else. + +The counters are the issuer's own statement. The seat's signature on the announcement covers them, so +a reader knows WHO said them, not that they are true. A reader MUST NOT treat them as verified and +MUST NOT extend credit on them automatically. Accepting an issuer's currency is a manual act: the +operator of another seat reads this tag and, if it chooses, adds the URL to its own `accepted_mints`. +Nothing in this protocol derives that decision. + +An absent tag means the seat states no issuer mint. A tag with the wrong number of values, a counter +that is not decimal digits, an empty URL, or a URL that is not in the seat's `accepted_mints` states +no issuer mint either: a reader MUST read it as unstated and MUST NOT reject the announcement over +it. A reader that predates this tag ignores it under §2.1 and behaves as it did before. + +This tag appears on the announcement ONLY. A reader MUST NOT expect it on a kind `3402` claim. It is +not an award filter: a buyer pays on a mint the claim's `creq` names, and whether to accept an +issuer's mint at all was decided by an operator before any offer was sent. + `queue_depth` is a live count. It returns to `0` when the seat holds no non-terminal job. `accepting` is the seat's own statement of intent. A reader MUST NOT treat it as a guarantee. The From 2446900bc8b37cdf59b96e5bf70c32a4cd708b5d Mon Sep 17 00:00:00 2001 From: w-ecash-mutual-credit Date: Wed, 2 Sep 2026 17:42:20 -0700 Subject: [PATCH 2/6] =?UTF-8?q?protocol:=20drop=20`cap`=20from=20the=20`is?= =?UTF-8?q?suer=5Fmint`=20tag=20=E2=80=94=20there=20is=20no=20cap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner decision (Bob, 2 Sep, "lets keep it simple for now - no wrapper, no limit"): nothing enforces a ceiling on an issuer's outstanding tokens, so the tag no longer declares one. The tag is positional and shrinks to four values: `["issuer_mint", url, outstanding, retired, last_seen]`. Both index tables (the doc's position table and the malformed-shape test's field table) are renumbered: outstanding 3->2, retired 4->3, last_seen 5->4. `IssuerMintAd` loses `cap_sats`; serializer, parser destructure, the wire-shape fixture and the arity labels follow. The counters stay: they are the only trust signal an operator reads before extending credit. --- crates/maxplayer-core/src/heartbeat.rs | 27 ++++++++------------------ docs/protocol-v1.md | 13 ++++++------- 2 files changed, 14 insertions(+), 26 deletions(-) diff --git a/crates/maxplayer-core/src/heartbeat.rs b/crates/maxplayer-core/src/heartbeat.rs index d69ff1c8a..c15ec1435 100644 --- a/crates/maxplayer-core/src/heartbeat.rs +++ b/crates/maxplayer-core/src/heartbeat.rs @@ -280,7 +280,7 @@ pub const ADMITS_POOL_TAG: &str = "admits_pool"; /// nothing about its list. pub const ADMITS_TARGETED_TAG: &str = "admits_targeted"; -/// `["issuer_mint", url, cap, outstanding, retired, last_seen]` — the seat runs its OWN Cashu mint +/// `["issuer_mint", url, outstanding, retired, last_seen]` — the seat runs its OWN Cashu mint /// and issues tokens that are an IOU for its own future work (§4.2 "Issuer mint"). The unit stays /// `sat`: one token is one sat of the issuer's work at its published `rate`, and the mint URL is the /// sole thing that distinguishes this currency from any other `sat` on the wire. @@ -306,8 +306,6 @@ pub struct IssuerMintAd { /// The seat's own mint URL. MUST also appear in `accepted_mints`: a seat announcing a currency /// it will not itself take back is not announcing an issuer mint, and the reader treats it so. pub mint_url: String, - /// Ceiling on tokens OUTSTANDING the issuer enforces on its own minting, in sats. - pub cap_sats: u64, /// Minted minus retired, in sats, as of `last_seen`. pub outstanding_sats: u64, /// Tokens the issuer has taken back and burned, in sats, as of `last_seen`. @@ -317,13 +315,12 @@ pub struct IssuerMintAd { } impl IssuerMintAd { - /// The wire tag: `["issuer_mint", url, cap, outstanding, retired, last_seen]`, counters as - /// plain decimal digit strings. + /// The wire tag: `["issuer_mint", url, outstanding, retired, last_seen]`, counters as plain + /// decimal digit strings. pub fn to_tag(&self) -> TagSpec { TagSpec(vec![ ISSUER_MINT_TAG.to_owned(), self.mint_url.clone(), - self.cap_sats.to_string(), self.outstanding_sats.to_string(), self.retired_sats.to_string(), self.last_seen.to_string(), @@ -340,7 +337,7 @@ impl IssuerMintAd { /// [`admission_from_tags`] applies to a half-stated policy. pub fn from_tags(tags: &[TagSpec], accepted_mints: &[String]) -> Option { let tag = first_tag(tags, ISSUER_MINT_TAG)?; - let [_, url, cap, outstanding, retired, last_seen] = tag.0.as_slice() else { + let [_, url, outstanding, retired, last_seen] = tag.0.as_slice() else { return None; }; if url.is_empty() || !accepted_mints.contains(url) { @@ -348,7 +345,6 @@ impl IssuerMintAd { } Some(Self { mint_url: url.clone(), - cap_sats: decimal_sats(cap)?, outstanding_sats: decimal_sats(outstanding)?, retired_sats: decimal_sats(retired)?, last_seen: decimal_sats(last_seen)?, @@ -1482,14 +1478,13 @@ mod tests { fn issuer() -> IssuerMintAd { IssuerMintAd { mint_url: mints()[0].clone(), - cap_sats: 100_000, outstanding_sats: 2_500, retired_sats: 750, last_seen: 1_788_390_000, } } - /// The wire shape, pinned by hand in both directions: one tag, five positional values, counters + /// The wire shape, pinned by hand in both directions: one tag, four positional values, counters /// as plain decimal digits. #[test] fn issuer_mint_wire_shape_round_trips() { @@ -1502,7 +1497,6 @@ mod tests { vec![ "issuer_mint", "https://testnut.example/Bitcoin", - "100000", "2500", "750", "1788390000", @@ -1619,11 +1613,11 @@ mod tests { let mut short = good.clone(); short.pop(); - cases.push(("four values".to_owned(), short)); + cases.push(("three values".to_owned(), short)); let mut long = good.clone(); long.push("extra".to_owned()); - cases.push(("six values".to_owned(), long)); + cases.push(("five values".to_owned(), long)); cases.push(("bare tag".to_owned(), vec![ISSUER_MINT_TAG.to_owned()])); @@ -1635,12 +1629,7 @@ mod tests { unlisted[1] = "https://elsewhere.example/Bitcoin".to_owned(); cases.push(("url not in accepted_mints".to_owned(), unlisted)); - for (index, field) in [ - (2, "cap"), - (3, "outstanding"), - (4, "retired"), - (5, "last_seen"), - ] { + for (index, field) in [(2, "outstanding"), (3, "retired"), (4, "last_seen")] { for bad in [ "", "-1", diff --git a/docs/protocol-v1.md b/docs/protocol-v1.md index d50479638..fb4a996eb 100644 --- a/docs/protocol-v1.md +++ b/docs/protocol-v1.md @@ -102,7 +102,7 @@ replaces it on every beat. Every fact below is current as of that beat, EXCEPT ` | `["agents", id, ...]` | 0..1 | no | Harnesses the seat can run | | `["admits_pool", "open"` or `"closed"]` | 0..1 | no | Whether the seat claims untargeted (open-pool) offers | | `["admits_targeted", "open"`, `"named"` or `"closed"]` | 0..1 | no | Who the seat admits on the targeted surface | -| `["issuer_mint", url, cap, outstanding, retired, last_seen]` | 0..1 | no | The seat's own mint and its issuance counters | +| `["issuer_mint", url, outstanding, retired, last_seen]` | 0..1 | no | The seat's own mint and its issuance counters | | `["harness_family", family, ...]` | 0..1 | no | Harness families the seat serves | | `["harness_model", family, model]` | 0..N | no | One resolved model, paired to its family | | `["capabilities", token, ...]` | 0..1 | no | Capability tokens the seat proved | @@ -183,17 +183,16 @@ with them, and the seat retires what comes back. No outside money enters or leav The unit stays `sat`. One token is one sat of this seat's work at its published `rate`. The mint URL is the sole thing that distinguishes this currency from any other `sat` on the wire. -It is one tag with five positional values, every one required when the tag is present: +It is one tag with four positional values, every one required when the tag is present: | Position | Value | Meaning | |---|---|---| | 1 | `url` | The seat's own mint. MUST also appear in the seat's `accepted_mints` | -| 2 | `cap` | Ceiling on tokens outstanding that the issuer enforces on its own minting, in sats | -| 3 | `outstanding` | Minted minus retired, in sats, as of `last_seen` | -| 4 | `retired` | Tokens the issuer has taken back and burned, in sats, as of `last_seen` | -| 5 | `last_seen` | Unix seconds at which the counters were read from the mint | +| 2 | `outstanding` | Minted minus retired, in sats, as of `last_seen` | +| 3 | `retired` | Tokens the issuer has taken back and burned, in sats, as of `last_seen` | +| 4 | `last_seen` | Unix seconds at which the counters were read from the mint | -`cap`, `outstanding`, `retired` and `last_seen` are strings of decimal digits and nothing else. +`outstanding`, `retired` and `last_seen` are strings of decimal digits and nothing else. The counters are the issuer's own statement. The seat's signature on the announcement covers them, so a reader knows WHO said them, not that they are true. A reader MUST NOT treat them as verified and From e78b1775d570e293eed727e7e97bebc0f2a500e8 Mon Sep 17 00:00:00 2001 From: w-ecash-mutual-credit Date: Wed, 2 Sep 2026 18:19:06 -0700 Subject: [PATCH 3/6] heartbeat: the issuer_mint doc says FOUR positional values, matching the tag The tag shrank to four values when `cap` was removed; one module-doc sentence at the top of the section still said five. Doc only. --- crates/maxplayer-core/src/heartbeat.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/maxplayer-core/src/heartbeat.rs b/crates/maxplayer-core/src/heartbeat.rs index c15ec1435..b02f4422e 100644 --- a/crates/maxplayer-core/src/heartbeat.rs +++ b/crates/maxplayer-core/src/heartbeat.rs @@ -285,7 +285,7 @@ pub const ADMITS_TARGETED_TAG: &str = "admits_targeted"; /// `sat`: one token is one sat of the issuer's work at its published `rate`, and the mint URL is the /// sole thing that distinguishes this currency from any other `sat` on the wire. /// -/// ONE tag, FIVE positional values, all required when the tag is present — see [`IssuerMintAd`] for +/// ONE tag, FOUR positional values, all required when the tag is present — see [`IssuerMintAd`] for /// what each one means. Absent means the seat states no issuer mint. Malformed reads as UNSTATED, /// never as a rejection: an optional tag must not be able to take a working seat off the market. /// From b77d371cbecb0d6c91e2fe80ee5466c0d71d6691 Mon Sep 17 00:00:00 2001 From: w-ecash-mutual-credit Date: Wed, 2 Sep 2026 17:38:09 -0700 Subject: [PATCH 4/6] wallet: issuer-mint class, class-aware fence, Lightning hop refusal (stage 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The buyer wallet learns a mint CLASS (docs/protocol-v1.md §4.2 "Issuer mint"): Lightning, or ISSUER — a seat's own Cashu mint whose tokens are an IOU for that seat's work and have no Lightning route in or out. Markers, each recorded with WHO said it (`mint_class::IssuerMarker`): - Own: this seat's own mint, from a new optional `issuer_mint` config key. - Info: a mint whose NUT-06 info lists no bolt11 method under NUT-04/05, learned by a bounded GET /v1/info per accepted mint at accept. - Declared: the seller's own kind-30340 `issuer_mint` tag, read off its announcement at accept by (author, kind, d). Rules: - The real-mint fence admits an Own/Info issuer mint whatever `allow_real_mints` says (it carries no sats). A Declared mint does NOT widen the fence: a seller's signed word must not open the buyer's fence to any mint it names. Every marker refuses the hop. - `plan_payment` refuses a Lightning hop INTO or OUT OF any issuer mint with the plain reason "you hold none of this seller's currency"; an issuer entry is never a hop target. Direct payment at one is unchanged. - `select_source_mint` prefers the buyer's OWN mint when the seller lists it. - The hop executor asks both mints their class before any leg (run_hop gate + plan_quotes guard); `wallet fund` / `wallet melt` refuse at a mint whose info lists no bolt11. An unreachable info read is UNKNOWN, i.e. Lightning, so reachability errors keep their existing labels. - The class knowledge is SEALED into the accept-bind (`issuer_mints`, with markers) and re-derived at pay; legacy binds read as none known. Unit stays `sat`; the three sat gates (gateway.rs parse_offer, payment_wallet.rs terms_for_offer + seller receive) are untouched. --- crates/maxplayer-core/src/authorize_pay.rs | 75 ++- crates/maxplayer-core/src/buyer/lifecycle.rs | 26 +- crates/maxplayer-core/src/buyer/mod.rs | 8 + crates/maxplayer-core/src/buyer_fund.rs | 15 +- crates/maxplayer-core/src/collect.rs | 1 + crates/maxplayer-core/src/crossmint.rs | 374 ++++++++++-- crates/maxplayer-core/src/crossmint_hop.rs | 136 +++++ crates/maxplayer-core/src/home.rs | 18 + crates/maxplayer-core/src/job_lifecycle.rs | 244 +++++++- crates/maxplayer-core/src/lib.rs | 2 + crates/maxplayer-core/src/mint_class.rs | 562 ++++++++++++++++++ crates/maxplayer-core/src/wallet_ops.rs | 31 + .../maxplayer-core/tests/collect_integrity.rs | 2 + crates/maxplayer/src/mcp.rs | 1 + crates/maxplayer/src/wallet_cli.rs | 2 + 15 files changed, 1441 insertions(+), 56 deletions(-) create mode 100644 crates/maxplayer-core/src/mint_class.rs diff --git a/crates/maxplayer-core/src/authorize_pay.rs b/crates/maxplayer-core/src/authorize_pay.rs index 4b01de22f..b88758fbc 100644 --- a/crates/maxplayer-core/src/authorize_pay.rs +++ b/crates/maxplayer-core/src/authorize_pay.rs @@ -73,6 +73,12 @@ pub struct AuthorizePayRequest { /// a claim with no `creq` — the buyer then pays from the pinned default mint. #[allow(clippy::struct_field_names)] pub accepted_mints: Vec, + /// The issuer mints SEALED into the accept-bind (§4.2 "Issuer mint"), each with WHO said it: + /// the buyer's own (config), any accepted mint classified from its info at accept, and the + /// seller's own declaration off its announcement. The class-aware fence and the hop refusal + /// read this seal; the pay path never probes a mint's class itself, so the plan re-derived here + /// is the plan that was accepted. Empty ⇒ none known (a legacy bind, or Lightning mints only). + pub issuer_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 @@ -146,6 +152,8 @@ pub struct CompleteLockedRequest { pub seller_signature: String, pub creq_hash: Option, pub accepted_mints: Vec, + /// Sealed issuer-mint knowledge, as on [`AuthorizePayRequest::issuer_mints`]. + pub issuer_mints: Vec, pub realized_mint: Option, } @@ -317,6 +325,7 @@ pub async fn authorize_pay_async( key, seller_nostr, plan, + issuers, } = derive_payment( home, &request.job_id, @@ -326,6 +335,7 @@ pub async fn authorize_pay_async( &request.seller_pubkey, request.amount_sats, &request.accepted_mints, + &request.issuer_mints, request.realized_mint.as_deref(), request.creq_hash.clone(), )?; @@ -495,7 +505,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(home, &terms, &issuers), ) .await?; // A pairing already on disk WINS over freshly raised quotes. This attempt may have @@ -509,8 +519,12 @@ pub async fn authorize_pay_async( } }; - let wallet = buyer_fund::open_wallet_at_mint_async(home, &wallet_open_mint_url(home, &terms)) - .await?; + let wallet = buyer_fund::open_wallet_at_mint_admitted_async( + home, + &wallet_open_mint_url(home, &terms, &issuers), + &issuers, + ) + .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 // reqwest pooled connection whose IO driver task lived on the caller; the worker then blocked that @@ -724,6 +738,7 @@ pub async fn complete_recovered_locked_async( key, seller_nostr, plan: _, + issuers, } = derive_payment( home, &request.job_id, @@ -733,6 +748,7 @@ pub async fn complete_recovered_locked_async( &request.seller_pubkey, request.amount_sats, &request.accepted_mints, + &request.issuer_mints, request.realized_mint.as_deref(), request.creq_hash.clone(), )?; @@ -756,7 +772,11 @@ 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_admitted_async( + home, + &wallet_open_mint_url(home, &terms, &issuers), + &issuers, + ) .await?; let payment_send = NostrPaymentSend::new(home.config.relay_url.clone(), keys); let mut effects = CdkPaymentEffects::spawn( @@ -814,11 +834,18 @@ fn contribution_policy(home: &MaxplayerHome) -> crate::contribution::ContentPoli /// 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 was fenced while planning; `open_wallet_at_mint_admitted_async` re-checks, redundant-safe). +/// `issuers` is the SEALED issuer-mint knowledge the plan was made with: a realized issuer mint +/// passes the fence by class, and the assertion here has to know that or it would fire on every +/// payment made in a seat's own currency. +pub(crate) fn wallet_open_mint_url( + home: &MaxplayerHome, + terms: &PaymentTerms, + issuers: &crate::mint_class::IssuerMints, +) -> String { let mint_url = terms.mint.to_string(); debug_assert!( - crate::home::mint_allowed(&mint_url, home.config.allow_real_mints), + crate::mint_class::mint_admitted(&mint_url, home.config.allow_real_mints, issuers), "frozen realized mint must already be fenced before wallet open" ); mint_url @@ -887,6 +914,9 @@ struct DerivedPayment { /// do not re-parse it. seller_nostr: NostrPublicKey, plan: crate::crossmint::PayPlan, + /// The sealed issuer-mint knowledge the plan was made with, handed on so the wallet-open fence + /// judges the realized mint by the same class the planner did. + issuers: crate::mint_class::IssuerMints, } /// Derive the stable [`PaymentTerms`] + [`PaymentKey`] (and thus the attempt id) from a trade's @@ -911,6 +941,7 @@ fn derive_payment( seller_pubkey: &str, amount_sats: u64, accepted_mints: &[String], + issuer_mints: &[crate::mint_class::IssuerMintSeal], realized_mint: Option<&str>, creq_hash: Option, ) -> Result { @@ -926,10 +957,14 @@ 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()); + // The issuer-mint knowledge is the SEAL, and only the seal: no live config, no probe. A pay-time + // re-derivation that consulted anything else could plan a different payment than was accepted. + let issuers = crate::mint_class::IssuerMints::from_seal(issuer_mints); let plan = crate::crossmint::plan_payment( buyer_selected_mint, accepted_mints, home.config.allow_real_mints, + &issuers, )?; let terms = PaymentTerms::new( plan.realized_mint().clone(), @@ -951,6 +986,7 @@ fn derive_payment( key, seller_nostr, plan, + issuers, }) } @@ -1188,6 +1224,7 @@ fn publish_receipt_event( #[cfg(test)] mod tests { use super::*; + use crate::mint_class::IssuerMints; use cashu::MintUrl; use crate::budget::BudgetGate; @@ -1200,7 +1237,7 @@ mod tests { // 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(DEFAULT_MINT_URL, &[], false, &IssuerMints::none()).unwrap(); assert!(!plan.is_hop()); assert_eq!( plan.realized_mint(), @@ -1218,6 +1255,7 @@ mod tests { DEFAULT_MINT_URL.to_string(), ], false, + &IssuerMints::none(), ) .unwrap(); assert!(!plan.is_hop(), "overlap must not hop"); @@ -1240,6 +1278,7 @@ mod tests { "https://buyer-only.example", &[DEFAULT_MINT_URL.to_string()], true, + &IssuerMints::none(), ) .unwrap(); assert!(plan.is_hop(), "no overlap must plan a hop, not refuse"); @@ -1263,6 +1302,7 @@ mod tests { "https://buyer-only.example", &[DEFAULT_MINT_URL.to_string()], false, + &IssuerMints::none(), ) .unwrap_err(); assert!( @@ -1277,7 +1317,7 @@ mod tests { #[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) + crate::crossmint::plan_payment(DEFAULT_MINT_URL, &[REAL_MINT.to_string()], false, &IssuerMints::none()) .unwrap_err(); assert!(matches!(error, AuthorizePayError::Input(_))); let rendered = error.to_string(); @@ -1293,7 +1333,7 @@ mod tests { #[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(); + crate::crossmint::plan_payment(REAL_MINT, &[REAL_MINT.to_string()], false, &IssuerMints::none()).unwrap_err(); assert!(matches!(error, AuthorizePayError::Input(_))); assert!(error.to_string().contains("real-mint fence")); } @@ -1302,14 +1342,14 @@ mod tests { #[test] fn pay_plan_real_mint_admitted_when_flag_true() { let plan = - crate::crossmint::plan_payment(REAL_MINT, &[REAL_MINT.to_string()], true).unwrap(); + crate::crossmint::plan_payment(REAL_MINT, &[REAL_MINT.to_string()], true, &IssuerMints::none()).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. let hopped = - crate::crossmint::plan_payment(REAL_MINT, &[DEFAULT_MINT_URL.to_string()], true) + crate::crossmint::plan_payment(REAL_MINT, &[DEFAULT_MINT_URL.to_string()], true, &IssuerMints::none()) .unwrap(); assert!(hopped.is_hop()); assert_eq!( @@ -1490,6 +1530,7 @@ mod tests { seller_signature: String::new(), creq_hash: None, accepted_mints: Vec::new(), + issuer_mints: Vec::new(), realized_mint: None, contribution: None, }; @@ -1609,6 +1650,7 @@ mod tests { // 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()], + issuer_mints: Vec::new(), realized_mint: Some(DEFAULT_MINT_URL.to_string()), contribution: None, }; @@ -1655,6 +1697,7 @@ mod tests { seller_signature: String::new(), creq_hash: None, accepted_mints: Vec::new(), + issuer_mints: Vec::new(), realized_mint: None, contribution: None, }; @@ -1699,6 +1742,7 @@ mod tests { seller_signature: String::new(), creq_hash: None, accepted_mints: Vec::new(), + issuer_mints: Vec::new(), realized_mint: None, contribution: None, }; @@ -1746,6 +1790,7 @@ mod tests { seller_signature: valid_sig, creq_hash: None, accepted_mints: Vec::new(), + issuer_mints: Vec::new(), realized_mint: None, contribution: None, }; @@ -1859,7 +1904,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, true, &IssuerMints::none()) .expect("plans") .realized_mint() .clone() @@ -1971,6 +2016,7 @@ mod tests { seller_signature: forged_sig, creq_hash: None, accepted_mints: Vec::new(), + issuer_mints: Vec::new(), realized_mint: None, contribution: None, }; @@ -2028,6 +2074,7 @@ mod tests { seller_signature: forged_sig, creq_hash: Some(creq_hash.clone()), accepted_mints: vec![DEFAULT_MINT_URL.to_string()], + issuer_mints: Vec::new(), realized_mint: None, contribution: None, }; @@ -2101,6 +2148,7 @@ mod tests { seller_signature: sig_over_2, creq_hash: None, accepted_mints: Vec::new(), + issuer_mints: Vec::new(), realized_mint: None, contribution: None, }; @@ -2132,6 +2180,7 @@ mod tests { seller_signature: sig_over_aa, creq_hash: None, accepted_mints: Vec::new(), + issuer_mints: Vec::new(), 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..bbd80b49a 100644 --- a/crates/maxplayer-core/src/buyer/lifecycle.rs +++ b/crates/maxplayer-core/src/buyer/lifecycle.rs @@ -42,6 +42,12 @@ pub struct AwardFilters<'a> { pub buyer_mint: &'a str, /// Whether real (non-testnut) mints are permitted; gates the mint-compat check. pub allow_real_mints: bool, + /// The issuer mints the buyer knows of at award time (§4.2 "Issuer mint"): its OWN, from config. + /// A seller's issuer mint is not known here — the award filter reads the claim and never probes + /// a mint — so a claim listing only such a mint passes this filter as a Lightning hop and is + /// refused at accept, where the class is learned and sealed. Fail-safe: the filter never admits + /// a claim the accept gate would not. A reference, so the filters stay `Copy`. + pub issuer_mints: &'a crate::mint_class::IssuerMints, /// 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. @@ -86,12 +92,14 @@ pub fn award_filters_for_offer<'a>( max_sats: u64, buyer_mint: &'a str, allow_real_mints: bool, + issuer_mints: &'a crate::mint_class::IssuerMints, ) -> AwardFilters<'a> { AwardFilters { offer_amount_sats: offer.amount_sats, max_sats, buyer_mint, allow_real_mints, + issuer_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,6 +417,7 @@ pub fn unsatisfiable_capability_request( offer_amount_sats: 0, max_sats: 0, buyer_mint: "", + issuer_mints: &crate::mint_class::NO_ISSUER_MINTS, allow_real_mints: false, requested_agent, requested_harness_family, @@ -608,7 +617,13 @@ 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, + filters.allow_real_mints, + filters.issuer_mints, + ) + .is_ok() } /// What [`award_with_reservation`] may do about a job, decided BEFORE any reserve, sign, or send. @@ -1610,6 +1625,7 @@ mod tests { max_sats, buyer_mint: DEFAULT_MINT_URL, allow_real_mints: false, + issuer_mints: &crate::mint_class::NO_ISSUER_MINTS, requested_agent: None, requested_harness_family: None, requested_model: None, @@ -3955,7 +3971,13 @@ mod tests { /// predicate, and the fix in both cases is to call the real thing rather than to test the copy /// harder. fn filters_from_offer<'a>(offer: &'a OfferView, max_sats: u64) -> AwardFilters<'a> { - award_filters_for_offer(offer, max_sats, DEFAULT_MINT_URL, false) + award_filters_for_offer( + offer, + max_sats, + DEFAULT_MINT_URL, + false, + &crate::mint_class::NO_ISSUER_MINTS, + ) } // THE ACCEPTANCE TEST FOR #897, both axes through BOTH selection entry points. diff --git a/crates/maxplayer-core/src/buyer/mod.rs b/crates/maxplayer-core/src/buyer/mod.rs index 3e935c77e..5d318bb37 100644 --- a/crates/maxplayer-core/src/buyer/mod.rs +++ b/crates/maxplayer-core/src/buyer/mod.rs @@ -822,11 +822,14 @@ async fn award(context: &BuyerContext, id: Value, params: Value) -> Response { // every other filter come from the SIGNED OFFER, never from award params, so the request // cannot be changed after the fact. Sharing the constructor is what makes "both paths // filter identically" structural instead of a convention someone has to keep noticing. + let issuer_mints = + crate::mint_class::IssuerMints::none().with_own(context.home.config.issuer_mint()); let filters = lifecycle::award_filters_for_offer( offer, max_sats, context.home.config.default_mint(), context.home.config.allow_real_mints, + &issuer_mints, ); // Manual award names the claim but applies the SAME hard filters as auto-award — @@ -1312,11 +1315,14 @@ async fn drive_auto_award( // THE SAME constructor the manual award path uses, so the two cannot apply different filters. // Both selection entry points then consult `claim_meets_capability_request`: // `select_awardable_claim` here, `named_claim_awardable` on the manual path. + let issuer_mints = + crate::mint_class::IssuerMints::none().with_own(context.home.config.issuer_mint()); let filters = lifecycle::award_filters_for_offer( offer, max_sats, context.home.config.default_mint(), context.home.config.allow_real_mints, + &issuer_mints, ); // Built AFTER `filters` so the deadline park can name the capability request that refused @@ -5180,6 +5186,7 @@ mod tests { seller_signature: String::new(), creq_hash: None, accepted_mints: vec![], + issuer_mints: Vec::new(), funding_mint: None, delivery_mint: None, agent_used: None, @@ -5218,6 +5225,7 @@ mod tests { seller_signature: String::new(), creq_hash: None, accepted_mints: vec![], + issuer_mints: Vec::new(), funding_mint: None, delivery_mint: None, agent_used: None, diff --git a/crates/maxplayer-core/src/buyer_fund.rs b/crates/maxplayer-core/src/buyer_fund.rs index 675511c14..8347f2775 100644 --- a/crates/maxplayer-core/src/buyer_fund.rs +++ b/crates/maxplayer-core/src/buyer_fund.rs @@ -85,12 +85,25 @@ pub async fn open_wallet_async(home: &MaxplayerHome) -> Result Result { + open_wallet_at_mint_admitted_async(home, mint_url, &crate::mint_class::IssuerMints::none()).await +} + +/// [`open_wallet_at_mint_async`] with the caller's SEALED issuer-mint knowledge (§4.2 "Issuer +/// mint"): a mint `issuers` names passes the fence by class — it carries no sats — so a payment +/// made in a seat's own currency can open its wallet there whatever `allow_real_mints` says and +/// whatever scheme the sidecar's URL has. Every other mint is fenced exactly as before. The pay path +/// passes the bind's seal; nothing else may widen the fence. +pub async fn open_wallet_at_mint_admitted_async( + home: &MaxplayerHome, + mint_url: &str, + issuers: &crate::mint_class::IssuerMints, ) -> 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) { + if !crate::mint_class::mint_admitted(mint_url, home.config.allow_real_mints, issuers) { return Err(FundError::MintNotAllowed { mint_url: mint_url.to_owned(), }); diff --git a/crates/maxplayer-core/src/collect.rs b/crates/maxplayer-core/src/collect.rs index 2e0fa3c1f..d329a93a4 100644 --- a/crates/maxplayer-core/src/collect.rs +++ b/crates/maxplayer-core/src/collect.rs @@ -406,6 +406,7 @@ mod tests { seller_signature: String::new(), creq_hash: None, accepted_mints: Vec::new(), + issuer_mints: Vec::new(), funding_mint: None, delivery_mint: None, agent_used: None, diff --git a/crates/maxplayer-core/src/crossmint.rs b/crates/maxplayer-core/src/crossmint.rs index 283d56fd9..68b5bf1a2 100644 --- a/crates/maxplayer-core/src/crossmint.rs +++ b/crates/maxplayer-core/src/crossmint.rs @@ -16,7 +16,7 @@ use std::str::FromStr; use cdk::mint_url::MintUrl; use crate::authorize_pay::AuthorizePayError; -use crate::home; +use crate::mint_class::{ISSUER_HOP_REFUSAL, IssuerMints, mint_admitted}; /// How the buyer reaches a mint the seller accepts. #[derive(Debug, Clone, PartialEq, Eq)] @@ -87,16 +87,24 @@ impl PayPlan { /// source: a hop ends with the buyer holding ecash at the target, so an unfenced target would let a /// real-sats mint in through the back door while `allow_real_mints` is off. /// +/// The fence is class-aware ([`mint_admitted`]): a mint `issuers` names is admitted whatever the +/// real-money switch says, because it carries no sats. The hop is class-aware in the other +/// direction: an issuer mint has no Lightning, so a hop can neither leave one nor land on one. +/// A DIRECT payment at an issuer mint is fine — that is the buyer spending currency it already holds +/// (the seller's, or its own where the seller lists it) — and is the only way such a mint is paid. +/// /// 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 /// a retry that re-derived a different target would compute a different attempt id and defeat -/// pays-once. +/// pays-once. `issuers` is part of that determinism: the accept path seals what it knew into the +/// bind and the pay path passes the seal back in, never a fresh classification. pub fn plan_payment( buyer_selected_mint: &str, accepted_mints: &[String], allow_real_mints: bool, + issuers: &IssuerMints, ) -> Result { - if !home::mint_allowed(buyer_selected_mint, allow_real_mints) { + if !mint_admitted(buyer_selected_mint, allow_real_mints, issuers) { 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" @@ -120,12 +128,26 @@ 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 + // No overlap, so reaching the seller means a Lightning hop — which an issuer mint cannot be a + // leg of, on either side. OUT: the buyer's selected mint is an issuer mint; its tokens do not + // melt to Lightning, they only hire their issuer. Refused before any target is even considered. + if issuers.contains(buyer_selected_mint) { + return Err(AuthorizePayError::Input(format!( + "cross-mint hop refused: {ISSUER_HOP_REFUSAL}. The buyer's mint {buyer_mint} is an \ + issuer mint with no Lightning, so nothing can be melted out of it to reach the seller's \ + mints {accepted_mints:?}; its tokens are only good for hiring the seat that issued them" + ))); + } + + // IN: an accepted mint that is an issuer mint cannot be hopped INTO — Lightning cannot buy the + // seller's currency; only holding it already can pay there (the Direct row above). Skip every + // such entry, then hop to the first remaining mint the fence admits; refuse fail-closed if none // does, rather than hopping to a mint we are not permitted to hold ecash at. let target = accepted_mints .iter() .zip(listed) - .find(|(raw, _)| home::mint_allowed(raw, allow_real_mints)) + .filter(|(raw, _)| !issuers.contains(raw)) + .find(|(raw, _)| mint_admitted(raw, allow_real_mints, issuers)) .map(|(_, parsed)| parsed); match target { @@ -133,6 +155,14 @@ pub fn plan_payment( source: buyer_mint, target, }), + None if accepted_mints.iter().any(|raw| issuers.contains(raw)) => { + Err(AuthorizePayError::Input(format!( + "cross-mint hop refused: {ISSUER_HOP_REFUSAL}. The seller accepts issuer-mint \ + currency {accepted_mints:?} that has no Lightning route, so a hop from \ + {buyer_mint} cannot land there; only tokens the buyer already holds at the \ + issuer's mint can pay this seller" + ))) + } 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 \ @@ -147,10 +177,18 @@ pub fn plan_payment( /// the default (which would drain the default and pay a melt fee). Falls back to the configured /// default — today's behavior — when no held, accepted, fence-admissible mint covers the amount. /// -/// Deterministic preference: the FIRST entry of the seller's `accepted_mints`, in the seller's list -/// order, that (1) passes the real-mint fence and (2) shows a balance `>= amount_sats`. Returning an -/// accepted mint makes [`plan_payment`] plan a DIRECT payment from it (no hop); the seller's order is -/// their stated preference and keeps the choice stable across retries. +/// FIRST preference, ahead of any balance: the buyer's OWN issuer mint (`own_mint`), when the seller +/// lists it. A seat pays in its own currency wherever that currency is taken — that is what the +/// currency is for — and it needs no balance to do so: the issuer mints at will from its own mint +/// (stage 3), and the pay path refuses fail-closed at the sealed mint if it cannot. `own_mint` is compared by +/// normalized URL, and an `own_mint` the seller does not list changes nothing. +/// +/// Then the deterministic balance preference: the FIRST entry of the seller's `accepted_mints`, in +/// the seller's list order, that (1) passes the class-aware real-mint fence and (2) shows a balance +/// `>= amount_sats`. A seller's issuer mint the buyer holds a balance at qualifies here like any +/// other — bearer tokens are spent by whoever holds them. 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. /// /// Balance-awareness is ADVISORY and applied ONCE, here at accept. The result is sealed into the /// accept-bind and re-derived (not re-decided) at pay, so a later balance or config-default change @@ -163,12 +201,21 @@ pub(crate) fn select_source_mint( allow_real_mints: bool, balances: &[crate::wallet_ops::MintBalance], amount_sats: u64, + own_mint: Option<&str>, + issuers: &IssuerMints, ) -> String { + if let Some(own) = own_mint.and_then(|own| MintUrl::from_str(own).ok()) + && let Some(listed) = accepted_mints + .iter() + .find(|accepted| MintUrl::from_str(accepted).map(|url| url == own).unwrap_or(false)) + { + return listed.clone(); + } accepted_mints .iter() .find(|accepted| { let mint = accepted.as_str(); - home::mint_allowed(mint, allow_real_mints) && holds_at_least(balances, mint, amount_sats) + mint_admitted(mint, allow_real_mints, issuers) && holds_at_least(balances, mint, amount_sats) }) .cloned() .unwrap_or_else(|| config_default.to_owned()) @@ -295,7 +342,7 @@ 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(DEFAULT_MINT_URL, &[DEFAULT_MINT_URL.to_owned()], false, &IssuerMints::none()) .expect("an accepted, fenced buyer mint plans"); assert_eq!( plan, @@ -316,7 +363,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, true, &IssuerMints::none()).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 +372,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, true, &IssuerMints::none()).expect("a hop is plannable"); assert_eq!( plan, PayPlan::Hop { @@ -350,10 +397,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, true, &IssuerMints::none()).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, true, &IssuerMints::none()).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,7 +409,7 @@ 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(DEFAULT_MINT_URL, &[DEFAULT_MINT_URL.to_owned()], false, &IssuerMints::none()) .expect("direct plans"); assert_eq!(plan.source_mint(), plan.realized_mint()); assert_eq!(plan.source_mint(), &mint(DEFAULT_MINT_URL)); @@ -377,9 +424,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, true, &IssuerMints::none()).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, true, &IssuerMints::none()).expect("plans"); assert_eq!( again, first, "target selection must not vary between attempts" @@ -390,7 +437,7 @@ 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(DEFAULT_MINT_URL, &[], false, &IssuerMints::none()).expect("legacy bind plans"); assert_eq!( plan, PayPlan::Direct { @@ -403,7 +450,7 @@ mod tests { #[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) + let error = plan_payment("https://real-mint.example", &accepted, false, &IssuerMints::none()) .expect_err("an unfenced buyer mint must refuse"); let rendered = error.to_string(); assert!( @@ -418,7 +465,7 @@ mod tests { 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) + let error = plan_payment(DEFAULT_MINT_URL, &accepted, false, &IssuerMints::none()) .expect_err("an unfenced hop target must refuse"); let rendered = error.to_string(); assert!( @@ -436,7 +483,7 @@ mod tests { #[test] fn hop_to_a_real_mint_plans_once_the_operator_opts_in() { let accepted = vec!["https://real-mint.example".to_owned()]; - let plan = plan_payment(DEFAULT_MINT_URL, &accepted, true) + let plan = plan_payment(DEFAULT_MINT_URL, &accepted, true, &IssuerMints::none()) .expect("opted-in real mint is a permitted hop target"); assert_eq!( plan, @@ -523,11 +570,266 @@ mod tests { "http://not-https.example".to_owned(), DEFAULT_MINT_URL.to_owned(), ]; - let plan = plan_payment("https://buyer.example", &accepted, true) + let plan = plan_payment("https://buyer.example", &accepted, true, &IssuerMints::none()) .expect("a later admissible entry is usable"); assert_eq!(plan.realized_mint(), &mint(DEFAULT_MINT_URL)); } + // ---- issuer mints (§4.2 "Issuer mint"): the fence admits, the hop refuses, own mint first ---- + + /// The buyer's own sidecar mint — plain http on loopback, the normal case. + const SIDECAR: &str = "http://127.0.0.1:3338"; + /// Another seat's issuer mint, reachable on a LAN. Also not https. + const SELLER_ISSUER: &str = "http://10.0.0.7:3338"; + + fn issuers(urls: &[&str]) -> IssuerMints { + IssuerMints::from_urls(urls) + } + + /// A DIRECT payment at a known issuer mint passes the fence with the real-money switch OFF and + /// over plain http: it moves no sats. The same URL nobody classified is fenced exactly as before. + #[test] + fn a_direct_payment_at_a_known_issuer_mint_passes_the_fence() { + let accepted = vec![SELLER_ISSUER.to_owned(), DEFAULT_MINT_URL.to_owned()]; + let plan = plan_payment(SELLER_ISSUER, &accepted, false, &issuers(&[SELLER_ISSUER])) + .expect("direct in the seller's currency"); + assert_eq!( + plan, + PayPlan::Direct { + mint: mint(SELLER_ISSUER) + } + ); + for allow in [false, true] { + let refused = plan_payment(SELLER_ISSUER, &accepted, allow, &IssuerMints::none()) + .expect_err("unclassified http mint is fenced as before"); + assert!(refused.to_string().contains("real-mint fence"), "{refused}"); + } + } + + /// A hop INTO an issuer mint refuses with the plain reason; an issuer entry is never a hop + /// target, whatever its position in the seller's list. + #[test] + fn a_hop_into_an_issuer_mint_is_refused_with_the_plain_reason() { + // The seller accepts only its own currency; the buyer sits at a Lightning mint. No landing. + let error = plan_payment( + DEFAULT_MINT_URL, + &[SELLER_ISSUER.to_owned()], + true, + &issuers(&[SELLER_ISSUER]), + ) + .expect_err("no Lightning route into an issuer mint"); + let text = error.to_string(); + assert!(text.contains(ISSUER_HOP_REFUSAL), "{text}"); + assert!(text.contains(SELLER_ISSUER), "{text}"); + + // The seller lists its issuer mint FIRST and a Lightning mint after: the issuer entry is + // skipped and the hop lands on the Lightning mint. + let buyer = "https://buyer-only.example"; + let plan = plan_payment( + buyer, + &[SELLER_ISSUER.to_owned(), DEFAULT_MINT_URL.to_owned()], + true, + &issuers(&[SELLER_ISSUER]), + ) + .expect("hops past the issuer entry"); + assert_eq!( + plan, + PayPlan::Hop { + source: mint(buyer), + target: mint(DEFAULT_MINT_URL), + } + ); + + // The class is what does it, not the scheme: an https issuer mint that the fence ALONE + // would admit as a target is still never one once classified. + let https_issuer = "https://issuer.example/Bitcoin"; + let accepted = vec![https_issuer.to_owned(), DEFAULT_MINT_URL.to_owned()]; + let unclassified = plan_payment(buyer, &accepted, true, &IssuerMints::none()).unwrap(); + assert_eq!( + unclassified.realized_mint(), + &mint(https_issuer), + "unclassified, the fence alone admits it as a hop target" + ); + let classified = plan_payment(buyer, &accepted, true, &issuers(&[https_issuer])).unwrap(); + assert_eq!( + classified.realized_mint(), + &mint(DEFAULT_MINT_URL), + "classified, it is never a hop target" + ); + } + + /// A hop OUT of an issuer mint refuses: the buyer's own currency does not melt to Lightning. A + /// seller that LISTS that mint is paid directly, as ever. + #[test] + fn a_hop_out_of_an_issuer_mint_is_refused_with_the_plain_reason() { + let error = plan_payment( + SIDECAR, + &[DEFAULT_MINT_URL.to_owned()], + true, + &issuers(&[SIDECAR]), + ) + .expect_err("nothing melts out of an issuer mint"); + let text = error.to_string(); + assert!(text.contains(ISSUER_HOP_REFUSAL), "{text}"); + assert!(text.contains("melted out"), "{text}"); + + let plan = plan_payment( + SIDECAR, + &[DEFAULT_MINT_URL.to_owned(), SIDECAR.to_owned()], + false, + &issuers(&[SIDECAR]), + ) + .expect("the seller takes the buyer's currency: direct"); + assert_eq!(plan, PayPlan::Direct { mint: mint(SIDECAR) }); + } + + /// NEGATIVE, exhaustively over a small world: no plan this module can produce puts a known + /// issuer mint on either leg of a hop, for any source, any two-entry accepted list, and either + /// setting of the real-money switch. + #[test] + fn no_plan_ever_puts_an_issuer_mint_on_a_hop_leg() { + let lightning = ["https://a.example", "https://b.example", DEFAULT_MINT_URL]; + let issuer_urls = [SIDECAR, SELLER_ISSUER, "https://issuer.example"]; + let known = issuers(&issuer_urls); + let mut world: Vec<&str> = lightning.to_vec(); + world.extend(issuer_urls); + + let mut hops = 0; + for source in &world { + for first in &world { + for second in &world { + for allow in [false, true] { + let accepted = vec![first.to_string(), second.to_string()]; + if let Ok(PayPlan::Hop { source, target }) = + plan_payment(source, &accepted, allow, &known) + { + hops += 1; + assert!( + !known.contains(&source.to_string()), + "planned a hop OUT of issuer mint {source} (accepted {accepted:?})" + ); + assert!( + !known.contains(&target.to_string()), + "planned a hop INTO issuer mint {target} (accepted {accepted:?})" + ); + } + } + } + } + } + assert!(hops > 0, "the world must contain some legitimate hops for this to prove anything"); + } + + /// `select_source_mint` puts the buyer's OWN mint first when the seller lists it, balance or no + /// balance; otherwise it behaves as before. A seller's issuer mint the buyer holds tokens at is a + /// valid direct source (bearer) once classified — and fenced as before when not. + #[test] + fn source_selection_prefers_the_buyers_own_mint_when_the_seller_lists_it() { + let listed_with_slash = "http://127.0.0.1:3338/"; + let accepted = vec![DEFAULT_MINT_URL.to_owned(), listed_with_slash.to_owned()]; + let rich_default = [balance(DEFAULT_MINT_URL, 10_000)]; + let known = issuers(&[SIDECAR]); + + // Own mint listed (spelled with a trailing slash — normalized compare) beats a funded default, + // and the SELLER's spelling is what is returned, so the plan matches the creq list. + assert_eq!( + select_source_mint(DEFAULT_MINT_URL, &accepted, true, &rich_default, 100, Some(SIDECAR), &known), + listed_with_slash + ); + // Own mint not listed: unchanged behaviour. + assert_eq!( + select_source_mint(DEFAULT_MINT_URL, &[DEFAULT_MINT_URL.to_owned()], true, &rich_default, 100, Some(SIDECAR), &known), + DEFAULT_MINT_URL + ); + // No own mint stated: unchanged behaviour. + assert_eq!( + select_source_mint(DEFAULT_MINT_URL, &accepted, true, &rich_default, 100, None, &IssuerMints::none()), + DEFAULT_MINT_URL + ); + // A seller's issuer mint the buyer HOLDS tokens at: direct source, switch off, plain http. + let held = [balance(SELLER_ISSUER, 500)]; + assert_eq!( + select_source_mint(DEFAULT_MINT_URL, &[SELLER_ISSUER.to_owned()], false, &held, 100, None, &issuers(&[SELLER_ISSUER])), + SELLER_ISSUER + ); + // ...and not without the class: the fence refuses the http URL as before. + assert_eq!( + select_source_mint(DEFAULT_MINT_URL, &[SELLER_ISSUER.to_owned()], false, &held, 100, None, &IssuerMints::none()), + DEFAULT_MINT_URL + ); + } + + /// A mint the SELLER declared its issuer mint (its kind-30340 tag, read at accept) is an issuer + /// mint to the hop — never a leg, in either direction — but NOT to the fence: a seller's word + /// widens nothing. So a declared https mint is skipped as a hop target where the fence alone + /// would have landed there; a direct payment at a declared REAL mint still answers to the + /// real-money switch exactly as before; and a declared sidecar is fenced until the mint's own + /// info (or this seat's config) says otherwise. + #[test] + fn a_sellers_declared_issuer_mint_refuses_the_hop_but_widens_nothing() { + let declared_https = "https://issuer.example/Bitcoin"; + let declared_real = "https://mint.minibits.cash/Bitcoin"; + let buyer = "https://buyer-only.example"; + + // Hop INTO a declared mint: refused with the plain reason when it is the only entry... + let error = plan_payment( + buyer, + &[declared_https.to_owned()], + true, + &IssuerMints::none().with_declared(Some(declared_https)), + ) + .expect_err("no hop into a declared issuer mint"); + assert!(error.to_string().contains(ISSUER_HOP_REFUSAL), "{error}"); + // ...and skipped when a Lightning mint follows it, where unclassified it would have landed. + let accepted = vec![declared_https.to_owned(), DEFAULT_MINT_URL.to_owned()]; + let unclassified = plan_payment(buyer, &accepted, true, &IssuerMints::none()).unwrap(); + assert_eq!(unclassified.realized_mint(), &mint(declared_https)); + let declared = plan_payment( + buyer, + &accepted, + true, + &IssuerMints::none().with_declared(Some(declared_https)), + ) + .unwrap(); + assert_eq!(declared.realized_mint(), &mint(DEFAULT_MINT_URL)); + + // Hop OUT of a declared mint: refused. (The seller declaring the BUYER's mint is odd but + // costs nothing to refuse — the buyer would only ever hop out of it over Lightning.) + let error = plan_payment( + declared_https, + &[DEFAULT_MINT_URL.to_owned()], + true, + &IssuerMints::none().with_declared(Some(declared_https)), + ) + .expect_err("nothing melts out of a declared issuer mint"); + assert!(error.to_string().contains(ISSUER_HOP_REFUSAL), "{error}"); + + // NEGATIVE — the fence: a declared REAL mint the buyer sits at is fenced with the switch off + // and admitted with it on, exactly as an undeclared one. The declaration changed nothing. + let declared_only = IssuerMints::none().with_declared(Some(declared_real)); + let refused = plan_payment(declared_real, &[declared_real.to_owned()], false, &declared_only) + .expect_err("declared or not, a real mint answers to the switch"); + assert!(refused.to_string().contains("real-mint fence"), "{refused}"); + assert_eq!( + plan_payment(declared_real, &[declared_real.to_owned()], true, &declared_only).unwrap(), + PayPlan::Direct { + mint: mint(declared_real) + } + ); + // A declared plain-http sidecar is still fenced, both switch settings — until it is + // classified by its own info or by this seat's config, which the Info/Own rows above cover. + for allow in [false, true] { + let refused = plan_payment( + SELLER_ISSUER, + &[SELLER_ISSUER.to_owned()], + allow, + &IssuerMints::none().with_declared(Some(SELLER_ISSUER)), + ) + .expect_err("a declaration does not open the fence"); + assert!(refused.to_string().contains("real-mint fence"), "{refused}"); + } + } + // ---- select_source_mint: balance-aware source selection at accept (#497 behavior B) ---- fn balance(mint_url: &str, sats: u64) -> crate::wallet_ops::MintBalance { @@ -551,13 +853,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, true, &IssuerMints::none()).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, true, &balances, 100, None, &IssuerMints::none()); 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, true, &IssuerMints::none()).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 +873,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, true, &thin, 100, None, &IssuerMints::none()), 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, true, &elsewhere, 100, None, &IssuerMints::none()), 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, &[], true, &elsewhere, 100, None, &IssuerMints::none()), minibits); } // #266 guard: the widened balance read surfaces DB-discovered, unconfigured mints for DISPLAY, @@ -592,7 +894,7 @@ mod tests { discovered.configured = false; assert_eq!( - select_source_mint(default_mint, &accepted, true, &[discovered], 100), + select_source_mint(default_mint, &accepted, true, &[discovered], 100, None, &IssuerMints::none()), default_mint, "DB discovery widens display only; it must not change the sealed funding mint" ); @@ -606,12 +908,12 @@ mod tests { 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), + select_source_mint(DEFAULT_MINT_URL, &accepted, false, &balances, 100, None, &IssuerMints::none()), DEFAULT_MINT_URL, "fence off: a covered real mint is not admissible, fall back to the default" ); assert_eq!( - select_source_mint(DEFAULT_MINT_URL, &accepted, true, &balances, 100), + select_source_mint(DEFAULT_MINT_URL, &accepted, true, &balances, 100, None, &IssuerMints::none()), real, "fence on: the covered real mint is now selectable" ); @@ -627,11 +929,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()], true, &balances, 100, None, &IssuerMints::none()), 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()], true, &balances, 100, None, &IssuerMints::none()), b ); } @@ -646,12 +948,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, true, &[balance(cuba, 5_000)], 100, None, &IssuerMints::none()); 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, true, &IssuerMints::none()).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..fa73b0b0c 100644 --- a/crates/maxplayer-core/src/crossmint_hop.rs +++ b/crates/maxplayer-core/src/crossmint_hop.rs @@ -43,6 +43,7 @@ use cdk::wallet::Wallet; use crate::buyer_fund; use crate::crossmint::{HopCost, HopJournal}; use crate::home::MaxplayerHome; +use crate::mint_class::{ISSUER_HOP_REFUSAL, MintClass, class_from_info}; use crate::payment_wallet::{MINT_TOUCH_TIMEOUT, is_mint_unreachable}; /// What the source mint says about the melt leg. @@ -187,11 +188,27 @@ pub enum HopError { /// Amount the target mint actually issued. minted: u64, }, + /// One leg of the hop is an ISSUER mint (§4.2 "Issuer mint") — a mint whose info lists no + /// bolt11 method. It has no Lightning to melt to or mint from, so the hop refuses before either + /// leg is touched. Planning already refuses such a hop; this is the executor's own check, so + /// that no journal, no caller, and no stale plan can put a Lightning leg on an issuer mint. + IssuerMint { + /// Which leg: `source` or `target`. + leg: &'static str, + /// The issuer mint. + mint: String, + }, } impl fmt::Display for HopError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Self::IssuerMint { leg, mint } => write!( + formatter, + "cross-mint hop refused: {ISSUER_HOP_REFUSAL}. The {leg} mint {mint} is an issuer \ + mint with no Lightning; its tokens are only good for hiring the seat that issued \ + them, so nothing can be melted out of it or minted into it over Lightning" + ), Self::Journal(detail) => write!(formatter, "cross-mint hop journal: {detail}"), Self::Mint(detail) => write!(formatter, "cross-mint hop: {detail}"), Self::MintUnreachable { label, detail } => write!( @@ -296,6 +313,14 @@ impl std::error::Error for HopError {} /// that pays the melt and then dies before the mint reproduces the exact strand the journal exists to /// survive, which no amount of testing against a live mint pair could produce on demand. pub(crate) trait HopEffects { + /// The class of the (source, target) mints, from what each mint says about itself (its NUT-06 + /// info). Asked FIRST, before any leg: an issuer mint on either side refuses the hop with no + /// melt and no mint quote touched. Knowledge, not a reachability gate: a mint that does not + /// answer is UNKNOWN and reads as `Lightning` (the fail-safe `mint_class::probe_issuer_mints` + /// uses), so the leg that follows refuses it with the reachability label an operator already + /// knows. A fake answers `Lightning` for both unless a test says otherwise. + fn mint_classes(&mut self) -> Result<(MintClass, MintClass), HopError>; + /// Ask the SOURCE mint what became of the melt. Asking is also cdk's recovery trigger — a melt /// interrupted mid-saga resumes on this call rather than needing a separate sweep. fn melt_leg(&mut self, melt_quote_id: &str) -> Result; @@ -639,6 +664,23 @@ pub(crate) fn run_hop( }); } + // Class gate, before the Planned record and before either leg: a hop has no business on an + // issuer mint in either direction (§4.2 "Issuer mint"). The planner already refuses one; this + // is the executor refusing on its own evidence — what the mints say about themselves — so no + // caller, journal, or stale plan can put a Lightning leg on a mint that has none. + let (source_class, target_class) = effects.mint_classes()?; + for (leg, class, mint) in [ + ("source", source_class, &journal.source_mint), + ("target", target_class, &journal.target_mint), + ] { + if class == MintClass::Issuer { + return Err(HopError::IssuerMint { + leg, + mint: mint.clone(), + }); + } + } + let pairing = match planned_of(&records) { None => { store.append_sync(&HopRecord::Planned(journal.clone()))?; @@ -906,6 +948,18 @@ impl CdkHopEffects { .into(), )); } + // No quote is raised at an issuer mint, on either side (§4.2 "Issuer mint"). Asked of the + // mints themselves, before the first quote, so a plan that reached here by any route still + // cannot put a Lightning leg on a mint that has none. A mint that does not answer is + // unknown, not refused here: the quote below refuses it with its own reachability label. + for (leg, wallet) in [("source", &self.source), ("target", &self.target)] { + if Self::class_of(wallet).await == MintClass::Issuer { + return Err(HopError::IssuerMint { + leg, + mint: wallet.mint_url.to_string(), + }); + } + } let mint_quote = bounded( "target mint quote", MINT_TOUCH_TIMEOUT, @@ -1022,7 +1076,31 @@ fn mint_quote_id(id: &impl fmt::Display) -> String { id.to_string() } +impl CdkHopEffects { + /// The class of one leg's mint from its own NUT-06 info (the wallet's cached load, bounded). + /// + /// Knowledge, not a gate: a mint that does not answer within [`MINT_TOUCH_TIMEOUT`], or answers + /// malformed, is UNKNOWN and reads as `Lightning` — the same fail-safe as + /// [`crate::mint_class::probe_issuer_mints`]. Only a mint that ANSWERS "no bolt11" is an issuer + /// mint here; an unreachable one is refused by the quote that follows, under the reachability + /// label (`target mint quote`, …) an operator already knows how to read. + async fn class_of(wallet: &Wallet) -> MintClass { + match tokio::time::timeout(MINT_TOUCH_TIMEOUT, wallet.load_mint_info()).await { + Ok(Ok(info)) => class_from_info(&info), + Ok(Err(_)) | Err(_) => MintClass::Lightning, + } + } +} + impl HopEffects for CdkHopEffects { + fn mint_classes(&mut self) -> Result<(MintClass, MintClass), HopError> { + let source = self.source.clone(); + let target = self.target.clone(); + block_on_leg("mint classes", async move { + (Self::class_of(&source).await, Self::class_of(&target).await) + }) + } + fn melt_leg(&mut self, melt_quote_id: &str) -> Result { let wallet = self.source.clone(); let quote_id = melt_quote_id.to_owned(); @@ -1294,6 +1372,9 @@ mod tests { fail_all_melts: bool, /// Actual Lightning fee the melt effect reports as paid (#186 reconciliation input). melt_fee: u64, + /// What each mint says it is (§4.2 "Issuer mint"). Lightning unless a test says otherwise. + source_class: MintClass, + target_class: MintClass, /// Shared with the journal so a test can assert write-before-effect order. ops: Rc>>, } @@ -1315,6 +1396,12 @@ mod tests { } impl HopEffects for FakeMints { + fn mint_classes(&mut self) -> Result<(MintClass, MintClass), HopError> { + let world = self.world.borrow(); + world.ops.borrow_mut().push("classes".to_owned()); + Ok((world.source_class, world.target_class)) + } + fn melt_leg(&mut self, _melt_quote_id: &str) -> Result { self.world .borrow() @@ -1717,10 +1804,59 @@ mod tests { // The send that follows hands the seller exactly the offer amount, so a short issue is refused // here rather than carried into the send path. + /// NEGATIVE: an issuer mint on either leg refuses BEFORE anything is journalled or touched — no + /// Planned record, no melt, no mint quote. The executor asks the mints what they are and stops + /// there. Two Lightning mints on the same journal proceed exactly as before. + #[test] + fn an_issuer_mint_on_either_leg_refuses_before_any_leg_is_touched() { + for (leg, source_class, target_class) in [ + ("target", MintClass::Lightning, MintClass::Issuer), + ("source", MintClass::Issuer, MintClass::Lightning), + ("source", MintClass::Issuer, MintClass::Issuer), + ] { + let world = MintWorld::shared(); + world.borrow_mut().source_class = source_class; + world.borrow_mut().target_class = target_class; + let store = MemJournal::default(); + let mut effects = FakeMints { + world: Rc::clone(&world), + }; + let error = run_hop(&store, &mut effects, &journal("attempt-1")) + .expect_err("an issuer leg must refuse"); + match &error { + HopError::IssuerMint { leg: got, .. } => assert_eq!(*got, leg), + other => panic!("expected IssuerMint, got {other}"), + } + assert!(error.to_string().contains(ISSUER_HOP_REFUSAL), "{error}"); + let ops = world.borrow().ops.borrow().clone(); + assert_eq!( + ops, + vec!["classes".to_owned()], + "only the class question may have run ({leg} issuer): {ops:?}" + ); + assert!( + store.replay("attempt-1").expect("replays").is_empty(), + "no Planned record may be written for a refused hop" + ); + } + + let world = MintWorld::shared(); + let store = MemJournal::default(); + let mut effects = FakeMints { + world: Rc::clone(&world), + }; + let settled = + run_hop(&store, &mut effects, &journal("attempt-1")).expect("two Lightning mints hop"); + assert_eq!(settled.minted_sats, 100); + } + #[test] fn issuing_less_than_the_pinned_delivery_amount_refuses() { struct ShortMint; impl HopEffects for ShortMint { + fn mint_classes(&mut self) -> Result<(MintClass, MintClass), HopError> { + Ok((MintClass::Lightning, MintClass::Lightning)) + } fn melt_leg(&mut self, _: &str) -> Result { Ok(MeltLeg::Unpaid) } diff --git a/crates/maxplayer-core/src/home.rs b/crates/maxplayer-core/src/home.rs index c6d894754..3ecc5481e 100644 --- a/crates/maxplayer-core/src/home.rs +++ b/crates/maxplayer-core/src/home.rs @@ -1314,6 +1314,14 @@ pub struct MaxplayerConfig { /// fields with separate meanings and are never merged or repurposed for one another. #[serde(default = "default_accepted_mints")] pub accepted_mints: Vec, + /// The mint this seat itself RUNS and issues its own currency from (`docs/protocol-v1.md` §4.2 + /// "Issuer mint") — the stage-3 sidecar's URL. `None` ⇒ this seat issues no currency, which is + /// every seat today. When set: it is the one mint the real-mint fence admits without the + /// real-money switch (it carries no sats), the buyer side pays from it wherever a seller lists + /// it, and the seat's kind-30340 announcement carries it with live counters (stage 3). Nothing + /// reads it as a spendable Lightning balance — an issuer mint has no Lightning. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub issuer_mint: Option, /// Per-job spend cap (sats) — the standing spend bound on the money path. Absent ⇒ the built-in /// [`DEFAULT_PER_JOB_BUDGET_SATS`]. Issue #378 removed the rolling/total cap: the durable /// `spent.jsonl` ledger still records every spend (audit + retry-idempotency), but this per-job cap @@ -1496,6 +1504,15 @@ impl MaxplayerConfig { .map(String::as_str) .unwrap_or(DEFAULT_MINT_URL) } + + /// The seat's OWN issuer mint (§4.2 "Issuer mint"), or `None` when it runs none. Blank and + /// whitespace-only read as `None`: an empty URL is not a mint this seat runs. + pub fn issuer_mint(&self) -> Option<&str> { + self.issuer_mint + .as_deref() + .map(str::trim) + .filter(|url| !url.is_empty()) + } } impl Default for MaxplayerConfig { @@ -1503,6 +1520,7 @@ impl Default for MaxplayerConfig { Self { relay_url: DEFAULT_RELAY_URL.to_owned(), accepted_mints: default_accepted_mints(), + issuer_mint: None, per_job_budget_sats: DEFAULT_PER_JOB_BUDGET_SATS, extra_mints: Vec::new(), allow_real_mints: true, diff --git a/crates/maxplayer-core/src/job_lifecycle.rs b/crates/maxplayer-core/src/job_lifecycle.rs index 8c3f6fbd9..e1b764fcc 100644 --- a/crates/maxplayer-core/src/job_lifecycle.rs +++ b/crates/maxplayer-core/src/job_lifecycle.rs @@ -367,6 +367,16 @@ pub struct AcceptedBind { /// the buyer pay path chooses the realized mint from it. Empty for a claim with no `creq`. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub accepted_mints: Vec, + /// The ISSUER MINTS this accept knew of when it planned the payment (§4.2 "Issuer mint"), each + /// with WHO said it ([`crate::mint_class::IssuerMarker`]): the buyer's own (from config), every + /// entry of `accepted_mints` whose NUT-06 info listed no bolt11 method at accept time, and the + /// mint the seller DECLARED on its own kind-30340 announcement. SEALED so the pay path + /// re-derives the identical plan — the class-aware fence and the hop refusal read this list, + /// never a fresh probe or a fresh relay read, so a mint (or a seller) that changes its answer + /// later can neither shift nor unblock a sealed decision. Empty ⇒ none known (every bind written + /// before this field, and every trade among Lightning mints). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub issuer_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 @@ -1341,6 +1351,22 @@ pub async fn accept_claim_async( // fee); fall back to the configured default. Balances are a local sqlite read (no network). The // CHOICE is sealed below and re-derived at pay, so it stays deterministic — a later balance or // config-default change can never shift a sealed mint (the pays-once attempt-id invariant). + // + // Which of the seller's mints are ISSUER mints (§4.2 "Issuer mint") is learned HERE and sealed + // with the selection, marker by marker: any accepted mint whose info lists no bolt11 method (a + // bounded GET /v1/info per mint, no wallet, no money), the buyer's own from config, and the + // mint the seller DECLARED on its announcement (one bounded relay read; the seller's word + // refuses a hop but never widens the fence — `mint_class` docs). The class-aware fence and the + // hop refusal are then re-derived at pay from the seal — never from a fresh probe or read. + let declared_issuer_mint = + fetch_seller_issuer_mint_async(home, &keys, &claim.seller_pubkey, timeout).await; + let issuers = crate::mint_class::probe_issuer_mints( + &accepted_mints, + crate::payment_wallet::MINT_TOUCH_TIMEOUT, + ) + .await + .with_own(home.config.issuer_mint()) + .with_declared(declared_issuer_mint.as_deref()); let source_seed = match crate::wallet_ops::balances_async(home).await { Ok(balances) => crate::crossmint::select_source_mint( home.config.default_mint(), @@ -1348,6 +1374,8 @@ pub async fn accept_claim_async( home.config.allow_real_mints, &balances, offer.amount_sats, + home.config.issuer_mint(), + &issuers, ), // Best-effort: a balance-read failure falls back to today's behavior (the default mint) // rather than blocking an otherwise-plannable payment. Logged, never silent. @@ -1364,9 +1392,11 @@ pub async fn accept_claim_async( &source_seed, &accepted_mints, home.config.allow_real_mints, + &issuers, ) .map_err(|error| JobLifecycleError::Input(error.to_string()))?; let (funding_mint, delivery_mint) = seal_bind_mints(&plan); + let issuer_mints = issuers.seal(); let buyer_pubkey = keys.public_key().to_hex(); // ACCEPT is its own kind: this is the pay-bind, not the selection — `prepare_award_async` owns @@ -1405,6 +1435,9 @@ pub async fn accept_claim_async( creq_hash: claim.creq.as_deref().map(crate::gateway::creq_hash_hex), // The creq's accepted-mint list (validated + parsed above, fail-closed). accepted_mints, + // The issuer mints known when the plan above was made — sealed so pay re-derives, not + // re-probes. + issuer_mints, // The FUNDING (source) mint SELECTED for this job, frozen above. Sealing the choice makes the // pay-path attempt id stable across retries. On a hop this is the source, NOT the delivery mint. funding_mint: Some(funding_mint), @@ -1843,6 +1876,9 @@ pub fn authorize_request_from_bind( // Thread the creq's accepted-mint list so the buyer chooses the realized // mint. Empty ⇒ a claim with no creq (pay from the pinned default mint). accepted_mints: bind.accepted_mints.clone(), + // Thread the SEALED issuer-mint knowledge so the class-aware fence and the hop refusal are + // re-derived from the bind, never re-probed at pay. + issuer_mints: bind.issuer_mints.clone(), // Thread the SEALED funding-mint selection so the pay path derives the paying mint from the // bind, not the live config default — stable attempt id across retries. `None` ⇒ legacy bind. realized_mint: bind.funding_mint.clone(), @@ -1902,6 +1938,9 @@ pub fn fill_explicit_request_from_bind( // caller-supplied value. The seller cosig does not pin the realized mint (the preimage binds // only creq_hash), so a caller list must never be trusted to select the paying mint. request.accepted_mints = bind.accepted_mints.clone(); + // Same seal for the issuer-mint knowledge: a caller must not be able to widen the fence or + // reclassify a hop leg by naming a mint an issuer. + request.issuer_mints = bind.issuer_mints.clone(); // Finding CC: same seal for the funding-mint SELECTION — derived SOLELY from the sealed bind, // overwriting any caller value. A caller must not be able to pick the paying mint (which would // shift the attempt id); the frozen selection makes retries dedup. @@ -2876,6 +2915,74 @@ fn select_result<'a>( }) } +/// Read the mint a seller DECLARES its own issuer mint on its kind-30340 announcement (§4.2 +/// "Issuer mint"), or `None` when it declares none. +/// +/// Resolved by `(author, kind, d)` — never by event id — because the announcement is addressable +/// and superseded in place ([`crate::heartbeat::ParsedHeartbeat::key`]). The newest stored beat is +/// the seat's current statement; the stage-1 reader rule (URL must be in the seat's own +/// `accepted_mints`, else unstated) is applied by [`crate::heartbeat::parse_heartbeat`]. +/// +/// Best-effort and FAIL-SAFE toward the behaviour that existed before the class did: a relay that +/// cannot be reached or refuses the read, a seat with no announcement, a beat this reader cannot +/// parse, and a beat with no tag all read as "declared none". Never an error — the tag is optional +/// (§2.1) and an unreadable optional tag must not block an accept — and never a widening: a +/// declaration only ever makes the planner REFUSE a hop it would otherwise plan. Logged when the +/// read itself fails, so a silent relay is a visible fact rather than a quiet `None`. +pub(crate) async fn fetch_seller_issuer_mint_async( + home: &MaxplayerHome, + keys: &nostr_sdk::Keys, + seller_pubkey: &str, + timeout: Duration, +) -> Option { + use nostr_sdk::pool::relay::ReqExitPolicy; + use nostr_sdk::prelude::{Client, Filter, Kind, PublicKey}; + + let seller = PublicKey::from_hex(seller_pubkey).ok()?; + let client = Client::new(keys.clone()); + client.automatic_authentication(true); + if let Err(error) = client.add_relay(&home.config.relay_url).await { + crate::opline!("accept: seller announcement read skipped (add relay: {error})"); + return None; + } + client.connect().await; + let read = async { + let relay = client.relay(&home.config.relay_url).await.ok()?; + relay.wait_for_connection(RELAY_CONNECT_WAIT).await; + let filter = Filter::new() + .author(seller) + .kind(Kind::Custom(crate::heartbeat::SELLER_HEARTBEAT_KIND)) + .identifier(crate::heartbeat::SELLER_HEARTBEAT_D) + .hashtag(gateway::MAXPLAYER_TAG) + .limit(1); + // Single-relay read, `ExitOnEOSE`: a refused REQ surfaces as `Err` (logged below) instead + // of being swallowed into an empty set by the pool — same discipline as the job view. + match relay + .fetch_events(filter, timeout, ReqExitPolicy::ExitOnEOSE) + .await + { + Ok(events) => Some(events), + Err(error) => { + crate::opline!( + "accept: seller announcement read failed for {seller_pubkey} ({error}); \ + treating the seller as declaring no issuer mint" + ); + None + } + } + } + .await; + client.disconnect().await; + let events = read?; + // The newest beat is the seat's current word; a relay serving more than one (replaceable + // events should not, but a reader must not depend on that) is resolved the way NIP-01 does. + let newest = events.into_iter().max_by_key(|event| event.created_at)?; + crate::heartbeat::parse_heartbeat(&event_to_draft(&newest)) + .ok()? + .issuer_mint + .map(|ad| ad.mint_url) +} + /// Convert a relay event into an [`EventDraft`] (tag/content only — no secrets). pub fn event_to_draft(event: &nostr_sdk::Event) -> EventDraft { let tags = event @@ -2951,6 +3058,7 @@ fn result_attribution(tags: &[TagSpec]) -> (Option, Option) { mod tests { use super::*; use crate::home; + use crate::mint_class::IssuerMints; // #602: offer-ABSENCE is certified from the offer read ALONE. The bug was // `read_confirmed = offer || feedback || result || probe`, which let a non-empty claims (or @@ -3032,6 +3140,7 @@ mod tests { seller_signature: "ab".repeat(32), creq_hash: None, accepted_mints: Vec::new(), + issuer_mints: Vec::new(), funding_mint: None, delivery_mint: None, agent_used: None, @@ -3071,6 +3180,7 @@ mod tests { seller_signature: "ab".repeat(32), creq_hash: None, accepted_mints: Vec::new(), + issuer_mints: Vec::new(), funding_mint: None, delivery_mint: None, agent_used: None, @@ -3159,6 +3269,7 @@ mod tests { seller_signature: valid_sig.clone(), creq_hash: None, accepted_mints: Vec::new(), + issuer_mints: Vec::new(), funding_mint: None, delivery_mint: None, agent_used: None, @@ -3211,6 +3322,7 @@ mod tests { seller_signature: "dd".repeat(64), creq_hash: Some("2ad9b34cbf8c".to_string()), accepted_mints: vec!["https://mint.minibits.cash/Bitcoin".into()], + issuer_mints: Vec::new(), funding_mint: None, delivery_mint: None, agent_used: None, @@ -3238,6 +3350,7 @@ mod tests { seller_signature: String::new(), creq_hash: None, accepted_mints: Vec::new(), + issuer_mints: Vec::new(), realized_mint: None, contribution: None, }; @@ -3269,6 +3382,7 @@ mod tests { seller_signature: "dd".repeat(64), creq_hash: Some("2ad9b34cbf8c".to_string()), accepted_mints: vec!["https://mint.minibits.cash/Bitcoin".into()], + issuer_mints: Vec::new(), funding_mint: None, delivery_mint: None, agent_used: None, @@ -3290,6 +3404,7 @@ mod tests { seller_signature: String::new(), creq_hash: None, accepted_mints: Vec::new(), + issuer_mints: Vec::new(), realized_mint: None, contribution: None, }; @@ -3320,6 +3435,7 @@ mod tests { seller_signature: "dd".repeat(64), creq_hash: Some("2ad9b34c".repeat(8)), accepted_mints: vec!["https://mint.minibits.cash/Bitcoin".into()], + issuer_mints: Vec::new(), funding_mint: None, delivery_mint: None, agent_used: None, @@ -3344,6 +3460,7 @@ mod tests { seller_signature: bind.seller_signature.clone(), creq_hash: bind.creq_hash.clone(), accepted_mints: bind.accepted_mints.clone(), + issuer_mints: Vec::new(), realized_mint: None, contribution: None, }; @@ -3383,6 +3500,7 @@ mod tests { accepted_mints: vec![bound_mint.clone()], // The funding-mint SELECTION is sealed in the bind (finding CC). Buyer funds at a mint in // the accepted set ⇒ direct payment ⇒ delivery mint equals the funding mint. + issuer_mints: Vec::new(), funding_mint: Some(bound_mint.clone()), delivery_mint: Some(bound_mint.clone()), agent_used: None, @@ -3405,6 +3523,7 @@ mod tests { // Caller substitutes a mint OUTSIDE the bound set — for BOTH the accepted set and the // realized-mint selection. accepted_mints: vec![attacker_mint.clone()], + issuer_mints: Vec::new(), realized_mint: Some(attacker_mint.clone()), contribution: None, }; @@ -3489,6 +3608,7 @@ mod tests { accepted_mints: vec![mint_a.to_string(), mint_b.to_string()], // Funding sealed at accept from the buyer's then-configured default (A); A is in the // accepted set ⇒ direct payment ⇒ delivery equals funding. + issuer_mints: Vec::new(), funding_mint: Some(mint_a.to_string()), delivery_mint: Some(mint_a.to_string()), agent_used: None, @@ -3512,7 +3632,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, true, &IssuerMints::none()) .expect("plan payment") .realized_mint() .clone(); @@ -3556,7 +3676,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(&home, &retry_terms, &IssuerMints::none()), ) .await .expect("open pay wallet"); @@ -3601,6 +3721,23 @@ mod tests { seller_signature: "ab".repeat(32), creq_hash: None, accepted_mints: Vec::new(), + // One sealed issuer mint per marker (§4.2 "Issuer mint"), so the round-trip covers the + // marker, not just the URL — a seal that came back as bare URLs would let a seller's + // declaration widen the fence at pay. + issuer_mints: vec![ + crate::mint_class::IssuerMintSeal { + url: "http://127.0.0.1:3338".into(), + marker: crate::mint_class::IssuerMarker::Own, + }, + crate::mint_class::IssuerMintSeal { + url: "https://info.example".into(), + marker: crate::mint_class::IssuerMarker::Info, + }, + crate::mint_class::IssuerMintSeal { + url: "https://declared.example".into(), + marker: crate::mint_class::IssuerMarker::Declared, + }, + ], // Distinct funding/delivery — a cross-mint bind — so the round-trip covers BOTH fields // (#495), not just their absence, and pins that the `realized_mint` alias does not clobber // the funding write on the way back out. @@ -3635,6 +3772,9 @@ mod tests { assert_eq!(bind.funding_mint, None, "missing field defaults to None (legacy)"); assert_eq!(bind.delivery_mint, None, "missing field defaults to None (legacy)"); assert_eq!(bind.accepted_mints, vec!["https://mint.example".to_string()]); + // §4.2 "Issuer mint": a bind written before the seal existed knows no issuer mint, so the + // pay path fences and hop-plans it exactly as it did then. + assert!(bind.issuer_mints.is_empty(), "missing seal defaults to none known (legacy)"); // #495 rename alias: a bind written BEFORE the rename carries a `realized_mint` key holding the // funding selection. The `alias = "realized_mint"` must load it into `funding_mint` (same @@ -3695,7 +3835,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()], true, &IssuerMints::none()) .expect("cross-mint plan"); assert!(plan.is_hop(), "distinct source/target must plan a hop"); let (funding, delivery) = seal_bind_mints(&plan); @@ -3705,7 +3845,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()], true, &IssuerMints::none()) .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); @@ -3806,6 +3946,7 @@ mod tests { seller_signature: "ab".repeat(32), creq_hash: None, accepted_mints: Vec::new(), + issuer_mints: Vec::new(), funding_mint: None, delivery_mint: None, agent_used: None, @@ -3875,6 +4016,7 @@ mod tests { seller_signature: "ab".repeat(32), creq_hash: None, accepted_mints: Vec::new(), + issuer_mints: Vec::new(), funding_mint: None, delivery_mint: None, agent_used: None, @@ -3965,6 +4107,7 @@ mod tests { seller_signature: "ab".repeat(32), creq_hash: None, accepted_mints: Vec::new(), + issuer_mints: Vec::new(), funding_mint: None, delivery_mint: None, agent_used: None, @@ -4010,6 +4153,7 @@ mod tests { seller_signature: "ab".repeat(32), creq_hash: None, accepted_mints: Vec::new(), + issuer_mints: Vec::new(), funding_mint: None, delivery_mint: None, agent_used: None, @@ -4051,6 +4195,7 @@ mod tests { seller_signature: "ab".repeat(32), creq_hash: None, accepted_mints: Vec::new(), + issuer_mints: Vec::new(), funding_mint: None, delivery_mint: None, agent_used: None, @@ -4530,6 +4675,96 @@ mod tests { let _ = std::fs::remove_dir_all(&root); } + /// §4.2 "Issuer mint": the buyer reads a seller's declaration off the seller's OWN kind-30340 + /// announcement — resolved by (author, kind, d) — through a real relay round trip. Three seats + /// on one relay: one declares its mint, one declares none, one declares a mint it does not list + /// in its own `accepted_mints` (the stage-1 reader rule reads that as unstated). Only the first + /// yields a URL; the buyer is a separate identity, as in production. A seat with no beat at all + /// is the fourth row. + /// + /// ⛔ A bare `LocalRelay`, deliberately NOT the `post_job_async` fixture — that path resolves a + /// fee floor at the home's real mint under `live-mints`; this one touches no mint at all. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_sellers_issuer_mint_declaration_is_read_off_its_own_announcement() { + use crate::heartbeat::{HeartbeatDraft, IssuerMintAd}; + use nostr_relay_builder::prelude::{LocalRelay, RelayBuilder}; + use nostr_sdk::prelude::{Client, Keys}; + + let relay = LocalRelay::new(RelayBuilder::default()); + relay.run().await.expect("relay run"); + let (root, mut home) = temp_job_home("read-declared-issuer"); + home.config.relay_url = relay.url().await.to_string(); + + let sidecar = "http://10.0.0.7:3338"; + let lightning = "https://testnut.example/Bitcoin"; + let ad = |url: &str| IssuerMintAd { + mint_url: url.to_owned(), + outstanding_sats: 0, + retired_sats: 0, + last_seen: 1_788_390_000, + }; + let declaring = Keys::generate(); + let silent = Keys::generate(); + let unlisted = Keys::generate(); + let absent = Keys::generate(); + let beats = [ + ( + &declaring, + HeartbeatDraft::new(true, 0, 5, vec![lightning.to_owned(), sidecar.to_owned()]) + .with_issuer_mint(ad(sidecar)), + ), + (&silent, HeartbeatDraft::new(true, 0, 5, vec![lightning.to_owned()])), + ( + &unlisted, + HeartbeatDraft::new(true, 0, 5, vec![lightning.to_owned()]) + .with_issuer_mint(ad(sidecar)), + ), + ]; + for (seat, beat) in beats { + let client = Client::new(seat.clone()); + client.add_relay(&home.config.relay_url).await.expect("add relay"); + client.connect().await; + tokio::time::sleep(Duration::from_millis(200)).await; + let event = crate::gateway::nostr::event_builder(&beat.to_event_draft()) + .expect("event builder") + .sign_with_keys(seat) + .expect("sign"); + client.send_event(&event).await.expect("publish the announcement"); + client.disconnect().await; + } + + let buyer = buyer_keys(&home).expect("buyer keys"); + let timeout = Duration::from_secs(5); + let read = |seat: &Keys| { + let seller_hex = seat.public_key().to_hex(); + let (home, buyer) = (&home, &buyer); + async move { fetch_seller_issuer_mint_async(home, buyer, &seller_hex, timeout).await } + }; + assert_eq!( + read(&declaring).await.as_deref(), + Some(sidecar), + "the declared, listed mint is read off the wire" + ); + assert_eq!(read(&silent).await, None, "a seat that states none declares none"); + assert_eq!( + read(&unlisted).await, + None, + "a declared mint the seat does not list is unstated (stage-1 reader rule)" + ); + assert_eq!(read(&absent).await, None, "no announcement at all is none"); + assert_eq!( + fetch_seller_issuer_mint_async(&home, &buyer, "not-a-pubkey", timeout).await, + None, + "an unparseable seller key is none, never a panic" + ); + + // What the seal makes of it: the declaration refuses, and admits nothing. + let issuers = crate::mint_class::IssuerMints::none() + .with_declared(read(&declaring).await.as_deref()); + assert!(issuers.contains(sidecar) && !issuers.admits(sidecar)); + let _ = std::fs::remove_dir_all(&root); + } + fn temp_job_home(label: &str) -> (std::path::PathBuf, crate::home::MaxplayerHome) { let root = std::env::temp_dir().join(format!( "maxplayer-jobs-{label}-{}-{}", @@ -5072,6 +5307,7 @@ mod tests { seller_signature: "ab".repeat(32), creq_hash: None, accepted_mints: Vec::new(), + issuer_mints: Vec::new(), funding_mint: None, delivery_mint: None, agent_used: None, diff --git a/crates/maxplayer-core/src/lib.rs b/crates/maxplayer-core/src/lib.rs index bc49e55db..834cd7c13 100644 --- a/crates/maxplayer-core/src/lib.rs +++ b/crates/maxplayer-core/src/lib.rs @@ -20,6 +20,8 @@ pub mod contribution; pub mod crossmint; #[cfg(all(feature = "wallet", feature = "gateway"))] pub mod crossmint_hop; +#[cfg(all(feature = "wallet", feature = "gateway"))] +pub mod mint_class; pub mod delivery; pub mod delivery_sentinel; #[cfg(feature = "git-delivery")] diff --git a/crates/maxplayer-core/src/mint_class.rs b/crates/maxplayer-core/src/mint_class.rs new file mode 100644 index 000000000..ff4d85d9e --- /dev/null +++ b/crates/maxplayer-core/src/mint_class.rs @@ -0,0 +1,562 @@ +//! The mint CLASS a buyer's wallet reasons about before it moves anything: Lightning-backed, or an +//! ISSUER mint — a seat's own Cashu mint whose tokens are an IOU for that seat's future work +//! (`docs/protocol-v1.md` §4.2 "Issuer mint"). +//! +//! An issuer mint has NO Lightning. Nothing enters it from outside and nothing leaves it: its tokens +//! are minted by the issuer on its own authority and are good for exactly one thing, hiring the +//! issuer. Two facts follow, and every rule in this module is one of them spelled out: +//! +//! - **The real-mint fence admits it.** The fence exists to stop REAL sats moving without an +//! operator's opt-in. An issuer mint carries no sats, so it passes whatever `allow_real_mints` +//! says, and whatever scheme its URL has (a sidecar on `http://127.0.0.1` is the normal case). +//! - **The Lightning hop refuses it, in both directions.** A hop melts at a source mint to pay an +//! invoice a target mint raised. An issuer mint can neither pay nor be paid over Lightning, so a +//! hop INTO one cannot land and a hop OUT of one cannot leave. The plain reason a buyer reads is +//! [`ISSUER_HOP_REFUSAL`]: it holds none of this seller's currency, and Lightning cannot buy any. +//! +//! How a mint is KNOWN to be an issuer mint — the two markers the design names, and nothing else. +//! Each is recorded with WHO said it ([`IssuerMarker`]), because the two rules above do not trust +//! the two markers equally: +//! +//! 1. **The ad tag.** A seat that runs one advertises it on its own kind-30340 announcement +//! ([`crate::heartbeat::ISSUER_MINT_TAG`]). Read two ways: +//! - The seat's OWN mint comes from config ([`crate::home::MaxplayerConfig::issuer_mint`]) — +//! the source the tag is published from — and is [`IssuerMarker::Own`]. The operator stated +//! it; it admits and it refuses. +//! - A SELLER's declaration is read off the seller's announcement at accept and is +//! [`IssuerMarker::Declared`]. It REFUSES the hop (the seller's word can only make the buyer +//! more careful) but does NOT widen the fence: a seller's signed tag must not be able to open +//! the buyer's real-mint fence to any mint the seller cares to name — a real mint the buyer +//! holds sats at, declared "issuer" by a stranger, would otherwise become spendable with the +//! real-money switch off. +//! 2. **The mint's own info.** A mint whose NUT-06 document lists no `bolt11` method under NUT-04 +//! (mint) or NUT-05 (melt) has no Lightning. [`class_from_info`] is that test, recorded as +//! [`IssuerMarker::Info`]: the mint itself says it holds no Lightning route, so it admits and it +//! refuses. It is how a buyer classifies a seller's mint at accept, where the classification is +//! sealed into the bind so the pay path re-derives rather than re-decides. +//! +//! Absence of every marker is UNKNOWN, and unknown reads as Lightning: the fence and the hop then +//! behave exactly as they did before this class existed. + +use std::collections::BTreeMap; +use std::str::FromStr; +use std::time::Duration; + +use cdk::mint_url::MintUrl; +use cdk::nuts::{MintInfo, PaymentMethod}; +use serde::{Deserialize, Serialize}; + +use crate::home; + +/// The reason a buyer reads when a Lightning hop would have to enter or leave an issuer mint. +pub const ISSUER_HOP_REFUSAL: &str = "you hold none of this seller's currency"; + +/// Which kind of mint a URL names, as far as the wallet can tell. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum MintClass { + /// A mint reachable over Lightning — every mint the wallet knew before issuer mints existed, + /// and every mint it knows nothing about. The default, because unknown reads as Lightning. + #[default] + Lightning, + /// A seat's own mint: no Lightning in, none out; its tokens buy that seat's work and nothing + /// else. + Issuer, +} + +/// WHO said a mint is an issuer mint. Every marker refuses the Lightning hop; only the markers +/// this seat can stand behind widen its real-mint fence (see the module docs). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum IssuerMarker { + /// Another seat's kind-30340 `issuer_mint` tag, read at accept. Refuses; does not admit. + Declared, + /// The mint's own NUT-06 info lists no bolt11 method. Refuses and admits. + Info, + /// This seat's own issuer mint, from its config. Refuses and admits. + Own, +} + +impl IssuerMarker { + /// Whether this marker is one the seat may widen its OWN real-mint fence on. + fn admits(self) -> bool { + matches!(self, Self::Info | Self::Own) + } +} + +/// One sealed issuer-mint fact: a normalized URL and who said it. The unit the accept-bind stores +/// so the pay path re-derives the identical fence and hop decisions. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct IssuerMintSeal { + pub url: String, + pub marker: IssuerMarker, +} + +/// Classify a mint from its NUT-06 info: `Issuer` iff neither NUT-04 (mint) nor NUT-05 (melt) +/// lists a `bolt11` method. +/// +/// Both tables are consulted because a hop needs Lightning on BOTH sides of a mint — a mint that +/// could be paid by Lightning but not pay out (or the reverse) is not a hop leg either, but that +/// is a different defect; the class here is "has no Lightning at all", which is what an issuer +/// mint run with no Lightning backend reports. +pub fn class_from_info(info: &MintInfo) -> MintClass { + let mints_bolt11 = info + .nuts + .nut04 + .methods + .iter() + .any(|method| method.method == PaymentMethod::BOLT11); + let melts_bolt11 = info + .nuts + .nut05 + .methods + .iter() + .any(|method| method.method == PaymentMethod::BOLT11); + if mints_bolt11 || melts_bolt11 { + MintClass::Lightning + } else { + MintClass::Issuer + } +} + +/// The issuer mints known for ONE payment decision, each with its marker. Built once, passed by +/// reference into [`crate::crossmint::plan_payment`] and [`crate::crossmint::select_source_mint`], +/// and sealed into the accept-bind so the pay path re-derives the identical decision. +/// +/// URLs are normalized through [`MintUrl`] (trailing slash, case) so `contains` agrees with the +/// comparisons the planner makes. An entry that does not parse as a mint URL is dropped: it could +/// never match a planned mint, and keeping it would only let a malformed config line masquerade +/// as knowledge. When two markers name one URL the stronger stands: an admitting marker is never +/// downgraded by a declaration. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct IssuerMints { + marks: BTreeMap, +} + +/// [`IssuerMints::none`] with a `'static` address, for a borrower that outlives any local — the +/// award filters are `Copy` and hold a reference. +pub static NO_ISSUER_MINTS: IssuerMints = IssuerMints { + marks: BTreeMap::new(), +}; + +impl IssuerMints { + /// No issuer mint is known. Every decision then runs exactly as it did before the class + /// existed. + pub fn none() -> Self { + Self::default() + } + + /// Issuer mints classified from their own info ([`IssuerMarker::Info`]) — a probe's result. + pub fn from_urls(urls: I) -> Self + where + I: IntoIterator, + S: AsRef, + { + let mut known = Self::default(); + for url in urls { + known.insert(url.as_ref(), IssuerMarker::Info); + } + known + } + + /// Rebuild from a sealed bind's list, marker by marker. The pay path's ONLY constructor. + pub fn from_seal(seal: &[IssuerMintSeal]) -> Self { + let mut known = Self::default(); + for entry in seal { + known.insert(&entry.url, entry.marker); + } + known + } + + /// Add the seat's OWN issuer mint ([`IssuerMarker::Own`]), when it has one. `None` adds nothing. + pub fn with_own(mut self, own_mint: Option<&str>) -> Self { + if let Some(own) = own_mint { + self.insert(own, IssuerMarker::Own); + } + self + } + + /// Add a mint another seat DECLARED its issuer mint on its announcement + /// ([`IssuerMarker::Declared`]), when it stated one. `None` adds nothing. + pub fn with_declared(mut self, declared: Option<&str>) -> Self { + if let Some(url) = declared { + self.insert(url, IssuerMarker::Declared); + } + self + } + + /// Add one issuer mint under `marker`. Silently ignores a URL that does not parse (see the type + /// docs); never downgrades an admitting marker to a declaration. + pub fn insert(&mut self, url: &str, marker: IssuerMarker) { + if let Ok(parsed) = MintUrl::from_str(url) { + let slot = self.marks.entry(parsed.to_string()).or_insert(marker); + if marker > *slot { + *slot = marker; + } + } + } + + /// Whether `url` names a known issuer mint under ANY marker — the hop-refusal question. An + /// unparseable `url` is never one. + pub fn contains(&self, url: &str) -> bool { + self.marker_of(url).is_some() + } + + /// Whether `url` names an issuer mint this seat may widen its real-mint fence on — the fence + /// question. A declared-only mint answers `false`: it refuses hops but is fenced as before. + pub fn admits(&self, url: &str) -> bool { + self.marker_of(url).is_some_and(IssuerMarker::admits) + } + + /// The marker recorded for `url`, if any. + pub fn marker_of(&self, url: &str) -> Option { + MintUrl::from_str(url) + .ok() + .and_then(|parsed| self.marks.get(&parsed.to_string()).copied()) + } + + /// The class of `url` given what is known: `Issuer` when listed under any marker, `Lightning` + /// otherwise. + pub fn class_of(&self, url: &str) -> MintClass { + if self.contains(url) { + MintClass::Issuer + } else { + MintClass::Lightning + } + } + + /// Nothing known. + pub fn is_empty(&self) -> bool { + self.marks.is_empty() + } + + /// The known issuer mints, normalized, in a stable order, every marker. + pub fn urls(&self) -> Vec { + self.marks.keys().cloned().collect() + } + + /// What gets sealed into a bind: every known mint with its marker, in a stable order. + pub fn seal(&self) -> Vec { + self.marks + .iter() + .map(|(url, marker)| IssuerMintSeal { + url: url.clone(), + marker: *marker, + }) + .collect() + } +} + +/// The real-mint fence, class-aware: an issuer mint under an ADMITTING marker passes +/// unconditionally; every other mint answers to [`home::mint_allowed`] exactly as before. +/// +/// This is the ONE place the class widens the fence, and it widens it only for a mint the seat +/// itself can stand behind — its own (from config) or one the mint's own info classified (sealed at +/// accept). A mint a seller merely declared, and a mint nobody classified, are fenced as they +/// always were. +pub fn mint_admitted(mint_url: &str, allow_real_mints: bool, issuers: &IssuerMints) -> bool { + issuers.admits(mint_url) || home::mint_allowed(mint_url, allow_real_mints) +} + +/// Refuse a Lightning operation (`op`) that would run against an issuer mint. `Ok(())` for a +/// Lightning mint. The one sentence every such refusal carries, so an operator reading a `wallet +/// fund`, a `wallet melt`, or a hop refusal sees the same reason. +pub fn refuse_lightning_at_issuer( + op: &str, + mint_url: &str, + class: MintClass, +) -> Result<(), String> { + match class { + MintClass::Lightning => Ok(()), + MintClass::Issuer => Err(format!( + "{op} refused: {mint_url} is an issuer mint with no Lightning — its tokens are only good \ + for hiring the seat that issued them; {ISSUER_HOP_REFUSAL}" + )), + } +} + +/// Ask each of `mint_urls` for its NUT-06 info and return those that classify as issuer mints +/// ([`IssuerMarker::Info`]). +/// +/// Best-effort and FAIL-SAFE in the direction that matters: a mint that does not answer within +/// `timeout`, answers malformed, or does not parse as a URL is NOT classified — it stays Lightning +/// class (unknown), so it is fenced and hop-planned exactly as before. Nothing here moves money or +/// opens a wallet; it is the same GET `/v1/info` the doctor's reachability probe makes. +/// +/// Called at accept, where the result is sealed into the bind. It is never called on the pay path: +/// the pay path re-derives from the seal, so a mint that changes its answer later cannot shift a +/// sealed decision. +pub async fn probe_issuer_mints(mint_urls: &[String], timeout: Duration) -> IssuerMints { + use cdk::wallet::{HttpClient, MintConnector}; + + let mut known = IssuerMints::none(); + for raw in mint_urls { + let Ok(url) = MintUrl::from_str(raw.trim()) else { + continue; + }; + let client = HttpClient::new(url.clone(), None); + let info = match tokio::time::timeout(timeout, client.get_mint_info()).await { + Ok(Ok(info)) => info, + // Unreachable or malformed: unknown, therefore Lightning. Never a refusal here — the + // fence and the hop executor are the gates; this is only knowledge. + Ok(Err(_)) | Err(_) => continue, + }; + if class_from_info(&info) == MintClass::Issuer { + known.insert(&url.to_string(), IssuerMarker::Info); + } + } + known +} + +#[cfg(test)] +mod tests { + use super::*; + use cdk::nuts::{CurrencyUnit, MeltMethodSettings, MintMethodSettings}; + + fn bolt11_mint_method() -> MintMethodSettings { + MintMethodSettings { + method: PaymentMethod::BOLT11, + unit: CurrencyUnit::Sat, + min_amount: None, + max_amount: None, + options: None, + } + } + + fn bolt11_melt_method() -> MeltMethodSettings { + MeltMethodSettings { + method: PaymentMethod::BOLT11, + unit: CurrencyUnit::Sat, + min_amount: None, + max_amount: None, + options: None, + } + } + + fn custom_melt_method(name: &str) -> MeltMethodSettings { + MeltMethodSettings { + method: PaymentMethod::Custom(name.to_owned()), + unit: CurrencyUnit::Sat, + min_amount: None, + max_amount: None, + options: None, + } + } + + /// A stock Lightning mint lists bolt11 under both NUT-04 and NUT-05. + fn lightning_info() -> MintInfo { + let mut info = MintInfo::new(); + info.nuts.nut04.methods = vec![bolt11_mint_method()]; + info.nuts.nut05.methods = vec![bolt11_melt_method()]; + info + } + + /// An issuer mint run with no Lightning backend: no bolt11 anywhere. It may still list a + /// custom melt method (the stage-3 "retire" path) — that is not Lightning. + fn issuer_info() -> MintInfo { + let mut info = MintInfo::new(); + info.nuts.nut04.methods = Vec::new(); + info.nuts.nut05.methods = vec![custom_melt_method("retire")]; + info + } + + #[test] + fn a_mint_listing_no_bolt11_method_is_an_issuer_mint() { + assert_eq!(class_from_info(&issuer_info()), MintClass::Issuer); + assert_eq!(class_from_info(&MintInfo::new()), MintClass::Issuer); + assert_eq!(class_from_info(&lightning_info()), MintClass::Lightning); + } + + /// Lightning on EITHER side is enough to be Lightning class: the class is "no Lightning at + /// all", not "cannot serve as a hop leg". + #[test] + fn bolt11_on_either_table_reads_as_lightning() { + let mut mint_only = MintInfo::new(); + mint_only.nuts.nut04.methods = vec![bolt11_mint_method()]; + assert_eq!(class_from_info(&mint_only), MintClass::Lightning); + + let mut melt_only = MintInfo::new(); + melt_only.nuts.nut05.methods = vec![bolt11_melt_method()]; + assert_eq!(class_from_info(&melt_only), MintClass::Lightning); + } + + #[test] + fn issuer_mints_normalize_and_compare_like_the_planner() { + let known = IssuerMints::from_urls(["https://Issuer.example/Bitcoin/"]); + assert!(known.contains("https://issuer.example/Bitcoin")); + assert!(known.contains("https://issuer.example/Bitcoin/")); + assert!(!known.contains("https://other.example/Bitcoin")); + assert!(!known.contains("not a url")); + assert_eq!( + known.class_of("https://issuer.example/Bitcoin"), + MintClass::Issuer + ); + assert_eq!( + known.class_of("https://other.example/Bitcoin"), + MintClass::Lightning + ); + assert_eq!( + known.urls(), + vec!["https://issuer.example/Bitcoin".to_owned()] + ); + } + + #[test] + fn own_mint_is_added_only_when_stated_and_a_bad_url_is_dropped() { + assert!(IssuerMints::none().with_own(None).is_empty()); + assert!(IssuerMints::none().with_own(Some("")).is_empty()); + assert!( + IssuerMints::none() + .with_own(Some("::not-a-url::")) + .is_empty() + ); + assert!( + IssuerMints::none() + .with_declared(Some("::not-a-url::")) + .is_empty() + ); + let own = IssuerMints::none().with_own(Some("http://127.0.0.1:3338")); + assert!(own.contains("http://127.0.0.1:3338/")); + assert_eq!( + own.marker_of("http://127.0.0.1:3338/"), + Some(IssuerMarker::Own) + ); + } + + /// The three markers all REFUSE (every one is an issuer mint to the hop), but only the two the + /// seat can stand behind — its own config, the mint's own info — ADMIT. A seller's declaration + /// is knowledge for the hop and nothing for the fence. + #[test] + fn every_marker_refuses_but_only_own_and_info_admit() { + let declared = IssuerMints::none().with_declared(Some("https://issuer.example")); + assert!(declared.contains("https://issuer.example")); + assert!(!declared.admits("https://issuer.example")); + assert_eq!( + declared.marker_of("https://issuer.example"), + Some(IssuerMarker::Declared) + ); + + let info = IssuerMints::from_urls(["https://issuer.example"]); + assert!(info.contains("https://issuer.example") && info.admits("https://issuer.example")); + + let own = IssuerMints::none().with_own(Some("https://issuer.example")); + assert!(own.contains("https://issuer.example") && own.admits("https://issuer.example")); + + // Unknown: neither. + assert!(!IssuerMints::none().contains("https://issuer.example")); + assert!(!IssuerMints::none().admits("https://issuer.example")); + } + + /// One URL, two markers: the admitting one stands whichever order they arrive in. A seller's + /// declaration can never downgrade what the seat itself knows. + #[test] + fn a_declaration_never_downgrades_an_admitting_marker() { + let info_then_declared = IssuerMints::from_urls(["https://issuer.example"]) + .with_declared(Some("https://issuer.example/")); + assert_eq!( + info_then_declared.marker_of("https://issuer.example"), + Some(IssuerMarker::Info) + ); + let declared_then_own = IssuerMints::none() + .with_declared(Some("https://issuer.example")) + .with_own(Some("https://issuer.example")); + assert_eq!( + declared_then_own.marker_of("https://issuer.example"), + Some(IssuerMarker::Own) + ); + assert_eq!( + declared_then_own.seal().len(), + 1, + "one URL is one sealed fact" + ); + } + + /// The seal round-trips: what accept knew, marker by marker, is what pay rebuilds — and the + /// JSON shape is stable, because it lives in every accept-bind on disk. + #[test] + fn the_seal_round_trips_with_its_markers() { + let known = IssuerMints::from_urls(["https://info.example"]) + .with_own(Some("http://127.0.0.1:3338")) + .with_declared(Some("https://declared.example")); + let seal = known.seal(); + assert_eq!(seal.len(), 3); + assert_eq!(IssuerMints::from_seal(&seal), known); + + let json = serde_json::to_string(&seal).expect("serializes"); + assert!(json.contains(r#""marker":"declared""#), "{json}"); + assert!(json.contains(r#""marker":"own""#), "{json}"); + assert!(json.contains(r#""marker":"info""#), "{json}"); + let back: Vec = serde_json::from_str(&json).expect("deserializes"); + assert_eq!(IssuerMints::from_seal(&back), known); + // Rebuilt, the fence and the hop answer as they did at accept. + let rebuilt = IssuerMints::from_seal(&back); + assert!(rebuilt.admits("https://info.example") && rebuilt.admits("http://127.0.0.1:3338")); + assert!( + rebuilt.contains("https://declared.example") + && !rebuilt.admits("https://declared.example") + ); + } + + /// The fence: an issuer mint passes regardless of `allow_real_mints` and of scheme; every other + /// mint answers to the unchanged `home::mint_allowed`. + #[test] + fn the_fence_admits_a_known_issuer_mint_and_nothing_else_new() { + let sidecar = "http://127.0.0.1:3338"; + let real = "https://mint.minibits.cash/Bitcoin"; + let known = IssuerMints::from_urls([sidecar]); + + // Issuer: admitted with the fence closed, and over plain http. + assert!(mint_admitted(sidecar, false, &known)); + assert!(mint_admitted(sidecar, true, &known)); + // Unknown http URL: refused as before, even with the real-money switch on. + assert!(!mint_admitted(sidecar, true, &IssuerMints::none())); + assert!(!mint_admitted(sidecar, false, &IssuerMints::none())); + // A real mint still answers to the switch alone — knowing an issuer changes nothing for it. + assert!(!mint_admitted(real, false, &known)); + assert!(mint_admitted(real, true, &known)); + // The dev allow-list entry still passes with the switch off. + assert!(mint_admitted(home::DEFAULT_MINT_URL, false, &known)); + } + + /// NEGATIVE: a seller DECLARING a mint its issuer mint opens nothing. A real mint so declared is + /// fenced exactly as an undeclared one — the switch alone decides — and the seller's sidecar so + /// declared stays fenced too, until the mint's own info (or this seat's config) says otherwise. + #[test] + fn a_sellers_declaration_does_not_widen_the_fence() { + let real = "https://mint.minibits.cash/Bitcoin"; + let sidecar = "http://10.0.0.7:3338"; + let declared = IssuerMints::none() + .with_declared(Some(real)) + .with_declared(Some(sidecar)); + assert!( + !mint_admitted(real, false, &declared), + "a declared real mint is still fenced" + ); + assert!( + mint_admitted(real, true, &declared), + "...and still opt-in-able, as before" + ); + assert!(!mint_admitted(sidecar, false, &declared)); + assert!( + !mint_admitted(sidecar, true, &declared), + "http is not https; declaration is not info" + ); + } + + #[test] + fn a_lightning_op_at_an_issuer_mint_is_refused_with_the_plain_reason() { + assert_eq!( + refuse_lightning_at_issuer( + "wallet fund", + "http://127.0.0.1:3338", + MintClass::Lightning + ), + Ok(()) + ); + let refusal = + refuse_lightning_at_issuer("wallet fund", "http://127.0.0.1:3338", MintClass::Issuer) + .expect_err("an issuer mint refuses"); + assert!(refusal.contains("wallet fund refused"), "{refusal}"); + assert!(refusal.contains("http://127.0.0.1:3338"), "{refusal}"); + assert!(refusal.contains(ISSUER_HOP_REFUSAL), "{refusal}"); + } +} diff --git a/crates/maxplayer-core/src/wallet_ops.rs b/crates/maxplayer-core/src/wallet_ops.rs index e0b7950f1..ca710a361 100644 --- a/crates/maxplayer-core/src/wallet_ops.rs +++ b/crates/maxplayer-core/src/wallet_ops.rs @@ -41,6 +41,11 @@ pub enum WalletOpsError { /// than a hardcoded constant — on a real-minibits home the constant would be a false-default /// lie (#579). MintPinnedDefault { mint_url: String }, + /// A Lightning operation (`wallet fund`, `wallet melt`) aimed at an ISSUER mint — a mint whose + /// info lists no bolt11 method (§4.2 "Issuer mint"). There is no Lightning there to mint from or + /// melt to; the message carries the plain reason. Issuance at a seat's own mint is a different + /// path (stage 3), never this one. + IssuerMint(String), Wallet(String), } @@ -58,6 +63,7 @@ impl std::fmt::Display for WalletOpsError { 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::IssuerMint(reason) => write!(formatter, "{reason}"), Self::MintPinnedDefault { mint_url } => write!( formatter, "cannot remove the default mint ({mint_url}); only extra_mints are removable" @@ -439,6 +445,29 @@ pub async fn balances_async(home: &MaxplayerHome) -> Result, Wa Ok(rows) } +/// Refuse a Lightning operation at an ISSUER mint (§4.2 "Issuer mint") BEFORE any quote is raised. +/// +/// The class comes from the mint's own NUT-06 info, read through the wallet's cached info load — +/// the same document the quote call would consult next, so this adds no new dependency and a mint +/// that cannot answer fails the operation exactly as the quote would have. `Ok(())` for a Lightning +/// mint. +async fn refuse_lightning_op_at_issuer( + wallet: &Wallet, + op: &str, + mint_url: &str, +) -> Result<(), WalletOpsError> { + let info = wallet + .load_mint_info() + .await + .map_err(|error| WalletOpsError::Wallet(format!("{op}: mint info: {error}")))?; + crate::mint_class::refuse_lightning_at_issuer( + op, + mint_url, + crate::mint_class::class_from_info(&info), + ) + .map_err(WalletOpsError::IssuerMint) +} + /// Create a mint quote and return the bolt11 **before** any poll/wait. pub async fn begin_mint_async( home: &MaxplayerHome, @@ -450,6 +479,7 @@ pub async fn begin_mint_async( } let mint_url = resolve_mint(home, mint_override)?; let wallet = open_wallet_async(home, &mint_url).await?; + refuse_lightning_op_at_issuer(&wallet, "wallet fund", &mint_url).await?; let amount = Amount::from(amount_sats); let quote = wallet .mint_quote(PaymentMethod::BOLT11, Some(amount), None, None) @@ -748,6 +778,7 @@ pub async fn melt_async( return Err(WalletOpsError::RealMintDisallowed { mint_url }); } let wallet = open_wallet_async(home, &mint_url).await?; + refuse_lightning_op_at_issuer(&wallet, "wallet melt", &mint_url).await?; let quote = wallet .melt_quote(PaymentMethod::BOLT11, bolt11, None, None) .await diff --git a/crates/maxplayer-core/tests/collect_integrity.rs b/crates/maxplayer-core/tests/collect_integrity.rs index 4a26f690e..d1ad73600 100644 --- a/crates/maxplayer-core/tests/collect_integrity.rs +++ b/crates/maxplayer-core/tests/collect_integrity.rs @@ -151,6 +151,7 @@ async fn collect_refuses_pay_when_delivered_tip_differs_from_bound_oid() { seller_signature: String::new(), creq_hash: None, accepted_mints: Vec::new(), + issuer_mints: Vec::new(), funding_mint: None, delivery_mint: None, agent_used: None, @@ -270,6 +271,7 @@ fn from_scratch_bind( seller_signature: String::new(), creq_hash: None, accepted_mints: Vec::new(), + issuer_mints: Vec::new(), funding_mint: None, delivery_mint: None, agent_used: None, diff --git a/crates/maxplayer/src/mcp.rs b/crates/maxplayer/src/mcp.rs index b1195106f..2ad06b4a0 100644 --- a/crates/maxplayer/src/mcp.rs +++ b/crates/maxplayer/src/mcp.rs @@ -622,6 +622,7 @@ mod tests { max_sats: 10, buyer_mint: DEFAULT_MINT_URL, allow_real_mints: false, + issuer_mints: &maxplayer_core::mint_class::NO_ISSUER_MINTS, requested_agent: None, requested_harness_family: None, requested_model: None, diff --git a/crates/maxplayer/src/wallet_cli.rs b/crates/maxplayer/src/wallet_cli.rs index 6074fe571..6220a4de2 100644 --- a/crates/maxplayer/src/wallet_cli.rs +++ b/crates/maxplayer/src/wallet_cli.rs @@ -699,6 +699,8 @@ fn parse_complete_locked( seller_signature: required(seller_signature, "--seller-signature")?, creq_hash, accepted_mints, + // The sealed bind overrides this on the locked path; a caller never names an issuer mint. + issuer_mints: Vec::new(), realized_mint, }; Ok((home, request)) From 3f13a1c2543cf1f385c2a7080d84d73e08010f74 Mon Sep 17 00:00:00 2001 From: w-ecash-mutual-credit Date: Wed, 2 Sep 2026 20:06:15 -0700 Subject: [PATCH 5/6] wallet: guard the fund-completion path at an issuer mint (check H) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `complete_mint_async` opened the wallet and went straight to `poll_and_mint` (`check_mint_quote`, then `wallet.mint`) with no issuer-class check — the guard sat only on fund-begin and melt. Insert the same `refuse_lightning_op_at_issuer(&wallet, "wallet fund", &mint_url)` after the wallet opens, before any quote call; nothing else in the function changes. NEGATIVE test, offline: a same-process mint stub that answers `/v1/info` (and the keysets refresh cdk makes alongside it) and RECORDS every request path. At a stub whose NUT-04/NUT-05 list no bolt11 (Issuer), completion returns `WalletOpsError::IssuerMint` and the mint sees only the info read — no `check_mint_quote`, no `wallet.mint`. Control: the identical call at a bolt11-listing stub passes the guard and fails later inside `poll_and_mint` as an ordinary Wallet error, so the gate is the class, not the stub. --- crates/maxplayer-core/src/wallet_ops.rs | 159 ++++++++++++++++++++++++ 1 file changed, 159 insertions(+) diff --git a/crates/maxplayer-core/src/wallet_ops.rs b/crates/maxplayer-core/src/wallet_ops.rs index ca710a361..1eb9370e7 100644 --- a/crates/maxplayer-core/src/wallet_ops.rs +++ b/crates/maxplayer-core/src/wallet_ops.rs @@ -506,6 +506,7 @@ pub async fn complete_mint_async( ) -> Result { let mint_url = mint_is_allowed(home, "e.mint_url)?; let wallet = open_wallet_async(home, &mint_url).await?; + refuse_lightning_op_at_issuer(&wallet, "wallet fund", &mint_url).await?; let funded = poll_and_mint(&wallet, "e.quote_id, quote.amount_sats).await?; let balance = wallet .total_balance() @@ -1400,4 +1401,162 @@ mod tests { ); let _ = std::fs::remove_dir_all(&root); } + + /// A same-process mint stub: answers `GET /v1/info` with `info`, everything else with 404, and + /// RECORDS every request path it receives. The recorder is the instrument — what the wallet + /// asked the mint is the whole question. No mint process, no money, no network beyond loopback. + fn recording_mint_stub(info: &cdk::nuts::MintInfo) -> (String, Arc>>) { + use std::io::{BufRead, BufReader, Write}; + + let body = serde_json::to_string(info).expect("mint info serializes"); + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind stub mint"); + let address = listener.local_addr().expect("stub mint address"); + let seen = Arc::new(std::sync::Mutex::new(Vec::new())); + let recorder = Arc::clone(&seen); + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(mut stream) = stream else { break }; + let mut reader = BufReader::new(match stream.try_clone() { + Ok(clone) => clone, + Err(_) => continue, + }); + let mut request_line = String::new(); + if reader.read_line(&mut request_line).is_err() { + continue; + } + loop { + let mut header = String::new(); + match reader.read_line(&mut header) { + Ok(0) => break, + Ok(_) if header == "\r\n" => break, + Ok(_) => {} + Err(_) => break, + } + } + let path = request_line.split_whitespace().nth(1).unwrap_or("").to_owned(); + recorder.lock().expect("recorder").push(path.clone()); + // Match the info route however the client spells its leading slashes (a normalized + // mint URL may carry a trailing `/`, giving `//v1/info`). + let route = path.trim_start_matches('/'); + let ok = |json: &str| { + format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\ + connection: close\r\n\r\n{json}", + json.len() + ) + }; + let response = if route == "v1/info" { + ok(&body) + } else if route == "v1/keysets" || route == "v1/keys" { + // cdk refreshes keysets alongside the info load; an empty set is a valid answer + // and keeps the stub honest about what it is: an info document, not a mint. + ok(r#"{"keysets":[]}"#) + } else { + "HTTP/1.1 404 Not Found\r\ncontent-length: 0\r\nconnection: close\r\n\r\n".to_owned() + }; + let _ = stream.write_all(response.as_bytes()); + } + }); + (format!("http://{address}"), seen) + } + + /// Check H (§4.2 "Issuer mint"): the fund COMPLETION path is guarded exactly like fund-begin and + /// melt. NEGATIVE: at a mint whose NUT-06 lists no bolt11 (Issuer per `class_from_info`), + /// `complete_mint_async` returns `WalletOpsError::IssuerMint` and the mint receives NO + /// `check_mint_quote` and NO `wallet.mint` request — only the `/v1/info` read the guard makes. + /// CONTROL: the identical call against a stub that lists bolt11 (Lightning) goes past the guard + /// and the recorder sees the quote lookup, so the negative above is not vacuous. + #[tokio::test] + async fn completing_a_mint_quote_at_an_issuer_mint_is_refused_before_any_quote_call() { + use cdk::nuts::{MintInfo, MintMethodSettings}; + + fn is_quote_or_mint_call(path: &str) -> bool { + path.contains("/mint/quote/") || path.contains("/mint/bolt11") + } + + // Issuer: no bolt11 under NUT-04 or NUT-05. + let mut issuer_info = MintInfo::new(); + issuer_info.nuts.nut04.methods = Vec::new(); + issuer_info.nuts.nut05.methods = Vec::new(); + assert_eq!( + crate::mint_class::class_from_info(&issuer_info), + crate::mint_class::MintClass::Issuer + ); + let (issuer_url, issuer_seen) = recording_mint_stub(&issuer_info); + + let root = temp_home("complete-at-issuer"); + let _ = std::fs::remove_dir_all(&root); + let mut home = bootstrap(&root).expect("bootstrap"); + // The loopback stub is a CONFIGURED mint, so `mint_is_allowed` admits it and the call reaches + // the wallet path under test rather than the configured-mint check. + home.config.extra_mints.push(issuer_url.clone()); + + let quote = MintQuote { + mint_url: issuer_url.clone(), + invoice: "lnbc1-not-a-real-invoice".to_owned(), + quote_id: "quote-at-issuer".to_owned(), + amount_sats: 5, + }; + let error = complete_mint_async(&home, "e) + .await + .expect_err("completing at an issuer mint must refuse"); + let seen = issuer_seen.lock().expect("recorder").clone(); + assert!( + matches!(error, WalletOpsError::IssuerMint(_)), + "expected IssuerMint, got: {error} (mint saw {seen:?})" + ); + let message = error.to_string(); + assert!(message.contains("wallet fund refused"), "{message}"); + assert!(message.contains(crate::mint_class::ISSUER_HOP_REFUSAL), "{message}"); + assert!( + seen.iter().any(|path| path.trim_start_matches('/') == "v1/info"), + "the guard must have read the mint's info: {seen:?}" + ); + assert!( + !seen.iter().any(|path| is_quote_or_mint_call(path)), + "neither check_mint_quote nor wallet.mint may reach an issuer mint: {seen:?}" + ); + + // CONTROL: same call, Lightning-class stub. The guard passes and execution goes on into + // `poll_and_mint`, where cdk's `check_mint_quote` resolves an id the local store has never + // seen WITHOUT a wire call and fails as an ordinary Wallet error — not IssuerMint, and not + // a refusal. So the control proves the gate is the CLASS, not the stub: identical call, + // identical unknown quote, the only difference is what `/v1/info` said. + let mut lightning_info = MintInfo::new(); + lightning_info.nuts.nut04.methods = vec![MintMethodSettings { + method: PaymentMethod::BOLT11, + unit: CurrencyUnit::Sat, + min_amount: None, + max_amount: None, + options: None, + }]; + let (lightning_url, lightning_seen) = recording_mint_stub(&lightning_info); + home.config.extra_mints.push(lightning_url.clone()); + let control = complete_mint_async( + &home, + &MintQuote { + mint_url: lightning_url, + invoice: "lnbc1-not-a-real-invoice".to_owned(), + quote_id: "quote-at-lightning".to_owned(), + amount_sats: 5, + }, + ) + .await + .expect_err("an unknown quote id fails inside poll_and_mint"); + let seen = lightning_seen.lock().expect("recorder").clone(); + assert!( + matches!(control, WalletOpsError::Wallet(_)), + "control must fail past the guard, not at it: {control} (mint saw {seen:?})" + ); + let control_message = control.to_string(); + assert!( + !control_message.contains("refused") && !control_message.contains("mint info"), + "control must not be a guard refusal or an info failure: {control_message}" + ); + assert!( + seen.iter().any(|path| path.trim_start_matches('/') == "v1/info"), + "the guard read the control mint's info too: {seen:?}" + ); + let _ = std::fs::remove_dir_all(&root); + } } From fc67661069461e1ee7728e9921aab3312433011b Mon Sep 17 00:00:00 2001 From: w-ecash-mutual-credit Date: Thu, 3 Sep 2026 19:04:26 -0700 Subject: [PATCH 6/6] mint class: the issuer class is DECLARED, never sniffed (stage 2 reshape) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A mint is an issuer mint because an operator said so — this seat's own config (`IssuerMarker::Own`) or the counterparty's kind-30340 ad (`IssuerMarker::Declared`) — and never because its /v1/info document listed no bolt11 method. Owner's ruling, 3 Sep 2026 ("lets do 2"). Dies: `class_from_info`, `IssuerMarker::Info`, `IssuerMints::from_urls`, `probe_issuer_mints`, and the three production network reads that existed only to obtain a class — the probe in mint_class.rs, `CdkHopEffects::class_of` in crossmint_hop.rs (`load_mint_info`), and `refuse_lightning_op_at_issuer` in wallet_ops.rs (`load_mint_info`). Replaces: `IssuerMarker::admits` is `matches!(self, Self::Own)`; nothing else about admission changes. `CdkHopEffects::open` takes the caller's `IssuerMints` and fixes both legs' classes there — the bind's seal on the pay path, this seat's own config on the recovery sweep — and `HopEffects::mint_classes` returns them. `wallet_ops`'s guard reads own config and runs before the wallet opens. Accept builds `IssuerMints::none().with_own(..).with_declared(..)`. Kept: `IssuerMintSeal` and accept-time sealing. A seal written under the retired `info` marker still loads: `#[serde(alias = "info")]` reads it as `Declared` — the hop it refused it still refuses, it admits nothing, and it re-serializes as `declared`. Tests: 3 removed (a_mint_listing_no_bolt11_method_is_an_issuer_mint, bolt11_on_either_table_reads_as_lightning, every_marker_refuses_but_only_own_and_info_admit), 4 added (every_marker_refuses_but_only_own_admits, a_legacy_info_seal_still_reads_as_a_declaration_and_admits_nothing, an_undeclared_mint_is_fenced_exactly_as_before_whatever_it_is, crossmint_hop_plan_quotes_refuses_a_declared_issuer_leg_without_asking_the_mint); the wallet_ops check-H test now asserts the declared mint receives NO request at all, not even /v1/info, while its stub would answer as a Lightning mint if asked. `grep -rnw -E 'class_from_info|load_mint_info|get_mint_info'` under crates/: 17 hits in 4 files at 3f13a1c, 1 at this commit (doctor.rs:93, the reachability probe, which obtains no class). --- crates/maxplayer-core/src/authorize_pay.rs | 3 + crates/maxplayer-core/src/crossmint.rs | 4 +- crates/maxplayer-core/src/crossmint_hop.rs | 131 ++++++--- crates/maxplayer-core/src/job_lifecycle.rs | 26 +- crates/maxplayer-core/src/mint_class.rs | 308 ++++++++------------- crates/maxplayer-core/src/wallet_ops.rs | 123 ++++---- 6 files changed, 285 insertions(+), 310 deletions(-) diff --git a/crates/maxplayer-core/src/authorize_pay.rs b/crates/maxplayer-core/src/authorize_pay.rs index b88758fbc..0f4412214 100644 --- a/crates/maxplayer-core/src/authorize_pay.rs +++ b/crates/maxplayer-core/src/authorize_pay.rs @@ -502,10 +502,13 @@ pub async fn authorize_pay_async( None => None, Some(source) => { let store = FsHopJournal::new(crossmint_hop::hop_journal_dir(home)); + // The hop executor's class gate reads the SEAL too — the same `issuers` the plan was + // derived from — so it can never learn a class from a mint. let effects = CdkHopEffects::open( home, &source.to_string(), &wallet_open_mint_url(home, &terms, &issuers), + &issuers, ) .await?; // A pairing already on disk WINS over freshly raised quotes. This attempt may have diff --git a/crates/maxplayer-core/src/crossmint.rs b/crates/maxplayer-core/src/crossmint.rs index 68b5bf1a2..5a2aff39c 100644 --- a/crates/maxplayer-core/src/crossmint.rs +++ b/crates/maxplayer-core/src/crossmint.rs @@ -582,8 +582,10 @@ mod tests { /// Another seat's issuer mint, reachable on a LAN. Also not https. const SELLER_ISSUER: &str = "http://10.0.0.7:3338"; + /// Issuer mints under the ADMITTING marker — declared by this seat's own config (`Own`). fn issuers(urls: &[&str]) -> IssuerMints { - IssuerMints::from_urls(urls) + urls.iter() + .fold(IssuerMints::none(), |known, url| known.with_own(Some(url))) } /// A DIRECT payment at a known issuer mint passes the fence with the real-money switch OFF and diff --git a/crates/maxplayer-core/src/crossmint_hop.rs b/crates/maxplayer-core/src/crossmint_hop.rs index fa73b0b0c..d2488e5ae 100644 --- a/crates/maxplayer-core/src/crossmint_hop.rs +++ b/crates/maxplayer-core/src/crossmint_hop.rs @@ -43,7 +43,7 @@ use cdk::wallet::Wallet; use crate::buyer_fund; use crate::crossmint::{HopCost, HopJournal}; use crate::home::MaxplayerHome; -use crate::mint_class::{ISSUER_HOP_REFUSAL, MintClass, class_from_info}; +use crate::mint_class::{ISSUER_HOP_REFUSAL, IssuerMints, MintClass}; use crate::payment_wallet::{MINT_TOUCH_TIMEOUT, is_mint_unreachable}; /// What the source mint says about the melt leg. @@ -313,12 +313,12 @@ impl std::error::Error for HopError {} /// that pays the melt and then dies before the mint reproduces the exact strand the journal exists to /// survive, which no amount of testing against a live mint pair could produce on demand. pub(crate) trait HopEffects { - /// The class of the (source, target) mints, from what each mint says about itself (its NUT-06 - /// info). Asked FIRST, before any leg: an issuer mint on either side refuses the hop with no - /// melt and no mint quote touched. Knowledge, not a reachability gate: a mint that does not - /// answer is UNKNOWN and reads as `Lightning` (the fail-safe `mint_class::probe_issuer_mints` - /// uses), so the leg that follows refuses it with the reachability label an operator already - /// knows. A fake answers `Lightning` for both unless a test says otherwise. + /// The class of the (source, target) mints, from what was DECLARED about each — the sealed + /// [`IssuerMints`] the executor was opened with; never from anything a mint says about itself. + /// Asked FIRST, before any leg: an issuer mint on either side refuses the hop with no melt and + /// no mint quote touched. A mint nobody declared is `Lightning`, and the leg that follows + /// refuses an unreachable one with the reachability label an operator already knows. A fake + /// answers `Lightning` for both unless a test says otherwise. fn mint_classes(&mut self) -> Result<(MintClass, MintClass), HopError>; /// Ask the SOURCE mint what became of the melt. Asking is also cdk's recovery trigger — a melt @@ -666,8 +666,8 @@ pub(crate) fn run_hop( // Class gate, before the Planned record and before either leg: a hop has no business on an // issuer mint in either direction (§4.2 "Issuer mint"). The planner already refuses one; this - // is the executor refusing on its own evidence — what the mints say about themselves — so no - // caller, journal, or stale plan can put a Lightning leg on a mint that has none. + // is the executor refusing on the declared knowledge it was opened with, so no caller, journal, + // or stale plan can put a Lightning leg on a mint an operator declared has none. let (source_class, target_class) = effects.mint_classes()?; for (leg, class, mint) in [ ("source", source_class, &journal.source_mint), @@ -900,16 +900,24 @@ async fn bounded( pub(crate) struct CdkHopEffects { source: Wallet, target: Wallet, + /// The DECLARED class of each leg's mint (§4.2 "Issuer mint"), fixed at `open` from the + /// issuer-mint knowledge the caller holds — the accept-bind's seal on the pay path, this seat's + /// own config on the sweep. Never read from a mint. + source_class: MintClass, + target_class: MintClass, /// `[buyer] hop_fee_buffer_multiplier`. Applied only when writing the Planned record. hop_fee_buffer_multiplier: u64, } impl CdkHopEffects { - /// Open the buyer's wallet at both mints. One sqlite store, two bound mints. + /// Open the buyer's wallet at both mints. One sqlite store, two bound mints. `issuers` is + /// what the caller KNOWS to be an issuer mint; it fixes both legs' classes here, so no later + /// step has to ask a mint what it is. pub(crate) async fn open( home: &MaxplayerHome, source_mint: &str, target_mint: &str, + issuers: &IssuerMints, ) -> Result { let source = buyer_fund::open_wallet_at_mint_async(home, source_mint) .await @@ -920,6 +928,8 @@ impl CdkHopEffects { Ok(Self { source, target, + source_class: issuers.class_of(source_mint), + target_class: issuers.class_of(target_mint), hop_fee_buffer_multiplier: home.config.buyer.hop_fee_buffer_multiplier, }) } @@ -948,12 +958,16 @@ impl CdkHopEffects { .into(), )); } - // No quote is raised at an issuer mint, on either side (§4.2 "Issuer mint"). Asked of the - // mints themselves, before the first quote, so a plan that reached here by any route still - // cannot put a Lightning leg on a mint that has none. A mint that does not answer is - // unknown, not refused here: the quote below refuses it with its own reachability label. - for (leg, wallet) in [("source", &self.source), ("target", &self.target)] { - if Self::class_of(wallet).await == MintClass::Issuer { + // No quote is raised at an issuer mint, on either side (§4.2 "Issuer mint"). Decided from + // the classes DECLARED at `open`, before the first quote, so a plan that reached here by any + // route still cannot put a Lightning leg on a mint an operator declared has none. A mint + // nobody declared is not refused here: the quote below refuses an unreachable one with its + // own reachability label. + for (leg, class, wallet) in [ + ("source", self.source_class, &self.source), + ("target", self.target_class, &self.target), + ] { + if class == MintClass::Issuer { return Err(HopError::IssuerMint { leg, mint: wallet.mint_url.to_string(), @@ -1076,29 +1090,10 @@ fn mint_quote_id(id: &impl fmt::Display) -> String { id.to_string() } -impl CdkHopEffects { - /// The class of one leg's mint from its own NUT-06 info (the wallet's cached load, bounded). - /// - /// Knowledge, not a gate: a mint that does not answer within [`MINT_TOUCH_TIMEOUT`], or answers - /// malformed, is UNKNOWN and reads as `Lightning` — the same fail-safe as - /// [`crate::mint_class::probe_issuer_mints`]. Only a mint that ANSWERS "no bolt11" is an issuer - /// mint here; an unreachable one is refused by the quote that follows, under the reachability - /// label (`target mint quote`, …) an operator already knows how to read. - async fn class_of(wallet: &Wallet) -> MintClass { - match tokio::time::timeout(MINT_TOUCH_TIMEOUT, wallet.load_mint_info()).await { - Ok(Ok(info)) => class_from_info(&info), - Ok(Err(_)) | Err(_) => MintClass::Lightning, - } - } -} - impl HopEffects for CdkHopEffects { fn mint_classes(&mut self) -> Result<(MintClass, MintClass), HopError> { - let source = self.source.clone(); - let target = self.target.clone(); - block_on_leg("mint classes", async move { - (Self::class_of(&source).await, Self::class_of(&target).await) - }) + // Fixed at `open` from declared knowledge; nothing is asked of either mint. + Ok((self.source_class, self.target_class)) } fn melt_leg(&mut self, melt_quote_id: &str) -> Result { @@ -1167,6 +1162,8 @@ impl HopEffects for CdkHopEffects { let effects = Self { source: self.source.clone(), target: self.target.clone(), + source_class: self.source_class, + target_class: self.target_class, hop_fee_buffer_multiplier: self.hop_fee_buffer_multiplier, }; let bolt11 = bolt11.to_owned(); @@ -1182,6 +1179,8 @@ impl HopEffects for CdkHopEffects { let effects = Self { source: self.source.clone(), target: self.target.clone(), + source_class: self.source_class, + target_class: self.target_class, hop_fee_buffer_multiplier: self.hop_fee_buffer_multiplier, }; block_on_leg("source coverage", async move { @@ -1305,7 +1304,12 @@ async fn sweep_one( store: &FsHopJournal, pairing: HopJournal, ) -> Result { - let mut effects = CdkHopEffects::open(home, &pairing.source_mint, &pairing.target_mint).await?; + // The sweep has no accept-bind in hand, so the declared knowledge it can stand behind is this + // seat's own config. A pairing was only ever journalled after the pay path's class gate passed + // under the bind's seal; this re-asks the same question of the one source the sweep holds. + let issuers = IssuerMints::none().with_own(home.config.issuer_mint()); + let mut effects = + CdkHopEffects::open(home, &pairing.source_mint, &pairing.target_mint, &issuers).await?; let mut recovered = Vec::new(); for (label, wallet) in [("source", &effects.source), ("target", &effects.target)] { bounded( @@ -1928,6 +1932,8 @@ mod tests { None, ) .unwrap(), + source_class: MintClass::Lightning, + target_class: MintClass::Lightning, hop_fee_buffer_multiplier: default_hop_fee_buffer_multiplier(), } } @@ -1952,10 +1958,59 @@ mod tests { None, ) .unwrap(), + source_class: MintClass::Lightning, + target_class: MintClass::Lightning, hop_fee_buffer_multiplier: default_hop_fee_buffer_multiplier(), } } + /// NEGATIVE (§4.2 "Issuer mint"): `plan_quotes` at a DECLARED issuer mint refuses on the + /// declaration alone — before any quote, and without asking the mint anything. The target here + /// has nothing listening (`https://127.0.0.1:1`): had the executor consulted the mint, the + /// refusal would have been `MintUnreachable` under the `target mint quote` label, as the two + /// tests below get for the SAME address with no declaration. The class decides, not the wire. + #[tokio::test] + async fn crossmint_hop_plan_quotes_refuses_a_declared_issuer_leg_without_asking_the_mint() { + let unreachable = "https://127.0.0.1:1"; + for (leg, source_class, target_class) in [ + ("target", MintClass::Lightning, MintClass::Issuer), + ("source", MintClass::Issuer, MintClass::Lightning), + ] { + let mut effects = cdk_hop_with_target(unreachable).await; + effects.source_class = source_class; + effects.target_class = target_class; + let journal_dir = scratch_dir(&format!("plan-declared-issuer-{leg}")); + let store = FsHopJournal::new(&journal_dir); + + let error = effects + .plan_quotes("attempt-declared", 100) + .await + .expect_err("a declared issuer leg must refuse"); + match &error { + HopError::IssuerMint { leg: got, mint } => { + assert_eq!(*got, leg); + assert_eq!(mint, unreachable); + } + other => panic!("expected IssuerMint on the {leg} leg, got {other}"), + } + assert!(error.to_string().contains(ISSUER_HOP_REFUSAL), "{error}"); + assert!( + !store.path_for("attempt-declared").exists() && !journal_dir.exists(), + "a class refusal must not touch the journal" + ); + } + + // The `open` path fixes the classes from the caller's knowledge, normalized like the + // planner: a declared URL spelled with a trailing slash still classifies the leg. + let known = IssuerMints::none().with_declared(Some("https://127.0.0.1:1/")); + assert_eq!(known.class_of(unreachable), MintClass::Issuer); + assert_eq!( + IssuerMints::none().class_of(unreachable), + MintClass::Lightning, + "undeclared, the same address is Lightning and is refused by the quote (below)" + ); + } + #[tokio::test] async fn crossmint_hop_plan_quotes_classifies_502_as_mint_unreachable_without_journal() { let (target_mint, responder) = crate::payment_wallet::http_502_mint(); diff --git a/crates/maxplayer-core/src/job_lifecycle.rs b/crates/maxplayer-core/src/job_lifecycle.rs index e1b764fcc..5c8bd257a 100644 --- a/crates/maxplayer-core/src/job_lifecycle.rs +++ b/crates/maxplayer-core/src/job_lifecycle.rs @@ -1352,21 +1352,17 @@ pub async fn accept_claim_async( // CHOICE is sealed below and re-derived at pay, so it stays deterministic — a later balance or // config-default change can never shift a sealed mint (the pays-once attempt-id invariant). // - // Which of the seller's mints are ISSUER mints (§4.2 "Issuer mint") is learned HERE and sealed - // with the selection, marker by marker: any accepted mint whose info lists no bolt11 method (a - // bounded GET /v1/info per mint, no wallet, no money), the buyer's own from config, and the - // mint the seller DECLARED on its announcement (one bounded relay read; the seller's word - // refuses a hop but never widens the fence — `mint_class` docs). The class-aware fence and the - // hop refusal are then re-derived at pay from the seal — never from a fresh probe or read. + // Which mints are ISSUER mints (§4.2 "Issuer mint") is DECLARED, gathered HERE and sealed with + // the selection, marker by marker: the buyer's own from config, and the mint the seller + // DECLARED on its announcement (one bounded relay read; the seller's word refuses a hop but + // never widens the fence — `mint_class` docs). No mint is asked what it is: a class is stated + // by an operator or it does not exist. The class-aware fence and the hop refusal are then + // re-derived at pay from the seal — never from a fresh read. let declared_issuer_mint = fetch_seller_issuer_mint_async(home, &keys, &claim.seller_pubkey, timeout).await; - let issuers = crate::mint_class::probe_issuer_mints( - &accepted_mints, - crate::payment_wallet::MINT_TOUCH_TIMEOUT, - ) - .await - .with_own(home.config.issuer_mint()) - .with_declared(declared_issuer_mint.as_deref()); + let issuers = crate::mint_class::IssuerMints::none() + .with_own(home.config.issuer_mint()) + .with_declared(declared_issuer_mint.as_deref()); let source_seed = match crate::wallet_ops::balances_async(home).await { Ok(balances) => crate::crossmint::select_source_mint( home.config.default_mint(), @@ -3729,10 +3725,6 @@ mod tests { url: "http://127.0.0.1:3338".into(), marker: crate::mint_class::IssuerMarker::Own, }, - crate::mint_class::IssuerMintSeal { - url: "https://info.example".into(), - marker: crate::mint_class::IssuerMarker::Info, - }, crate::mint_class::IssuerMintSeal { url: "https://declared.example".into(), marker: crate::mint_class::IssuerMarker::Declared, diff --git a/crates/maxplayer-core/src/mint_class.rs b/crates/maxplayer-core/src/mint_class.rs index ff4d85d9e..29e8ed301 100644 --- a/crates/maxplayer-core/src/mint_class.rs +++ b/crates/maxplayer-core/src/mint_class.rs @@ -14,36 +14,31 @@ //! hop INTO one cannot land and a hop OUT of one cannot leave. The plain reason a buyer reads is //! [`ISSUER_HOP_REFUSAL`]: it holds none of this seller's currency, and Lightning cannot buy any. //! -//! How a mint is KNOWN to be an issuer mint — the two markers the design names, and nothing else. -//! Each is recorded with WHO said it ([`IssuerMarker`]), because the two rules above do not trust -//! the two markers equally: +//! How a mint is KNOWN to be an issuer mint — it is DECLARED by an operator, and never inferred +//! from the mint itself. Each declaration is recorded with WHO said it ([`IssuerMarker`]), because +//! the two rules above do not trust the two sources equally: //! -//! 1. **The ad tag.** A seat that runs one advertises it on its own kind-30340 announcement -//! ([`crate::heartbeat::ISSUER_MINT_TAG`]). Read two ways: -//! - The seat's OWN mint comes from config ([`crate::home::MaxplayerConfig::issuer_mint`]) — -//! the source the tag is published from — and is [`IssuerMarker::Own`]. The operator stated -//! it; it admits and it refuses. -//! - A SELLER's declaration is read off the seller's announcement at accept and is -//! [`IssuerMarker::Declared`]. It REFUSES the hop (the seller's word can only make the buyer -//! more careful) but does NOT widen the fence: a seller's signed tag must not be able to open -//! the buyer's real-mint fence to any mint the seller cares to name — a real mint the buyer -//! holds sats at, declared "issuer" by a stranger, would otherwise become spendable with the -//! real-money switch off. -//! 2. **The mint's own info.** A mint whose NUT-06 document lists no `bolt11` method under NUT-04 -//! (mint) or NUT-05 (melt) has no Lightning. [`class_from_info`] is that test, recorded as -//! [`IssuerMarker::Info`]: the mint itself says it holds no Lightning route, so it admits and it -//! refuses. It is how a buyer classifies a seller's mint at accept, where the classification is -//! sealed into the bind so the pay path re-derives rather than re-decides. +//! 1. **This seat's own config** ([`crate::home::MaxplayerConfig::issuer_mint`]) — the source its +//! kind-30340 `issuer_mint` tag ([`crate::heartbeat::ISSUER_MINT_TAG`]) is published from — is +//! [`IssuerMarker::Own`]. The operator stated it; it admits and it refuses. +//! 2. **A counterparty's advertisement.** A SELLER's declaration is read off the seller's +//! announcement at accept and is [`IssuerMarker::Declared`]. It REFUSES the hop (the seller's +//! word can only make the buyer more careful) but does NOT widen the fence: a seller's signed +//! tag must not be able to open the buyer's real-mint fence to any mint the seller cares to +//! name — a real mint the buyer holds sats at, declared "issuer" by a stranger, would otherwise +//! become spendable with the real-money switch off. +//! +//! What a mint says about ITSELF (its NUT-06 `/v1/info` document) is not a source: no production +//! path reads a mint's info to obtain its class. Whatever is known is sealed into the accept-bind +//! ([`IssuerMintSeal`]) so the pay path re-derives rather than re-decides. //! //! Absence of every marker is UNKNOWN, and unknown reads as Lightning: the fence and the hop then //! behave exactly as they did before this class existed. use std::collections::BTreeMap; use std::str::FromStr; -use std::time::Duration; use cdk::mint_url::MintUrl; -use cdk::nuts::{MintInfo, PaymentMethod}; use serde::{Deserialize, Serialize}; use crate::home; @@ -63,15 +58,18 @@ pub enum MintClass { Issuer, } -/// WHO said a mint is an issuer mint. Every marker refuses the Lightning hop; only the markers -/// this seat can stand behind widen its real-mint fence (see the module docs). +/// WHO declared a mint an issuer mint. Every marker refuses the Lightning hop; only the marker +/// this seat can stand behind — its own config — widens its real-mint fence (see the module docs). #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum IssuerMarker { /// Another seat's kind-30340 `issuer_mint` tag, read at accept. Refuses; does not admit. + /// + /// Also what a seal written under the retired `info` marker (a class once inferred from the + /// mint's own NUT-06 document) reads back as: an old bind still loads, the hop it refused it + /// still refuses, and a class nobody declared never widens the fence. + #[serde(alias = "info")] Declared, - /// The mint's own NUT-06 info lists no bolt11 method. Refuses and admits. - Info, /// This seat's own issuer mint, from its config. Refuses and admits. Own, } @@ -79,7 +77,7 @@ pub enum IssuerMarker { impl IssuerMarker { /// Whether this marker is one the seat may widen its OWN real-mint fence on. fn admits(self) -> bool { - matches!(self, Self::Info | Self::Own) + matches!(self, Self::Own) } } @@ -91,33 +89,6 @@ pub struct IssuerMintSeal { pub marker: IssuerMarker, } -/// Classify a mint from its NUT-06 info: `Issuer` iff neither NUT-04 (mint) nor NUT-05 (melt) -/// lists a `bolt11` method. -/// -/// Both tables are consulted because a hop needs Lightning on BOTH sides of a mint — a mint that -/// could be paid by Lightning but not pay out (or the reverse) is not a hop leg either, but that -/// is a different defect; the class here is "has no Lightning at all", which is what an issuer -/// mint run with no Lightning backend reports. -pub fn class_from_info(info: &MintInfo) -> MintClass { - let mints_bolt11 = info - .nuts - .nut04 - .methods - .iter() - .any(|method| method.method == PaymentMethod::BOLT11); - let melts_bolt11 = info - .nuts - .nut05 - .methods - .iter() - .any(|method| method.method == PaymentMethod::BOLT11); - if mints_bolt11 || melts_bolt11 { - MintClass::Lightning - } else { - MintClass::Issuer - } -} - /// The issuer mints known for ONE payment decision, each with its marker. Built once, passed by /// reference into [`crate::crossmint::plan_payment`] and [`crate::crossmint::select_source_mint`], /// and sealed into the accept-bind so the pay path re-derives the identical decision. @@ -145,19 +116,6 @@ impl IssuerMints { Self::default() } - /// Issuer mints classified from their own info ([`IssuerMarker::Info`]) — a probe's result. - pub fn from_urls(urls: I) -> Self - where - I: IntoIterator, - S: AsRef, - { - let mut known = Self::default(); - for url in urls { - known.insert(url.as_ref(), IssuerMarker::Info); - } - known - } - /// Rebuild from a sealed bind's list, marker by marker. The pay path's ONLY constructor. pub fn from_seal(seal: &[IssuerMintSeal]) -> Self { let mut known = Self::default(); @@ -250,9 +208,8 @@ impl IssuerMints { /// unconditionally; every other mint answers to [`home::mint_allowed`] exactly as before. /// /// This is the ONE place the class widens the fence, and it widens it only for a mint the seat -/// itself can stand behind — its own (from config) or one the mint's own info classified (sealed at -/// accept). A mint a seller merely declared, and a mint nobody classified, are fenced as they -/// always were. +/// itself can stand behind — its own, from its config (sealed at accept). A mint a seller merely +/// declared, and a mint nobody declared, are fenced as they always were. pub fn mint_admitted(mint_url: &str, allow_real_mints: bool, issuers: &IssuerMints) -> bool { issuers.admits(mint_url) || home::mint_allowed(mint_url, allow_real_mints) } @@ -274,114 +231,13 @@ pub fn refuse_lightning_at_issuer( } } -/// Ask each of `mint_urls` for its NUT-06 info and return those that classify as issuer mints -/// ([`IssuerMarker::Info`]). -/// -/// Best-effort and FAIL-SAFE in the direction that matters: a mint that does not answer within -/// `timeout`, answers malformed, or does not parse as a URL is NOT classified — it stays Lightning -/// class (unknown), so it is fenced and hop-planned exactly as before. Nothing here moves money or -/// opens a wallet; it is the same GET `/v1/info` the doctor's reachability probe makes. -/// -/// Called at accept, where the result is sealed into the bind. It is never called on the pay path: -/// the pay path re-derives from the seal, so a mint that changes its answer later cannot shift a -/// sealed decision. -pub async fn probe_issuer_mints(mint_urls: &[String], timeout: Duration) -> IssuerMints { - use cdk::wallet::{HttpClient, MintConnector}; - - let mut known = IssuerMints::none(); - for raw in mint_urls { - let Ok(url) = MintUrl::from_str(raw.trim()) else { - continue; - }; - let client = HttpClient::new(url.clone(), None); - let info = match tokio::time::timeout(timeout, client.get_mint_info()).await { - Ok(Ok(info)) => info, - // Unreachable or malformed: unknown, therefore Lightning. Never a refusal here — the - // fence and the hop executor are the gates; this is only knowledge. - Ok(Err(_)) | Err(_) => continue, - }; - if class_from_info(&info) == MintClass::Issuer { - known.insert(&url.to_string(), IssuerMarker::Info); - } - } - known -} - #[cfg(test)] mod tests { use super::*; - use cdk::nuts::{CurrencyUnit, MeltMethodSettings, MintMethodSettings}; - - fn bolt11_mint_method() -> MintMethodSettings { - MintMethodSettings { - method: PaymentMethod::BOLT11, - unit: CurrencyUnit::Sat, - min_amount: None, - max_amount: None, - options: None, - } - } - - fn bolt11_melt_method() -> MeltMethodSettings { - MeltMethodSettings { - method: PaymentMethod::BOLT11, - unit: CurrencyUnit::Sat, - min_amount: None, - max_amount: None, - options: None, - } - } - - fn custom_melt_method(name: &str) -> MeltMethodSettings { - MeltMethodSettings { - method: PaymentMethod::Custom(name.to_owned()), - unit: CurrencyUnit::Sat, - min_amount: None, - max_amount: None, - options: None, - } - } - - /// A stock Lightning mint lists bolt11 under both NUT-04 and NUT-05. - fn lightning_info() -> MintInfo { - let mut info = MintInfo::new(); - info.nuts.nut04.methods = vec![bolt11_mint_method()]; - info.nuts.nut05.methods = vec![bolt11_melt_method()]; - info - } - - /// An issuer mint run with no Lightning backend: no bolt11 anywhere. It may still list a - /// custom melt method (the stage-3 "retire" path) — that is not Lightning. - fn issuer_info() -> MintInfo { - let mut info = MintInfo::new(); - info.nuts.nut04.methods = Vec::new(); - info.nuts.nut05.methods = vec![custom_melt_method("retire")]; - info - } - - #[test] - fn a_mint_listing_no_bolt11_method_is_an_issuer_mint() { - assert_eq!(class_from_info(&issuer_info()), MintClass::Issuer); - assert_eq!(class_from_info(&MintInfo::new()), MintClass::Issuer); - assert_eq!(class_from_info(&lightning_info()), MintClass::Lightning); - } - - /// Lightning on EITHER side is enough to be Lightning class: the class is "no Lightning at - /// all", not "cannot serve as a hop leg". - #[test] - fn bolt11_on_either_table_reads_as_lightning() { - let mut mint_only = MintInfo::new(); - mint_only.nuts.nut04.methods = vec![bolt11_mint_method()]; - assert_eq!(class_from_info(&mint_only), MintClass::Lightning); - - let mut melt_only = MintInfo::new(); - melt_only.nuts.nut05.methods = vec![bolt11_melt_method()]; - assert_eq!(class_from_info(&melt_only), MintClass::Lightning); - } #[test] fn issuer_mints_normalize_and_compare_like_the_planner() { - let known = IssuerMints::from_urls(["https://Issuer.example/Bitcoin/"]); + let known = IssuerMints::none().with_own(Some("https://Issuer.example/Bitcoin/")); assert!(known.contains("https://issuer.example/Bitcoin")); assert!(known.contains("https://issuer.example/Bitcoin/")); assert!(!known.contains("https://other.example/Bitcoin")); @@ -422,11 +278,12 @@ mod tests { ); } - /// The three markers all REFUSE (every one is an issuer mint to the hop), but only the two the - /// seat can stand behind — its own config, the mint's own info — ADMIT. A seller's declaration - /// is knowledge for the hop and nothing for the fence. + /// Both markers REFUSE (each is an issuer mint to the hop), but only the one the seat can stand + /// behind — its own config — ADMITS. A seller's declaration is knowledge for the hop and + /// nothing for the fence. Each half on its own. #[test] - fn every_marker_refuses_but_only_own_and_info_admit() { + fn every_marker_refuses_but_only_own_admits() { + // Ad-declared: refuses, does not admit. let declared = IssuerMints::none().with_declared(Some("https://issuer.example")); assert!(declared.contains("https://issuer.example")); assert!(!declared.admits("https://issuer.example")); @@ -434,12 +291,16 @@ mod tests { declared.marker_of("https://issuer.example"), Some(IssuerMarker::Declared) ); + assert!(!IssuerMarker::Declared.admits()); - let info = IssuerMints::from_urls(["https://issuer.example"]); - assert!(info.contains("https://issuer.example") && info.admits("https://issuer.example")); - + // Config-declared: refuses and admits. let own = IssuerMints::none().with_own(Some("https://issuer.example")); assert!(own.contains("https://issuer.example") && own.admits("https://issuer.example")); + assert_eq!( + own.marker_of("https://issuer.example"), + Some(IssuerMarker::Own) + ); + assert!(IssuerMarker::Own.admits()); // Unknown: neither. assert!(!IssuerMints::none().contains("https://issuer.example")); @@ -447,14 +308,15 @@ mod tests { } /// One URL, two markers: the admitting one stands whichever order they arrive in. A seller's - /// declaration can never downgrade what the seat itself knows. + /// declaration can never downgrade what the seat itself stated. #[test] fn a_declaration_never_downgrades_an_admitting_marker() { - let info_then_declared = IssuerMints::from_urls(["https://issuer.example"]) + let own_then_declared = IssuerMints::none() + .with_own(Some("https://issuer.example")) .with_declared(Some("https://issuer.example/")); assert_eq!( - info_then_declared.marker_of("https://issuer.example"), - Some(IssuerMarker::Info) + own_then_declared.marker_of("https://issuer.example"), + Some(IssuerMarker::Own) ); let declared_then_own = IssuerMints::none() .with_declared(Some("https://issuer.example")) @@ -474,35 +336,70 @@ mod tests { /// JSON shape is stable, because it lives in every accept-bind on disk. #[test] fn the_seal_round_trips_with_its_markers() { - let known = IssuerMints::from_urls(["https://info.example"]) + let known = IssuerMints::none() .with_own(Some("http://127.0.0.1:3338")) .with_declared(Some("https://declared.example")); let seal = known.seal(); - assert_eq!(seal.len(), 3); + assert_eq!(seal.len(), 2); assert_eq!(IssuerMints::from_seal(&seal), known); let json = serde_json::to_string(&seal).expect("serializes"); assert!(json.contains(r#""marker":"declared""#), "{json}"); assert!(json.contains(r#""marker":"own""#), "{json}"); - assert!(json.contains(r#""marker":"info""#), "{json}"); + assert!(!json.contains(r#""marker":"info""#), "{json}"); let back: Vec = serde_json::from_str(&json).expect("deserializes"); assert_eq!(IssuerMints::from_seal(&back), known); // Rebuilt, the fence and the hop answer as they did at accept. let rebuilt = IssuerMints::from_seal(&back); - assert!(rebuilt.admits("https://info.example") && rebuilt.admits("http://127.0.0.1:3338")); + assert!(rebuilt.admits("http://127.0.0.1:3338")); assert!( rebuilt.contains("https://declared.example") && !rebuilt.admits("https://declared.example") ); } - /// The fence: an issuer mint passes regardless of `allow_real_mints` and of scheme; every other - /// mint answers to the unchanged `home::mint_allowed`. + /// A seal written BEFORE the class became declaration-only carried a third marker, `info` (a + /// class inferred from the mint's own NUT-06 document). Such a bind must still load: the entry + /// reads back as a declaration — the hop still refuses it — and it admits nothing, because no + /// operator ever stated it. It is written back as `declared`, never as `info`. + #[test] + fn a_legacy_info_seal_still_reads_as_a_declaration_and_admits_nothing() { + let legacy = r#"[ + {"url":"https://info.example","marker":"info"}, + {"url":"http://127.0.0.1:3338","marker":"own"}, + {"url":"https://declared.example","marker":"declared"} + ]"#; + let seal: Vec = serde_json::from_str(legacy).expect("a legacy seal loads"); + assert_eq!(seal.len(), 3); + assert_eq!(seal[0].marker, IssuerMarker::Declared); + assert_eq!(seal[1].marker, IssuerMarker::Own); + assert_eq!(seal[2].marker, IssuerMarker::Declared); + + let rebuilt = IssuerMints::from_seal(&seal); + assert_eq!(rebuilt.class_of("https://info.example"), MintClass::Issuer); + assert!( + !rebuilt.admits("https://info.example"), + "a class nobody declared never widens the fence" + ); + assert!(!mint_admitted("https://info.example", false, &rebuilt)); + assert!(rebuilt.admits("http://127.0.0.1:3338")); + + let rewritten = serde_json::to_string(&rebuilt.seal()).expect("serializes"); + assert!(!rewritten.contains(r#""marker":"info""#), "{rewritten}"); + assert!(rewritten.contains(r#""marker":"declared""#), "{rewritten}"); + assert!( + serde_json::from_str::(r#""sniffed""#).is_err(), + "only the named markers read" + ); + } + + /// The fence: an issuer mint this seat declared passes regardless of `allow_real_mints` and of + /// scheme; every other mint answers to the unchanged `home::mint_allowed`. #[test] fn the_fence_admits_a_known_issuer_mint_and_nothing_else_new() { let sidecar = "http://127.0.0.1:3338"; let real = "https://mint.minibits.cash/Bitcoin"; - let known = IssuerMints::from_urls([sidecar]); + let known = IssuerMints::none().with_own(Some(sidecar)); // Issuer: admitted with the fence closed, and over plain http. assert!(mint_admitted(sidecar, false, &known)); @@ -517,9 +414,38 @@ mod tests { assert!(mint_admitted(home::DEFAULT_MINT_URL, false, &known)); } + /// NEGATIVE: a mint nobody declared is fenced EXACTLY as `home::mint_allowed` fences it, for + /// every URL and either switch setting — including a mint that is unreachable, one that is + /// malformed, and one that would answer "no bolt11" if asked. None of that can matter, because + /// nothing asks: the class comes from a declaration or it does not exist. + #[test] + fn an_undeclared_mint_is_fenced_exactly_as_before_whatever_it_is() { + let nobody = IssuerMints::none(); + let candidates = [ + "https://mint.minibits.cash/Bitcoin", + home::DEFAULT_MINT_URL, + "http://127.0.0.1:1", // nothing listens here + "http://10.255.255.1:3338", // unroutable + "https://no-such-host.invalid/", // does not resolve + "::not-a-url::", // malformed + "", + ]; + for url in candidates { + for allow in [false, true] { + assert_eq!( + mint_admitted(url, allow, &nobody), + home::mint_allowed(url, allow), + "undeclared {url:?} with allow_real_mints={allow}" + ); + } + assert_eq!(nobody.class_of(url), MintClass::Lightning, "{url:?}"); + assert_eq!(nobody.marker_of(url), None, "{url:?}"); + } + } + /// NEGATIVE: a seller DECLARING a mint its issuer mint opens nothing. A real mint so declared is /// fenced exactly as an undeclared one — the switch alone decides — and the seller's sidecar so - /// declared stays fenced too, until the mint's own info (or this seat's config) says otherwise. + /// declared stays fenced too, unless this seat's own config names it. #[test] fn a_sellers_declaration_does_not_widen_the_fence() { let real = "https://mint.minibits.cash/Bitcoin"; @@ -538,7 +464,7 @@ mod tests { assert!(!mint_admitted(sidecar, false, &declared)); assert!( !mint_admitted(sidecar, true, &declared), - "http is not https; declaration is not info" + "http is not https; a seller's declaration is not this seat's" ); } diff --git a/crates/maxplayer-core/src/wallet_ops.rs b/crates/maxplayer-core/src/wallet_ops.rs index 1eb9370e7..16e9e733d 100644 --- a/crates/maxplayer-core/src/wallet_ops.rs +++ b/crates/maxplayer-core/src/wallet_ops.rs @@ -445,27 +445,21 @@ pub async fn balances_async(home: &MaxplayerHome) -> Result, Wa Ok(rows) } -/// Refuse a Lightning operation at an ISSUER mint (§4.2 "Issuer mint") BEFORE any quote is raised. +/// Refuse a Lightning operation at an ISSUER mint (§4.2 "Issuer mint") BEFORE the wallet is opened +/// or any quote is raised. /// -/// The class comes from the mint's own NUT-06 info, read through the wallet's cached info load — -/// the same document the quote call would consult next, so this adds no new dependency and a mint -/// that cannot answer fails the operation exactly as the quote would have. `Ok(())` for a Lightning -/// mint. -async fn refuse_lightning_op_at_issuer( - wallet: &Wallet, +/// The class is DECLARED, never read off the mint: an operator `wallet fund`/`wallet melt` runs +/// with no accept-bind in hand, so the one declaration this seat can stand behind is its own +/// config (`issuer_mint`). A mint the config does not name is Lightning class and proceeds as it +/// always did; nothing here touches the network. `Ok(())` for a Lightning mint. +fn refuse_lightning_op_at_issuer( + home: &MaxplayerHome, op: &str, mint_url: &str, ) -> Result<(), WalletOpsError> { - let info = wallet - .load_mint_info() - .await - .map_err(|error| WalletOpsError::Wallet(format!("{op}: mint info: {error}")))?; - crate::mint_class::refuse_lightning_at_issuer( - op, - mint_url, - crate::mint_class::class_from_info(&info), - ) - .map_err(WalletOpsError::IssuerMint) + let issuers = crate::mint_class::IssuerMints::none().with_own(home.config.issuer_mint()); + crate::mint_class::refuse_lightning_at_issuer(op, mint_url, issuers.class_of(mint_url)) + .map_err(WalletOpsError::IssuerMint) } /// Create a mint quote and return the bolt11 **before** any poll/wait. @@ -478,8 +472,8 @@ pub async fn begin_mint_async( return Err(WalletOpsError::Wallet("amount must be > 0".into())); } let mint_url = resolve_mint(home, mint_override)?; + refuse_lightning_op_at_issuer(home, "wallet fund", &mint_url)?; let wallet = open_wallet_async(home, &mint_url).await?; - refuse_lightning_op_at_issuer(&wallet, "wallet fund", &mint_url).await?; let amount = Amount::from(amount_sats); let quote = wallet .mint_quote(PaymentMethod::BOLT11, Some(amount), None, None) @@ -505,8 +499,8 @@ pub async fn complete_mint_async( quote: &MintQuote, ) -> Result { let mint_url = mint_is_allowed(home, "e.mint_url)?; + refuse_lightning_op_at_issuer(home, "wallet fund", &mint_url)?; let wallet = open_wallet_async(home, &mint_url).await?; - refuse_lightning_op_at_issuer(&wallet, "wallet fund", &mint_url).await?; let funded = poll_and_mint(&wallet, "e.quote_id, quote.amount_sats).await?; let balance = wallet .total_balance() @@ -778,8 +772,8 @@ pub async fn melt_async( if !home::mint_allowed(&mint_url, home.config.allow_real_mints) { return Err(WalletOpsError::RealMintDisallowed { mint_url }); } + refuse_lightning_op_at_issuer(home, "wallet melt", &mint_url)?; let wallet = open_wallet_async(home, &mint_url).await?; - refuse_lightning_op_at_issuer(&wallet, "wallet melt", &mint_url).await?; let quote = wallet .melt_quote(PaymentMethod::BOLT11, bolt11, None, None) .await @@ -1460,12 +1454,16 @@ mod tests { (format!("http://{address}"), seen) } - /// Check H (§4.2 "Issuer mint"): the fund COMPLETION path is guarded exactly like fund-begin and - /// melt. NEGATIVE: at a mint whose NUT-06 lists no bolt11 (Issuer per `class_from_info`), - /// `complete_mint_async` returns `WalletOpsError::IssuerMint` and the mint receives NO - /// `check_mint_quote` and NO `wallet.mint` request — only the `/v1/info` read the guard makes. - /// CONTROL: the identical call against a stub that lists bolt11 (Lightning) goes past the guard - /// and the recorder sees the quote lookup, so the negative above is not vacuous. + /// Check H (§4.2 "Issuer mint"): fund-begin and fund-completion are guarded by the DECLARED + /// class — this seat's own `issuer_mint` config — and the guard runs before the wallet opens. + /// NEGATIVE: at the declared mint, `begin_mint_async` and `complete_mint_async` return + /// `WalletOpsError::IssuerMint` and the mint receives NO request at all — no quote, no mint, + /// and not even `/v1/info`, because nothing asks a mint what it is. The stub would answer as a + /// LIGHTNING mint (bolt11 under NUT-04) if it were asked, so a class read off the wire would + /// have let the call through: the declaration is what refuses it. + /// CONTROL: the identical completion against an UNDECLARED stub goes past the guard and into + /// `poll_and_mint`, where cdk fails an unknown quote id as an ordinary Wallet error — not + /// IssuerMint, not a refusal — so the negative above is not vacuous. #[tokio::test] async fn completing_a_mint_quote_at_an_issuer_mint_is_refused_before_any_quote_call() { use cdk::nuts::{MintInfo, MintMethodSettings}; @@ -1473,24 +1471,36 @@ mod tests { fn is_quote_or_mint_call(path: &str) -> bool { path.contains("/mint/quote/") || path.contains("/mint/bolt11") } + fn lightning_info() -> MintInfo { + let mut info = MintInfo::new(); + info.nuts.nut04.methods = vec![MintMethodSettings { + method: PaymentMethod::BOLT11, + unit: CurrencyUnit::Sat, + min_amount: None, + max_amount: None, + options: None, + }]; + info + } - // Issuer: no bolt11 under NUT-04 or NUT-05. - let mut issuer_info = MintInfo::new(); - issuer_info.nuts.nut04.methods = Vec::new(); - issuer_info.nuts.nut05.methods = Vec::new(); - assert_eq!( - crate::mint_class::class_from_info(&issuer_info), - crate::mint_class::MintClass::Issuer - ); - let (issuer_url, issuer_seen) = recording_mint_stub(&issuer_info); + let (issuer_url, issuer_seen) = recording_mint_stub(&lightning_info()); let root = temp_home("complete-at-issuer"); let _ = std::fs::remove_dir_all(&root); let mut home = bootstrap(&root).expect("bootstrap"); - // The loopback stub is a CONFIGURED mint, so `mint_is_allowed` admits it and the call reaches - // the wallet path under test rather than the configured-mint check. + // The loopback stub is a CONFIGURED mint, so `mint_is_allowed`/`resolve_mint` admit it and + // the call reaches the guard under test rather than the configured-mint check; and it is + // this seat's DECLARED issuer mint, which is the whole of what the guard reads. home.config.extra_mints.push(issuer_url.clone()); + home.config.issuer_mint = Some(issuer_url.clone()); + let begin = begin_mint_async(&home, 5, Some(&issuer_url)) + .await + .expect_err("funding at a declared issuer mint must refuse"); + assert!( + matches!(begin, WalletOpsError::IssuerMint(_)), + "expected IssuerMint from begin, got: {begin}" + ); let quote = MintQuote { mint_url: issuer_url.clone(), invoice: "lnbc1-not-a-real-invoice".to_owned(), @@ -1499,7 +1509,7 @@ mod tests { }; let error = complete_mint_async(&home, "e) .await - .expect_err("completing at an issuer mint must refuse"); + .expect_err("completing at a declared issuer mint must refuse"); let seen = issuer_seen.lock().expect("recorder").clone(); assert!( matches!(error, WalletOpsError::IssuerMint(_)), @@ -1507,30 +1517,23 @@ mod tests { ); let message = error.to_string(); assert!(message.contains("wallet fund refused"), "{message}"); + assert!(message.contains(&issuer_url), "{message}"); assert!(message.contains(crate::mint_class::ISSUER_HOP_REFUSAL), "{message}"); assert!( - seen.iter().any(|path| path.trim_start_matches('/') == "v1/info"), - "the guard must have read the mint's info: {seen:?}" + seen.is_empty(), + "a declared issuer mint is asked NOTHING — not a quote, not its info: {seen:?}" ); assert!( !seen.iter().any(|path| is_quote_or_mint_call(path)), "neither check_mint_quote nor wallet.mint may reach an issuer mint: {seen:?}" ); - // CONTROL: same call, Lightning-class stub. The guard passes and execution goes on into - // `poll_and_mint`, where cdk's `check_mint_quote` resolves an id the local store has never - // seen WITHOUT a wire call and fails as an ordinary Wallet error — not IssuerMint, and not - // a refusal. So the control proves the gate is the CLASS, not the stub: identical call, - // identical unknown quote, the only difference is what `/v1/info` said. - let mut lightning_info = MintInfo::new(); - lightning_info.nuts.nut04.methods = vec![MintMethodSettings { - method: PaymentMethod::BOLT11, - unit: CurrencyUnit::Sat, - min_amount: None, - max_amount: None, - options: None, - }]; - let (lightning_url, lightning_seen) = recording_mint_stub(&lightning_info); + // CONTROL: same call, same kind of stub, NOT declared. The guard passes and execution goes + // on into `poll_and_mint`, where cdk's `check_mint_quote` resolves an id the local store has + // never seen WITHOUT a wire call and fails as an ordinary Wallet error — not IssuerMint, + // and not a refusal. Identical call, identical unknown quote, identical info document: the + // only difference is the declaration. + let (lightning_url, _lightning_seen) = recording_mint_stub(&lightning_info()); home.config.extra_mints.push(lightning_url.clone()); let control = complete_mint_async( &home, @@ -1543,20 +1546,14 @@ mod tests { ) .await .expect_err("an unknown quote id fails inside poll_and_mint"); - let seen = lightning_seen.lock().expect("recorder").clone(); assert!( matches!(control, WalletOpsError::Wallet(_)), - "control must fail past the guard, not at it: {control} (mint saw {seen:?})" + "control must fail past the guard, not at it: {control}" ); let control_message = control.to_string(); assert!( - !control_message.contains("refused") && !control_message.contains("mint info"), - "control must not be a guard refusal or an info failure: {control_message}" + !control_message.contains("refused"), + "control must not be a guard refusal: {control_message}" ); - assert!( - seen.iter().any(|path| path.trim_start_matches('/') == "v1/info"), - "the guard read the control mint's info too: {seen:?}" - ); - let _ = std::fs::remove_dir_all(&root); } }