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 01/10] 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 02/10] =?UTF-8?q?protocol:=20drop=20`cap`=20from=20the=20`?= =?UTF-8?q?issuer=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 03/10] 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 04/10] 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 05/10] 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 06/10] 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); } } From 17b42837f6aafb9b9bbb258715915a29cc47df89 Mon Sep 17 00:00:00 2001 From: w-ecash-s3a-issuer-sidecar Date: Fri, 4 Sep 2026 05:03:37 -0700 Subject: [PATCH 07/10] =?UTF-8?q?feat(ecash):=20stage=203a=20=E2=80=94=20t?= =?UTF-8?q?he=20issuer=20sidecar=20becomes=20real=20(wizard,=20counters,?= =?UTF-8?q?=20retirement,=20producer)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `issuer_mint` protocol tag (§4.2) shipped in stage 1 and nothing in production had ever produced one. This is the producer. `maxplayer issuer init` writes the sidecar's files and nothing else: a fresh BIP39 mnemonic at `/mint-seed` mode 0600 (kept, never overwritten, if one exists), `/mint/mintd-config.toml` with NO `mnemonic` key — the seed reaches cdk-mintd by `--seed-file` — and `issuer_mint` / `accepted_mints` / `extra_mints` in config.toml. It does not install, spawn or supervise the mint; it prints the command. `issuer status` reads the counters out of the mint's own sqlite, opened read-only: issued = sum(blind_signature.amount), redeemed = sum(proof.amount where state='SPENT'), outstanding = issued - redeemed. `retired` cannot come from there — the mint burns a proof without recording who presented it — so it is the seat's own durable count in `/mint/retired.jsonl`, gated by retired <= redeemed. `issuer retire` burns. Measured against cdk-mintd 0.17.2 on 4 Sep: a NUT-05 melt of a well-formed bolt11 the mint never issued, that nothing pays, moves every input proof to SPENT and signs NO new blind signature — 18 proofs / 100 sat burned with sum(blind_signature.amount) unchanged. No second mint, no Lightning. `wallet melt`'s stage-2 refusal is untouched: retirement is not a melt on the wallet surface. `heartbeat_for_state` and `retraction_for_state` take the advertisement as a REQUIRED `Option`, for the reason `admission` is required: a caller that could omit it would publish a seat whose silence is indistinguishable from one too old to speak. Both publish sites pass it. The negative is the load-bearing half. No issuer mint, a sidecar that is DOWN, a sqlite that will not open, a ledger line that will not parse, or a URL outside `accepted_mints` all yield an ABSENT tag — the beat still publishes and the seat stays on the market. Liveness is a `/v1/info` GET, not a file read: a dead mint's sqlite still reads, and publishing its stale counters with `last_seen = now` would be the wrong number §6 forbids. `send_async` and `receive_async` get the class-aware fence the rest of the product already has, so the seat can hold the currency it issues. Only the `Own` marker admits; a counterparty's declaration widens nothing. Out of scope by maxie's ruling: the two-seat loop (stage 3b, after #964). Co-Authored-By: Claude Opus 5 --- Cargo.lock | 1 + crates/maxplayer-core/Cargo.toml | 8 +- crates/maxplayer-core/src/heartbeat.rs | 142 +- crates/maxplayer-core/src/home.rs | 21 + crates/maxplayer-core/src/issuer.rs | 1245 +++++++++++++++++ crates/maxplayer-core/src/lib.rs | 5 + crates/maxplayer-core/src/seller_node/run.rs | 11 + crates/maxplayer-core/src/wallet_ops.rs | 181 ++- .../tests/issuer_sidecar_live.rs | 320 +++++ crates/maxplayer/src/cli.rs | 7 +- crates/maxplayer/src/issuer_cli.rs | 504 +++++++ crates/maxplayer/src/main.rs | 1 + 12 files changed, 2429 insertions(+), 17 deletions(-) create mode 100644 crates/maxplayer-core/src/issuer.rs create mode 100644 crates/maxplayer-core/tests/issuer_sidecar_live.rs create mode 100644 crates/maxplayer/src/issuer_cli.rs diff --git a/Cargo.lock b/Cargo.lock index 6b5eaa0e5..7bcda2985 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2583,6 +2583,7 @@ version = "0.5.6" dependencies = [ "async-trait", "base64 0.22.1", + "bip39", "bytes", "cashu", "cdk", diff --git a/crates/maxplayer-core/Cargo.toml b/crates/maxplayer-core/Cargo.toml index 5a5d0e482..c1d115784 100644 --- a/crates/maxplayer-core/Cargo.toml +++ b/crates/maxplayer-core/Cargo.toml @@ -38,7 +38,7 @@ git-delivery = ["gateway", "dep:git2", "dep:reqwest", "dep:base64"] # calls, and a reqwest async client (with `stream`, for SSE responses) forwarding to the real upstream. # It lives under `wallet` because `seller_exec` — the only caller — already does, so reqwest and # `tokio/net` are guaranteed present here. -wallet = ["git-delivery", "gateway", "dep:cashu", "dep:cdk", "dep:cdk-sqlite", "dep:nostr-sdk", "dep:rusqlite", "dep:libc", "dep:bytes", "dep:hyper", "dep:hyper-util", "dep:http-body-util", "dep:futures-util", "tokio/sync", "tokio/time", "tokio/net", "tokio/io-util", "tokio/signal"] +wallet = ["git-delivery", "gateway", "dep:bip39", "dep:cashu", "dep:cdk", "dep:cdk-sqlite", "dep:nostr-sdk", "dep:rusqlite", "dep:libc", "dep:bytes", "dep:hyper", "dep:hyper-util", "dep:http-body-util", "dep:futures-util", "tokio/sync", "tokio/time", "tokio/net", "tokio/io-util", "tokio/signal"] # TESTS ONLY, opt-in, OFF by default (issue #720). The four tests that reach a LIVE third-party mint # over the public internet — two in `job_lifecycle` (post_job's pre-publish fee floor resolves the # home's shipped minibits default) and two in `payment_wallet` (the worker's real send leg fetches @@ -56,6 +56,12 @@ test-support = [] [dependencies] base64 = { workspace = true, optional = true } +# BIP39 mnemonic generation for the issuer sidecar's mint seed (§4.2, stage 3a). `cdk-mintd` derives +# its signing keys from a phrase it reads through `--seed-file`, and `crate::issuer` is the only +# place this seat ever produces one. Already in the workspace lock transitively through cdk, so no +# new resolution; default features only — the entropy comes from `getrandom`, already a direct +# dependency, so the `rand` feature is not needed. +bip39 = { version = "2.2.2", optional = true } cashu = { version = "=0.17.2", optional = true } config.workspace = true git2 = { workspace = true, optional = true } diff --git a/crates/maxplayer-core/src/heartbeat.rs b/crates/maxplayer-core/src/heartbeat.rs index b02f4422e..24d0bddfd 100644 --- a/crates/maxplayer-core/src/heartbeat.rs +++ b/crates/maxplayer-core/src/heartbeat.rs @@ -924,6 +924,7 @@ pub fn heartbeat_for_state( agents: Vec, capability: SeatCapability, admission: crate::home::AdmissionPolicy, + issuer_mint: Option, ) -> HeartbeatDraft { // The capability arrives already derived, from `LiveRoster::Advertisement::capability()` — the // ONE route from a roster read to something emittable. Deriving it here instead would make this @@ -931,7 +932,7 @@ pub fn heartbeat_for_state( // that can drift; the fields are observed STATE (models, probed capabilities) that cannot be // recomputed from the `agents` list anyway. Callers pass names and capability from the same // single locked snapshot, so they cannot describe different rosters. - HeartbeatDraft::new( + let mut draft = HeartbeatDraft::new( in_flight == 0 && anything_serving, in_flight, rate_sats, @@ -943,7 +944,21 @@ pub fn heartbeat_for_state( // caller that could omit it would publish a seat stating no policy, which is indistinguishable // on the wire from a seat too old to have one. Both publish sites hold the `SellerConfig` this // is derived from, so neither has to reach for a default. - .with_admission(admission) + .with_admission(admission); + // REQUIRED for the same reason as `admission`, and the `Option` here is the STATED value rather + // than a defaulted one: `None` means "this seat runs no issuer mint", which is a fact the caller + // knows and this function cannot derive. A defaulted parameter would let a publish site that + // simply forgot emit a seat whose silence about its own currency is indistinguishable from a + // seat too old to speak — the exact failure mode #313 shipped. + // + // The caller is `crate::issuer::advertisement`, which is TOTAL: a sidecar that is down, a sqlite + // that will not open, a counter that will not parse and a URL outside `accepted_mints` all yield + // `None`, so the beat still publishes and the seat stays on the market (see the rule stated at + // `ISSUER_MINT_TAG` above: an optional tag must never take a working seat off the market). + if let Some(ad) = issuer_mint { + draft = draft.with_issuer_mint(ad); + } + draft } /// The seat's **terminal beat** (#747): the ordinary announcement, published one last time with @@ -982,6 +997,7 @@ pub fn retraction_for_state( agents: Vec, capability: SeatCapability, admission: crate::home::AdmissionPolicy, + issuer_mint: Option, ) -> HeartbeatDraft { // `anything_serving = false` BY CONSTRUCTION: nothing serves a seat that is leaving the role. It // is passed as a literal, not taken as a parameter, so no caller and no in-flight count can make @@ -997,6 +1013,12 @@ pub fn retraction_for_state( // market is not a claim to have changed who this seat would admit. `accepting=n` is the // field that carries "not taking work", and it is passed as a literal above. admission, + // So does the issuer advertisement, and for a sharper reason: the terminal beat REPLACES the + // seat's standing announcement in place (kind-30340 is addressable), so dropping the tag + // here would leave the seat's last public word about its own outstanding currency as + // "states nothing" — while the tokens it issued are still out there in somebody's wallet. + // A seat leaving the market still owes what it issued. + issuer_mint, ) } @@ -1846,9 +1868,101 @@ mod tests { ); } + /// The producer, both ways, read back off the EVENT rather than off the draft. + /// + /// `heartbeat_for_state` takes the advertisement as a REQUIRED parameter, so a publish site + /// cannot forget it — this pins the two answers that parameter can carry, and that the `None` + /// one is a beat the market still sees. + #[test] + fn the_beat_carries_the_issuer_advertisement_it_was_given_and_omits_it_when_given_none() { + let stated = heartbeat_for_state( + 0, + true, + 5, + mints(), + vec!["claude".into()], + cap(&["claude"]), + TEST_POLICY, + Some(issuer()), + ) + .to_event_draft(); + let tag = first_tag(&stated.tags, ISSUER_MINT_TAG).expect("issuer_mint tag"); + assert_eq!( + tag.0, + vec![ + ISSUER_MINT_TAG.to_owned(), + mints()[0].clone(), + "2500".to_owned(), + "750".to_owned(), + "1788390000".to_owned(), + ] + ); + assert_eq!( + IssuerMintAd::from_tags(&stated.tags, &mints()), + Some(issuer()), + "a reader gets back exactly what the seat stated" + ); + + let silent = heartbeat_for_state( + 0, + true, + 5, + mints(), + vec!["claude".into()], + cap(&["claude"]), + TEST_POLICY, + None, + ) + .to_event_draft(); + assert!( + first_tag(&silent.tags, ISSUER_MINT_TAG).is_none(), + "None states nothing and emits no tag" + ); + assert_eq!(IssuerMintAd::from_tags(&silent.tags, &mints()), None); + // ...and the seat is still on the market. This is the §6 rule: an optional tag must never + // take a working seat off it. + let parsed = parse_heartbeat(&silent).expect("a seat with no issuer mint still parses"); + assert!(parsed.accepting, "silence about a mint is not silence"); + assert_eq!(parsed.issuer_mint, None); + // Every OTHER tag is identical, so the advertisement is purely additive. + let strip = |draft: &EventDraft| -> Vec { + draft + .tags + .iter() + .filter(|tag| tag.first() != Some(ISSUER_MINT_TAG)) + .cloned() + .collect() + }; + assert_eq!(strip(&stated), strip(&silent)); + } + + /// A seat LEAVING the market still owes what it issued. The terminal beat replaces the seat's + /// standing announcement in place, so dropping the tag here would make the seat's permanent + /// public word about its own outstanding currency "states nothing". + #[test] + fn a_terminal_beat_still_states_what_the_seat_issued() { + let terminal = retraction_for_state( + 0, + 5, + mints(), + vec!["claude".into()], + cap(&["claude"]), + TEST_POLICY, + Some(issuer()), + ) + .to_event_draft(); + assert_eq!( + IssuerMintAd::from_tags(&terminal.tags, &mints()), + Some(issuer()) + ); + let parsed = parse_heartbeat(&terminal).expect("terminal beat parses"); + assert!(!parsed.accepting, "a terminal beat is still accepting=n"); + assert_eq!(parsed.issuer_mint, Some(issuer())); + } + #[test] fn advertises_every_harness_in_preference_order() { - let draft = heartbeat_for_state(0, true, 5, mints(), vec!["claude".into(), "codex".into()], cap(&["claude", "codex"]), TEST_POLICY) + let draft = heartbeat_for_state(0, true, 5, mints(), vec!["claude".into(), "codex".into()], cap(&["claude", "codex"]), TEST_POLICY, None) .to_event_draft(); let tag = first_tag(&draft.tags, "agents").expect("agents tag"); assert_eq!(tag.0, vec!["agents", "claude", "codex"]); @@ -1862,7 +1976,7 @@ mod tests { // A raw `agent_command` seller has no preset label, so it advertises no roster and the tag // is omitted rather than emitted empty. It IS serving (hence `true`), which is why an // unstated list must never read as dark. - let stated_none = heartbeat_for_state(0, true, 5, mints(), Vec::new(), SeatCapability::default(), TEST_POLICY).to_event_draft(); + let stated_none = heartbeat_for_state(0, true, 5, mints(), Vec::new(), SeatCapability::default(), TEST_POLICY, None).to_event_draft(); assert_eq!( stated_none, draft(true, 0, 5).with_admission(TEST_POLICY).to_event_draft() @@ -1876,7 +1990,7 @@ mod tests { #[test] fn accepting_flips_with_in_flight_state() { - let idle = heartbeat_for_state(0, true, 5, mints(), Vec::new(), SeatCapability::default(), TEST_POLICY); + let idle = heartbeat_for_state(0, true, 5, mints(), Vec::new(), SeatCapability::default(), TEST_POLICY, None); assert!(idle.accepting); assert_eq!(idle.queue_depth, 0); assert_eq!( @@ -1884,7 +1998,7 @@ mod tests { Some("y") ); - let busy = heartbeat_for_state(1, true, 5, mints(), Vec::new(), SeatCapability::default(), TEST_POLICY); + let busy = heartbeat_for_state(1, true, 5, mints(), Vec::new(), SeatCapability::default(), TEST_POLICY, None); assert!(!busy.accepting); assert_eq!(busy.queue_depth, 1); assert_eq!( @@ -1906,7 +2020,7 @@ mod tests { #[test] fn accepting_requires_a_free_slot_and_something_serving() { let accepting_of = |in_flight, serving| { - let draft = heartbeat_for_state(in_flight, serving, 5, mints(), Vec::new(), SeatCapability::default(), TEST_POLICY).to_event_draft(); + let draft = heartbeat_for_state(in_flight, serving, 5, mints(), Vec::new(), SeatCapability::default(), TEST_POLICY, None).to_event_draft(); ( first_tag_value(&draft.tags, "accepting") .expect("accepting tag") @@ -1937,7 +2051,7 @@ mod tests { #[test] fn queue_depth_is_the_depth_not_a_busy_flag() { for depth in [2_u32, 3, 17] { - let draft = heartbeat_for_state(depth, true, 5, mints(), Vec::new(), SeatCapability::default(), TEST_POLICY).to_event_draft(); + let draft = heartbeat_for_state(depth, true, 5, mints(), Vec::new(), SeatCapability::default(), TEST_POLICY, None).to_event_draft(); assert_eq!( first_tag_value(&draft.tags, "queue_depth"), Some(depth.to_string().as_str()), @@ -1953,7 +2067,7 @@ mod tests { // And the boundary that #313 got wrong in the field: nothing in flight ⇒ available, no // matter how much this seat has done in the past. The store-side half of this is // `a_store_holding_only_terminal_jobs_reports_none_in_flight`. - let free = heartbeat_for_state(0, true, 5, mints(), Vec::new(), SeatCapability::default(), TEST_POLICY).to_event_draft(); + let free = heartbeat_for_state(0, true, 5, mints(), Vec::new(), SeatCapability::default(), TEST_POLICY, None).to_event_draft(); assert_eq!(first_tag_value(&free.tags, "accepting"), Some("y")); assert_eq!(first_tag_value(&free.tags, "queue_depth"), Some("0")); } @@ -1964,7 +2078,7 @@ mod tests { #[test] fn the_terminal_beat_is_accepting_n_whatever_the_seat_was_doing() { for in_flight in [0_u32, 1, 9] { - let event = retraction_for_state(in_flight, 5, mints(), vec!["claude".into()], cap(&["claude"]), TEST_POLICY) + let event = retraction_for_state(in_flight, 5, mints(), vec!["claude".into()], cap(&["claude"]), TEST_POLICY, None) .to_event_draft(); assert_eq!( first_tag_value(&event.tags, "accepting"), @@ -1989,8 +2103,8 @@ mod tests { /// it, and the directory would go on reading the old one. #[test] fn the_terminal_beat_replaces_the_live_one_at_the_same_address() { - let live = heartbeat_for_state(0, true, 5, mints(), vec!["claude".into()], cap(&["claude"]), TEST_POLICY).to_event_draft(); - let terminal = retraction_for_state(0, 5, mints(), vec!["claude".into()], cap(&["claude"]), TEST_POLICY).to_event_draft(); + let live = heartbeat_for_state(0, true, 5, mints(), vec!["claude".into()], cap(&["claude"]), TEST_POLICY, None).to_event_draft(); + let terminal = retraction_for_state(0, 5, mints(), vec!["claude".into()], cap(&["claude"]), TEST_POLICY, None).to_event_draft(); assert_eq!(first_tag_value(&live.tags, "accepting"), Some("y")); assert_eq!(terminal.kind, live.kind, "same kind, or it is not a replacement"); @@ -2443,6 +2557,7 @@ mod tests { ], ), TEST_POLICY, + None, ) .to_event_draft(); @@ -2467,7 +2582,7 @@ mod tests { fn a_seat_that_observed_no_model_emits_no_model_tag() { // The default state of every seat before a probe has reported anything. Absent means // unstated, and nothing else on the beat shifts because of it. - let event = heartbeat_for_state(0, true, 5, mints(), vec!["claude".to_owned()], cap(&["claude"]), TEST_POLICY) + let event = heartbeat_for_state(0, true, 5, mints(), vec!["claude".to_owned()], cap(&["claude"]), TEST_POLICY, None) .to_event_draft(); assert!(harness_models_from_tags(&event.tags).is_empty()); // The POSITIVE CONTROL for the assertion above: the same event still carries the family, so @@ -2720,6 +2835,7 @@ mod tests { vec!["claude".to_owned(), "my-fork".to_owned(), "codex".to_owned()], cap(&["claude", "my-fork", "codex"]), TEST_POLICY, + None, ) .to_event_draft(); assert_eq!(agents_from_tags(&event.tags), vec!["claude", "my-fork", "codex"]); diff --git a/crates/maxplayer-core/src/home.rs b/crates/maxplayer-core/src/home.rs index 3ecc5481e..c03520729 100644 --- a/crates/maxplayer-core/src/home.rs +++ b/crates/maxplayer-core/src/home.rs @@ -1322,6 +1322,16 @@ pub struct MaxplayerConfig { /// 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, + /// Where this seat's ISSUER MINT SEED lives — the BIP39 mnemonic `cdk-mintd` derives its signing + /// keys from (`docs/protocol-v1.md` §4.2, stage 3a). Absent ⇒ `/mint-seed`, beside the seat + /// `key`. + /// + /// It is a PATH and never the phrase: the seed reaches the mint by `--seed-file`, never through + /// this file and never through the mint's own config, so nothing that prints a config can print + /// a seed. The path is named here so an operator can move the file to a volume of their choosing + /// without the sidecar and the seat disagreeing about where it is. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mint_seed_path: 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 @@ -1513,6 +1523,16 @@ impl MaxplayerConfig { .map(str::trim) .filter(|url| !url.is_empty()) } + + /// The configured issuer-mint seed PATH, or `None` for the default (`/mint-seed`). Blank + /// and whitespace-only read as `None`, the same way [`Self::issuer_mint`] does: an empty path is + /// not a location. + pub fn mint_seed_path(&self) -> Option<&str> { + self.mint_seed_path + .as_deref() + .map(str::trim) + .filter(|path| !path.is_empty()) + } } impl Default for MaxplayerConfig { @@ -1521,6 +1541,7 @@ impl Default for MaxplayerConfig { relay_url: DEFAULT_RELAY_URL.to_owned(), accepted_mints: default_accepted_mints(), issuer_mint: None, + mint_seed_path: 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/issuer.rs b/crates/maxplayer-core/src/issuer.rs new file mode 100644 index 000000000..a5d98aa63 --- /dev/null +++ b/crates/maxplayer-core/src/issuer.rs @@ -0,0 +1,1245 @@ +//! The seat's OWN Cashu mint — the stage-3a **issuer sidecar** (`docs/protocol-v1.md` §4.2 "Issuer +//! mint"). This module is the producer of the `issuer_mint` tag: it stands the sidecar's files up, +//! reads the counters back out of the mint, and hands the heartbeat an advertisement — or hands it +//! nothing at all, which is the case that matters most. +//! +//! ## What runs where +//! +//! The sidecar is `cdk-mintd` 0.17.2 in fake-wallet mode, an ordinary OS process the OPERATOR +//! starts. Nothing here installs it, spawns it or supervises it: [`init`] writes files and prints +//! the exact command, and every read below is a read of files that process owns. A seat whose +//! sidecar is not running is a seat that advertises no issuer mint, and that is a supported state, +//! not an error. +//! +//! ## The counters are read from the MINT, not from us +//! +//! §4.2 says the counters are read from the mint and `last_seen` is the instant of that read. There +//! is no API for them: the management RPC exposes 22 methods and not one of them retires a proof +//! (`cdk-mint-rpc-0.17.2/src/proto/cdk-mint-rpc.proto:6-22`). So the instrument is the mint's own +//! sqlite, opened READ-ONLY, with the definitions the 3 Sep prove-out measured: +//! +//! ```text +//! issued = sum(blind_signature.amount) +//! redeemed = sum(proof.amount where state = 'SPENT') +//! outstanding = issued - redeemed +//! ``` +//! +//! `retired_sats` is NOT in that table, and cannot be: the mint burns a proof without recording who +//! presented it, so a mint that has redeemed 100 sat cannot say whether the issuer took them back or +//! a counterparty spent them onward. Retirement is therefore the SEAT's own durable count — every +//! burn this seat performed through [`retire`], appended to [`RETIRED_LEDGER_FILE`] — and it is +//! bounded by what the mint says: `retired <= redeemed` always, because every retirement is one of +//! the redemptions the mint counted. +//! +//! ## The negative is the load-bearing half +//! +//! [`advertisement`] returns `None` — the tag is ABSENT and the beat still publishes — when the +//! seat states no issuer mint, when the sidecar is down, when the sqlite will not open, when a +//! counter will not parse, and when the URL is missing from `accepted_mints`. It never returns an +//! error, never a zero standing in for an unknown, and never anything a caller could mistake for a +//! measurement. `heartbeat.rs:289-290` is the rule: an optional tag must not be able to take a +//! working seat off the market. + +use std::fmt; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; + +use crate::heartbeat::IssuerMintAd; +use crate::home::{self, MaxplayerHome}; + +/// The sidecar's working directory under the home: sqlite, WAL and the mint's own logs. +pub const MINT_DIR: &str = "mint"; +/// The `cdk-mintd` config this seat writes. It carries NO `mnemonic` key — the seed reaches the +/// mint by `--seed-file` (`cdk-mintd-0.17.2/src/cli.rs:30`, applied `src/lib.rs:268` → +/// `apply_seed_file` `:276-286`), and `mnemonic` is `Option` in cdk's config (`config.rs:56`), so +/// omitting it is legal. +pub const MINTD_CONFIG_FILE: &str = "mintd-config.toml"; +/// The sqlite file `cdk-mintd` creates in its work dir. Named by cdk, not by us. +pub const MINT_DB_FILE: &str = "cdk-mintd.sqlite"; +/// The seat's durable retirement ledger: one JSON object per line, append-only. +pub const RETIRED_LEDGER_FILE: &str = "retired.jsonl"; +/// Default seed file name, beside the seat `key` in the home root. +pub const MINT_SEED_FILE: &str = "mint-seed"; +/// The loopback host the sidecar binds, per the owner's stage-3 input. +pub const DEFAULT_LISTEN_HOST: &str = "127.0.0.1"; +/// The port the prove-out used and the wizard defaults to. +pub const DEFAULT_LISTEN_PORT: u16 = 3338; +/// The `cdk-mintd` version this seat's config shape was measured against. +pub const CDK_MINTD_VERSION: &str = "0.17.2"; + +/// Anything that can go wrong standing up, reading or driving the sidecar. +/// +/// ⛔ No variant carries the seed, a path to a decrypted seed's CONTENTS, or a mnemonic word. The +/// leak risk is our code, not cdk's: cdk's own config `Debug` prints a sha256 of the mnemonic +/// (`cdk-mintd-0.17.2/src/config.rs:105-118`), so nothing upstream would print it for us. +#[derive(Debug)] +pub enum IssuerError { + /// The seat's config names no `issuer_mint`. + NoIssuerMint, + /// A filesystem operation failed. Carries the path and the OS error, never file CONTENTS. + Io(String), + /// The mint's sqlite would not open read-only, or a counter query failed. + Counters(String), + /// A `listen_host` this seat refuses to write. + ListenHost(String), + /// The retirement ledger holds a line that will not parse. + Ledger(String), + /// A wallet or mint operation failed. + Wallet(String), + /// The home layer refused. + Home(String), +} + +impl fmt::Display for IssuerError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NoIssuerMint => write!( + formatter, + "this seat runs no issuer mint (config.toml has no issuer_mint); run `maxplayer \ + issuer init` first" + ), + Self::Io(detail) => write!(formatter, "issuer sidecar io: {detail}"), + Self::Counters(detail) => write!(formatter, "issuer mint counters: {detail}"), + Self::ListenHost(detail) => write!(formatter, "issuer sidecar listen_host: {detail}"), + Self::Ledger(detail) => write!(formatter, "issuer retirement ledger: {detail}"), + Self::Wallet(detail) => write!(formatter, "issuer wallet: {detail}"), + Self::Home(detail) => write!(formatter, "issuer home: {detail}"), + } + } +} + +impl std::error::Error for IssuerError {} + +impl From for IssuerError { + fn from(error: home::HomeError) -> Self { + Self::Home(error.to_string()) + } +} + +/// The sidecar's work dir: `/mint`. +pub fn mint_dir(home: &MaxplayerHome) -> PathBuf { + home.root.join(MINT_DIR) +} + +/// The `cdk-mintd` config this seat writes: `/mint/mintd-config.toml`. +pub fn mintd_config_path(home: &MaxplayerHome) -> PathBuf { + mint_dir(home).join(MINTD_CONFIG_FILE) +} + +/// The mint's sqlite: `/mint/cdk-mintd.sqlite`. +pub fn mint_db_path(home: &MaxplayerHome) -> PathBuf { + mint_dir(home).join(MINT_DB_FILE) +} + +/// The seat's retirement ledger: `/mint/retired.jsonl`. +pub fn retired_ledger_path(home: &MaxplayerHome) -> PathBuf { + mint_dir(home).join(RETIRED_LEDGER_FILE) +} + +/// The mint seed file: the configured `mint_seed_path`, else `/mint-seed` beside the seat +/// `key`. Its CONTENTS are never read by anything in this module — only `cdk-mintd` reads them, and +/// only through `--seed-file`. +pub fn seed_path(home: &MaxplayerHome) -> PathBuf { + match home.config.mint_seed_path() { + Some(configured) => PathBuf::from(configured), + None => home.root.join(MINT_SEED_FILE), + } +} + +/// The counters as the MINT states them, plus the instant they were read. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub struct MintCounters { + /// `sum(blind_signature.amount)` — everything this mint ever signed. + pub issued_sats: u64, + /// `sum(proof.amount where state = 'SPENT')` — everything it ever burned, by whoever. + pub redeemed_sats: u64, + /// `issued - redeemed`. Saturating: a mint whose redeemed somehow exceeded its issued would be + /// reporting an impossibility, and 0 is the only honest floor for "tokens still out there". + pub outstanding_sats: u64, + /// Unix seconds at which the two sums above were read (§4.2: `last_seen` is the instant of the + /// read, not of the last mint activity). + pub last_seen: u64, +} + +/// Read the counters straight out of the mint's own sqlite, READ-ONLY. +/// +/// `mode=ro` is not a courtesy: this process must never be able to write the mint's ledger, and a +/// read-only handle is the enforcement rather than the intention. A WAL database that refuses a +/// read-only open is REPORTED — never worked around by relaxing a permission or copying the file, +/// because a copy would answer about a moment that has already passed. +pub fn read_counters(db_path: &Path) -> Result { + if !db_path.exists() { + return Err(IssuerError::Counters(format!( + "{} does not exist (the sidecar has not run in this work dir)", + db_path.display() + ))); + } + let connection = rusqlite::Connection::open_with_flags( + db_path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_URI, + ) + .map_err(|error| { + IssuerError::Counters(format!( + "read-only open of {} refused: {error} (not relaxing a permission and not copying the \ + file — report this)", + db_path.display() + )) + })?; + + let issued: i64 = connection + .query_row( + "select coalesce(sum(amount), 0) from blind_signature", + [], + |row| row.get(0), + ) + .map_err(|error| IssuerError::Counters(format!("issued sum: {error}")))?; + let redeemed: i64 = connection + .query_row( + "select coalesce(sum(amount), 0) from proof where state = 'SPENT'", + [], + |row| row.get(0), + ) + .map_err(|error| IssuerError::Counters(format!("redeemed sum: {error}")))?; + + let issued_sats = u64::try_from(issued) + .map_err(|_| IssuerError::Counters(format!("issued sum is negative ({issued})")))?; + let redeemed_sats = u64::try_from(redeemed) + .map_err(|_| IssuerError::Counters(format!("redeemed sum is negative ({redeemed})")))?; + + Ok(MintCounters { + issued_sats, + redeemed_sats, + outstanding_sats: issued_sats.saturating_sub(redeemed_sats), + last_seen: now_unix(), + }) +} + +/// One retirement this seat performed, as written to the ledger. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RetirementRecord { + /// Unix seconds at which the melt confirmed. + pub at: u64, + /// Sats burned — the melt quote's amount, which the mint marked PAID. + pub sats: u64, + /// The mint the burn happened at. + pub mint_url: String, + /// The mint's melt quote id, so an operator can find the row in the mint's own DB. + pub quote_id: String, +} + +/// The seat's own durable retirement total: the sum of every line in the ledger. +/// +/// A ledger that does not exist is 0 retirements, not an error — a seat that has never burned +/// anything has retired nothing. A ledger line that will NOT parse IS an error, and the caller that +/// matters ([`advertisement`]) turns that into an absent tag: publishing a total derived from a +/// ledger we could not fully read would be a wrong number, which §6 forbids more strongly than it +/// forbids silence. +pub fn retired_total(ledger_path: &Path) -> Result { + let raw = match fs::read_to_string(ledger_path) { + Ok(raw) => raw, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0), + Err(error) => { + return Err(IssuerError::Ledger(format!( + "{}: {error}", + ledger_path.display() + ))); + } + }; + let mut total: u64 = 0; + for (index, line) in raw.lines().enumerate() { + if line.trim().is_empty() { + continue; + } + let record: RetirementRecord = serde_json::from_str(line).map_err(|error| { + IssuerError::Ledger(format!( + "{} line {}: {error}", + ledger_path.display(), + index + 1 + )) + })?; + total = total.checked_add(record.sats).ok_or_else(|| { + IssuerError::Ledger(format!( + "{} line {}: retired total overflows u64", + ledger_path.display(), + index + 1 + )) + })?; + } + Ok(total) +} + +/// Append one retirement to the ledger, fsync'd. The ledger is the ONLY record that a burn was +/// OURS, so it is written before the caller reports success. +fn append_retirement(ledger_path: &Path, record: &RetirementRecord) -> Result<(), IssuerError> { + if let Some(parent) = ledger_path.parent() { + fs::create_dir_all(parent) + .map_err(|error| IssuerError::Io(format!("{}: {error}", parent.display())))?; + } + let line = serde_json::to_string(record) + .map_err(|error| IssuerError::Ledger(error.to_string()))?; + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(ledger_path) + .map_err(|error| IssuerError::Io(format!("{}: {error}", ledger_path.display())))?; + file.write_all(line.as_bytes()) + .and_then(|()| file.write_all(b"\n")) + .and_then(|()| file.sync_all()) + .map_err(|error| IssuerError::Io(format!("{}: {error}", ledger_path.display()))) +} + +/// Everything `maxplayer issuer status` prints, and everything the beat needs. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct IssuerStatus { + pub mint_url: String, + pub work_dir: String, + pub issued_sats: u64, + pub redeemed_sats: u64, + pub outstanding_sats: u64, + /// The seat's OWN count of what IT burned — see the module docs on why this cannot come from + /// the mint. + pub retired_sats: u64, + pub last_seen: u64, + /// Whether the seat's `accepted_mints` lists the issuer URL. When false the tag is UNSTATED to + /// every reader (`IssuerMintAd::from_tags` drops a URL outside the list), so a status that hid + /// this would describe a seat nobody can see. + pub in_accepted_mints: bool, +} + +/// Read the full issuer status: config, counters and the seat's retirement total. Errors are +/// RETURNED here — an operator running `issuer status` asked a direct question and gets the real +/// answer, including "the sidecar is not running". +pub fn status(home: &MaxplayerHome) -> Result { + let mint_url = home + .config + .issuer_mint() + .ok_or(IssuerError::NoIssuerMint)? + .to_owned(); + let counters = read_counters(&mint_db_path(home))?; + let retired_sats = retired_total(&retired_ledger_path(home))?; + Ok(IssuerStatus { + in_accepted_mints: home.config.accepted_mints.iter().any(|m| m == &mint_url), + mint_url, + work_dir: mint_dir(home).display().to_string(), + issued_sats: counters.issued_sats, + redeemed_sats: counters.redeemed_sats, + outstanding_sats: counters.outstanding_sats, + retired_sats, + last_seen: counters.last_seen, + }) +} + +/// How long the beat will wait for the sidecar to answer before calling it down. Loopback, so this +/// is generous: the prove-out measured cold start at 0.200 s and warm start under 0.31 s. +const LIVENESS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2); + +/// Is the sidecar actually SERVING at `mint_url`? +/// +/// ⚠ **The sqlite is not the liveness signal, and reading it alone would publish a lie.** A killed +/// `cdk-mintd` leaves its database on disk, fully readable, holding the counters as of the moment it +/// died — so a beat built from the file alone would state `outstanding = N` with `last_seen = now` +/// for a mint that has been down for a week. §6 forbids exactly that ("never a wrong number"), and +/// it names "a sidecar that is down" as a case the tag must be ABSENT for. +/// +/// So this asks the mint. NUT-06 `/v1/info` is the cheapest question that distinguishes "this port +/// is open" from "a cdk mint is serving this URL" — a plain TCP connect would be satisfied by any +/// process that happened to grab the port. Any answer other than a 2xx is DOWN. +async fn sidecar_serving(mint_url: &str) -> bool { + let info_url = format!("{}/v1/info", mint_url.trim_end_matches('/')); + let Ok(client) = reqwest::Client::builder().timeout(LIVENESS_TIMEOUT).build() else { + return false; + }; + matches!(client.get(&info_url).send().await, Ok(response) if response.status().is_success()) +} + +/// The FILE half of the advertisement: everything knowable without asking the mint whether it is up. +/// +/// Split out from [`advertisement`] so each refusal is testable on its own — a test that could only +/// reach these through a live sidecar would prove nothing about them. +fn counters_advertisement(home: &MaxplayerHome) -> Option { + let mint_url = home.config.issuer_mint()?; + if !home.config.accepted_mints.iter().any(|m| m == mint_url) { + return None; + } + let counters = read_counters(&mint_db_path(home)).ok()?; + let retired_sats = retired_total(&retired_ledger_path(home)).ok()?; + Some(IssuerMintAd { + mint_url: mint_url.to_owned(), + outstanding_sats: counters.outstanding_sats, + retired_sats, + last_seen: counters.last_seen, + }) +} + +/// The `issuer_mint` advertisement for this beat, or `None` to state nothing. +/// +/// ⛔ **This function cannot fail and must never be made to.** It is called from the publish path, +/// where every case below is an ordinary operating state: +/// +/// 1. the seat states no `issuer_mint` — it runs no mint; +/// 2. the sidecar is DOWN — nothing answers `/v1/info` at the configured URL (and this is checked +/// BEFORE the counters are read, because the file outlives the process that wrote it); +/// 3. the sqlite will not open read-only, or a counter query fails; +/// 4. the retirement ledger holds a line that will not parse; +/// 5. the URL is not in `accepted_mints` — `IssuerMintAd::from_tags` would read the tag as UNSTATED +/// anyway, so emitting it would put bytes on the wire that no reader accepts. +/// +/// Every one of them yields `None`: the tag is absent, the beat publishes, and the seat stays on +/// the market. Never a wrong number, never a boot failure (`heartbeat.rs:289-290`). +pub async fn advertisement(home: &MaxplayerHome) -> Option { + let mint_url = home.config.issuer_mint()?; + if !sidecar_serving(mint_url).await { + return None; + } + counters_advertisement(home) +} + +/// What [`init`] did, so the caller can print it without re-deriving any of it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InitReport { + pub mint_url: String, + pub work_dir: PathBuf, + pub mintd_config: PathBuf, + pub seed_path: PathBuf, + /// False when a seed file was already there. A lost mint seed is lost money-shaped state, so an + /// existing one is KEPT and said so — the same shape as the seat key (`home.rs:1636-1642`). + pub seed_created: bool, + pub added_to_accepted_mints: bool, + pub added_to_extra_mints: bool, +} + +/// Options for the wizard. `listen_host` is separate from the URL because cdk builds its bind +/// address by string concatenation and the two spellings differ (see [`validate_listen_host`]). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InitOptions { + pub listen_host: String, + pub listen_port: u16, +} + +impl Default for InitOptions { + fn default() -> Self { + Self { + listen_host: DEFAULT_LISTEN_HOST.to_owned(), + listen_port: DEFAULT_LISTEN_PORT, + } + } +} + +/// Refuse a `listen_host` cdk cannot bind, by name. +/// +/// `cdk-mintd-0.17.2/src/lib.rs:1584` builds the address as +/// `SocketAddr::from_str(&format!("{listen_addr}:{listen_port}"))`. That is string concatenation, so +/// `"::1"` becomes `"::1:3338"`, which is not valid `SocketAddr` syntax and kills the process at +/// startup with `invalid socket address syntax` — measured, 3 Sep prove-out §5. The brackets have to +/// come from the config, so this refuses the bare form and NAMES them. +pub fn validate_listen_host(host: &str) -> Result<(), IssuerError> { + let host = host.trim(); + if host.is_empty() { + return Err(IssuerError::ListenHost("must not be empty".into())); + } + if host.contains(':') && !(host.starts_with('[') && host.ends_with(']')) { + return Err(IssuerError::ListenHost(format!( + "{host:?} is an IPv6 address without brackets; cdk-mintd builds its bind address by \ + string concatenation, so write it as \"[{host}]\" — the bare form aborts the process \ + at startup with `invalid socket address syntax`" + ))); + } + Ok(()) +} + +/// The mint URL for a bound host/port. An IPv6 literal keeps its brackets in a URL too. +fn mint_url_for(host: &str, port: u16) -> String { + format!("http://{host}:{port}/") +} + +/// Render the `cdk-mintd` config this seat runs. +/// +/// The four `[ln]` bounds are NOT optional in 0.17.2 — `struct Ln` at `cdk-mintd-0.17.2/src/ +/// config.rs:170` has them at `:175-178` and only `unit` above them carries `#[serde(default)]` — +/// even though the shipped `example.config.toml` comments them out. That example does not parse. +/// +/// There is deliberately NO `mnemonic` key: the seed arrives by `--seed-file`. +fn render_mintd_config(host: &str, port: u16, url: &str) -> String { + format!( + r#"# maxplayer issuer sidecar — written by `maxplayer issuer init`. +# Run it with (the seed is NOT in this file, by design): +# cdk-mintd --work-dir --config --seed-file +# +# NOTE: cdk-mintd logs at DEBUG to a daily-rotated file under the work dir and ships no +# retention setting. Somebody has to reap /logs. + +[info] +url = "{url}" +listen_host = "{host}" +listen_port = {port} + +[info.quote_ttl] +mint_ttl = 600 +melt_ttl = 120 + +[info.http_cache] +backend = "memory" +ttl = 60 +tti = 60 + +[mint_management_rpc] +enabled = false + +[mint_info] +name = "maxplayer issuer mint" +description = "this seat's own mint: no Lightning, loopback only" + +[database] +engine = "sqlite" + +[ln] +ln_backend = "fakewallet" +unit = "sat" +# These four are REQUIRED in cdk-mintd {CDK_MINTD_VERSION} (config.rs:175-178 carry no serde(default)). +min_mint = 1 +max_mint = 500000 +min_melt = 1 +max_melt = 500000 + +[fake_wallet] +fee_percent = 0.0 +reserve_fee_min = 0 +custom_payment_methods = [] +min_delay_time = 0 +max_delay_time = 0 + +[limits] +max_inputs = 1000 +max_outputs = 1000 +"# + ) +} + +/// Generate a fresh BIP39 mnemonic and write it `0600`, or KEEP an existing one. +/// +/// ⛔ The phrase is written and never returned, never printed, never logged and never put in an +/// error message. The only reader is `cdk-mintd`, through `--seed-file`. +fn ensure_seed(path: &Path) -> Result { + if path.exists() { + return Ok(false); + } + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|error| IssuerError::Io(format!("{}: {error}", parent.display())))?; + } + let mut entropy = [0u8; 32]; + getrandom::fill(&mut entropy).map_err(|error| IssuerError::Io(error.to_string()))?; + if entropy.iter().all(|&byte| byte == 0) { + return Err(IssuerError::Io("generated all-zero mint seed entropy".into())); + } + let mnemonic = bip39::Mnemonic::from_entropy(&entropy) + .map_err(|error| IssuerError::Io(format!("mnemonic generation failed: {error}")))?; + + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options + .open(path) + .map_err(|error| IssuerError::Io(format!("{}: {error}", path.display())))?; + // `write!` rather than a `let phrase = …` binding so the words are never held in a named local + // any later line could reach for. + write!(file, "{mnemonic}\n") + .and_then(|()| file.sync_all()) + .map_err(|error| IssuerError::Io(format!("{}: {error}", path.display())))?; + Ok(true) +} + +/// The wizard: write the sidecar's files and wire this seat's config to it. LOCAL FILES ONLY — no +/// relay, no wallet, no network, and nothing is spawned or installed. +/// +/// Idempotent in every part. An existing seed is kept (see [`ensure_seed`]); the config keys are set +/// to the same values a second run would set. +/// +/// It writes THREE config keys, not two. `issuer_mint` and `accepted_mints` are what §4.2 needs for +/// the tag to be readable at all — a URL outside `accepted_mints` reads as UNSTATED +/// (`IssuerMintAd::from_tags`), so a seat with one and not the other advertises nothing. The third, +/// `extra_mints`, is what lets this seat's OWN wallet open its OWN mint: `wallet_ops:: +/// configured_mints` is `accepted_mints[0]` plus `extra_mints`, so an issuer URL appended to +/// `accepted_mints` at position 1 would never reach `open_wallet_async`, and the seat could not hold +/// or retire the currency it issues. +pub fn init(home: &mut MaxplayerHome, options: &InitOptions) -> Result { + validate_listen_host(&options.listen_host)?; + let mint_url = mint_url_for(options.listen_host.trim(), options.listen_port); + + let work_dir = mint_dir(home); + fs::create_dir_all(&work_dir) + .map_err(|error| IssuerError::Io(format!("{}: {error}", work_dir.display())))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut permissions = fs::metadata(&work_dir) + .map_err(|error| IssuerError::Io(format!("{}: {error}", work_dir.display())))? + .permissions(); + permissions.set_mode(0o700); + fs::set_permissions(&work_dir, permissions) + .map_err(|error| IssuerError::Io(format!("{}: {error}", work_dir.display())))?; + } + + let seed_path = seed_path(home); + let seed_created = ensure_seed(&seed_path)?; + + let mintd_config = mintd_config_path(home); + let rendered = render_mintd_config(options.listen_host.trim(), options.listen_port, &mint_url); + crate::durable::write_atomic(&work_dir, &mintd_config, rendered.as_bytes()) + .map_err(|error| IssuerError::Io(format!("{}: {error}", mintd_config.display())))?; + + let url_for_edit = mint_url.clone(); + let mut added_to_accepted_mints = false; + let mut added_to_extra_mints = false; + let seed_for_config = seed_path.display().to_string(); + home::save_config(home, |config| { + config.issuer_mint = Some(url_for_edit.clone()); + if !config.accepted_mints.iter().any(|m| m == &url_for_edit) { + config.accepted_mints.push(url_for_edit.clone()); + added_to_accepted_mints = true; + } + if !config.extra_mints.iter().any(|m| m == &url_for_edit) { + config.extra_mints.push(url_for_edit.clone()); + added_to_extra_mints = true; + } + config.mint_seed_path = Some(seed_for_config.clone()); + })?; + + Ok(InitReport { + mint_url, + work_dir, + mintd_config, + seed_path, + seed_created, + added_to_accepted_mints, + added_to_extra_mints, + }) +} + +/// What one [`issue`] produced. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct IssueOutcome { + pub mint_url: String, + pub issued_sats: u64, + /// The seat's wallet balance at that mint after issuance. + pub balance_sats: u64, +} + +/// The seat mints `sats` of its OWN currency at its OWN mint, into its OWN wallet. +/// +/// This is NOT `wallet fund`, and it deliberately does not go through it: `wallet_ops:: +/// begin_mint_async` refuses an issuer mint by name (`refuse_lightning_op_at_issuer`, op string +/// `"wallet fund"`), because FUNDING a mint means paying it over Lightning and an issuer mint has +/// no Lightning. Issuance is the opposite act — the seat writing its own IOU — so it has its own +/// surface, and the Lightning refusal on the wallet surface stays exactly where stage 2 put it. +/// +/// The quote's invoice is never paid by anybody: a fake-wallet mint marks its own mint quote PAID +/// on its own say-so (prove-out §7 item 7). That is the whole point — an issuer mint issues on its +/// own authority, and the "payment proof" it returns is decoration. +pub async fn issue(home: &MaxplayerHome, sats: u64) -> Result { + if sats == 0 { + return Err(IssuerError::Wallet("amount must be > 0".into())); + } + let mint_url = own_mint_url(home)?; + let wallet = crate::wallet_ops::open_wallet_async(home, &mint_url) + .await + .map_err(|error| IssuerError::Wallet(error.to_string()))?; + let quote = wallet + .mint_quote( + cdk::nuts::PaymentMethod::BOLT11, + Some(cdk::Amount::from(sats)), + None, + None, + ) + .await + .map_err(|error| IssuerError::Wallet(error.to_string()))?; + let issued_sats = crate::wallet_ops::poll_and_mint(&wallet, "e.id, sats) + .await + .map_err(|error| IssuerError::Wallet(error.to_string()))?; + let balance_sats = wallet + .total_balance() + .await + .map_err(|error| IssuerError::Wallet(error.to_string()))? + .to_u64(); + Ok(IssueOutcome { + mint_url, + issued_sats, + balance_sats, + }) +} + +/// The seat takes `sats` of its own currency back and BURNS them at its own mint. +/// +/// ## The mechanism, and exactly what it is +/// +/// Measured on this host, 4 Sep, against `cdk-mintd 0.17.2` in fake-wallet mode: a NUT-05 melt of a +/// well-formed bolt11 the mint never issued, that nothing pays, completes — and it moves every input +/// proof to `state='SPENT'` while signing NO new blind signature. 18 proofs / 100 sat went SPENT with +/// `sum(blind_signature.amount)` unchanged at 100, so outstanding fell 100 → 0. That is a burn, and +/// it is the only one available: the management RPC's 22 methods retire nothing, and a swap cannot +/// do it because `Mint::verify_transaction_balanced` requires `outputs == inputs - fee` exactly. +/// +/// So the "invoice" below is a BURN INSTRUMENT, not a payment. It is built here, in-process, from a +/// random payment hash and an ephemeral key that is discarded on the next line; nothing can ever +/// route it, nothing will ever settle it, and no second mint is involved. The fake wallet's +/// `make_payment` reads the description as a `FakeInvoiceDescription` and, failing to parse one, +/// returns `Paid` for any bolt11 it is handed (`cdk-fake-wallet-0.17.2/src/lib.rs:661-673`). +/// +/// ⛔ This is NOT `wallet melt`, and it must never be routed through it. The stage-2 refusal at +/// `wallet_ops.rs:775` stays: an operator asking to pay a Lightning invoice out of an issuer mint is +/// asking for something that cannot happen, and gets told so. Retirement is the issuer destroying +/// its own IOU, which is a different act on a different surface. +/// +/// ⚠ `cdk-fake-wallet 0.17.2` reports `total_spent = amount + 1` unconditionally +/// (`src/lib.rs:730`), so the mint logs an "Over paid … Fee was too high" line and returns NO +/// change. The recorded retirement is the melt QUOTE's amount — what the seat asked to burn and the +/// mint confirmed PAID — never that inflated figure. +pub async fn retire(home: &MaxplayerHome, sats: u64) -> Result { + if sats == 0 { + return Err(IssuerError::Wallet("amount must be > 0".into())); + } + let mint_url = own_mint_url(home)?; + let wallet = crate::wallet_ops::open_wallet_async(home, &mint_url) + .await + .map_err(|error| IssuerError::Wallet(error.to_string()))?; + + let burn_instrument = burn_instrument(sats)?; + let quote = wallet + .melt_quote( + cdk::nuts::PaymentMethod::BOLT11, + &burn_instrument, + None, + None, + ) + .await + .map_err(|error| IssuerError::Wallet(error.to_string()))?; + let quoted = quote.amount.to_u64(); + if quoted != sats { + return Err(IssuerError::Wallet(format!( + "the mint quoted {quoted} sat for a {sats} sat retirement; refusing to burn an amount \ + the seat did not choose" + ))); + } + let balance = wallet + .total_balance() + .await + .map_err(|error| IssuerError::Wallet(error.to_string()))? + .to_u64(); + let need = sats.saturating_add(quote.fee_reserve.to_u64()); + if balance < need { + return Err(IssuerError::Wallet(format!( + "insufficient own currency to retire: balance={balance} need={need} \ + (amount+fee_reserve) at {mint_url}" + ))); + } + let quote_id = quote.id.clone(); + let prepared = wallet + .prepare_melt("e.id, std::collections::HashMap::new()) + .await + .map_err(|error| IssuerError::Wallet(error.to_string()))?; + let confirmed = prepared + .confirm() + .await + .map_err(|error| IssuerError::Wallet(error.to_string()))?; + if confirmed.state() != cdk::nuts::MeltQuoteState::Paid { + return Err(IssuerError::Wallet(format!( + "retirement melt ended in state {:?}, not Paid; nothing recorded as retired", + confirmed.state() + ))); + } + + let record = RetirementRecord { + at: now_unix(), + sats, + mint_url, + quote_id, + }; + // The ledger is the ONLY durable record that this burn was OURS, so it is written before the + // caller may report success. A burn that happened and was not recorded understates `retired` + // forever; the reverse would overstate it, which is why nothing is written before `confirm`. + append_retirement(&retired_ledger_path(home), &record)?; + Ok(record) +} + +/// This seat's own issuer mint URL, or the refusal. The class-aware fence is asserted here rather +/// than assumed: `mint_class::mint_admitted` with the seat's OWN marker is what makes a loopback +/// `http://` mint passable at all, and it admits nothing else. +fn own_mint_url(home: &MaxplayerHome) -> Result { + let mint_url = home + .config + .issuer_mint() + .ok_or(IssuerError::NoIssuerMint)? + .to_owned(); + let issuers = crate::mint_class::IssuerMints::none().with_own(Some(mint_url.as_str())); + if !crate::mint_class::mint_admitted(&mint_url, home.config.allow_real_mints, &issuers) { + return Err(IssuerError::Wallet(format!( + "{mint_url} is configured as this seat's issuer_mint but does not parse as a mint URL" + ))); + } + Ok(mint_url) +} + +/// Build the burn instrument: a well-formed BOLT11 for `sats` that nothing can ever pay. +/// +/// The payment hash is 32 fresh random bytes, so it names a preimage nobody holds — including us. +/// The signing key is generated here and dropped at the end of this function, so the "node" that +/// issued it ceases to exist before the invoice is used. The description is EMPTY on purpose: the +/// fake wallet tries to parse it as a `FakeInvoiceDescription` control object and falls back to its +/// defaults when that fails, so an empty description is the one that asks for no special behaviour. +fn burn_instrument(sats: u64) -> Result { + #[allow(deprecated)] + use cdk::secp256k1::hashes::{sha256, Hash}; + use cdk::lightning_invoice::{Currency, InvoiceBuilder, PaymentSecret}; + use cdk::secp256k1::SecretKey; + + let amount_msat = sats + .checked_mul(1_000) + .ok_or_else(|| IssuerError::Wallet(format!("{sats} sat overflows msat")))?; + + let mut key_bytes = [0u8; 32]; + getrandom::fill(&mut key_bytes).map_err(|error| IssuerError::Io(error.to_string()))?; + let signing_key = SecretKey::from_slice(&key_bytes) + .map_err(|error| IssuerError::Wallet(format!("ephemeral key: {error}")))?; + + let mut hash_bytes = [0u8; 32]; + getrandom::fill(&mut hash_bytes).map_err(|error| IssuerError::Io(error.to_string()))?; + let payment_hash = sha256::Hash::from_slice(&hash_bytes) + .map_err(|error| IssuerError::Wallet(format!("payment hash: {error}")))?; + + let mut secret_bytes = [0u8; 32]; + getrandom::fill(&mut secret_bytes).map_err(|error| IssuerError::Io(error.to_string()))?; + + InvoiceBuilder::new(Currency::Bitcoin) + .description(String::new()) + .payment_hash(payment_hash) + .payment_secret(PaymentSecret(secret_bytes)) + .amount_milli_satoshis(amount_msat) + .duration_since_epoch(std::time::Duration::from_secs(now_unix())) + .min_final_cltv_expiry_delta(144) + .build_signed(|hash| cdk::SECP256K1.sign_ecdsa_recoverable(hash, &signing_key)) + .map_err(|error| IssuerError::Wallet(format!("burn instrument: {error}"))) +} + +/// Unix seconds now. A clock before the epoch yields 0 rather than a panic: a beat with a wrong +/// `last_seen` is a readable statement; a panicking publish path is not. +fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|since| since.as_secs()) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_root(tag: &str) -> PathBuf { + let root = std::env::temp_dir().join(format!( + "maxplayer-issuer-{tag}-{}-{}", + std::process::id(), + now_unix() + )); + let _ = fs::remove_dir_all(&root); + root + } + + fn home_at(root: &Path) -> MaxplayerHome { + home::bootstrap(root).expect("bootstrap") + } + + /// The bracket rule, in both directions, with the message naming the fix. + #[test] + fn an_unbracketed_ipv6_listen_host_is_refused_by_name() { + assert!(validate_listen_host("127.0.0.1").is_ok()); + assert!(validate_listen_host("[::1]").is_ok()); + assert!(validate_listen_host("localhost").is_ok()); + let refusal = validate_listen_host("::1").expect_err("bare ::1 is refused"); + let text = refusal.to_string(); + assert!(text.contains("[::1]"), "{text}"); + assert!(text.contains("invalid socket address syntax"), "{text}"); + assert!(validate_listen_host("").is_err()); + } + + /// The rendered config carries the four required `[ln]` bounds and NO mnemonic key. + #[test] + fn the_rendered_mintd_config_has_the_required_bounds_and_no_mnemonic() { + let rendered = render_mintd_config("127.0.0.1", 3338, "http://127.0.0.1:3338/"); + for required in ["min_mint", "max_mint", "min_melt", "max_melt"] { + assert!(rendered.contains(required), "missing {required}"); + } + assert!( + !rendered.contains("mnemonic"), + "the seed must never reach the config file: {rendered}" + ); + assert!(rendered.contains(r#"listen_host = "127.0.0.1""#), "{rendered}"); + assert!(rendered.contains(r#"ln_backend = "fakewallet""#), "{rendered}"); + } + + /// The wizard is idempotent and NEVER overwrites a seed. A lost mint seed is lost money-shaped + /// state; the second run must keep the first run's file byte for byte. + #[test] + fn init_keeps_an_existing_seed_and_is_idempotent() { + let root = temp_root("init"); + let mut home = home_at(&root); + let first = init(&mut home, &InitOptions::default()).expect("first init"); + assert!(first.seed_created, "a fresh home has no seed yet"); + assert!(first.added_to_accepted_mints); + assert!(first.added_to_extra_mints); + let seed_bytes = fs::read(&first.seed_path).expect("seed readable"); + + let second = init(&mut home, &InitOptions::default()).expect("second init"); + assert!(!second.seed_created, "an existing seed is KEPT"); + assert!(!second.added_to_accepted_mints, "no duplicate entry"); + assert!(!second.added_to_extra_mints, "no duplicate entry"); + assert_eq!( + seed_bytes, + fs::read(&second.seed_path).expect("seed still readable"), + "the seed file was rewritten — that is lost money-shaped state" + ); + + assert_eq!(home.config.issuer_mint(), Some("http://127.0.0.1:3338/")); + assert_eq!( + home.config + .accepted_mints + .iter() + .filter(|m| *m == "http://127.0.0.1:3338/") + .count(), + 1 + ); + let _ = fs::remove_dir_all(&root); + } + + /// The seed is `0600` and its words appear in NO artefact this seat writes. + #[test] + fn the_seed_is_owner_only_and_never_leaves_its_file() { + let root = temp_root("seed"); + let mut home = home_at(&root); + let report = init(&mut home, &InitOptions::default()).expect("init"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = fs::metadata(&report.seed_path) + .expect("seed metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600, "seed mode {mode:#o}"); + } + let phrase = fs::read_to_string(&report.seed_path).expect("seed"); + let words: Vec<&str> = phrase.split_whitespace().collect(); + assert_eq!(words.len(), 24, "256-bit mnemonic"); + // Three consecutive seed words, every window. A SINGLE word would false-positive on prose + // (the BIP39 list is ordinary English), while three in a row cannot occur by accident and + // any real leak — a whole phrase, or a truncated one — contains at least one such window. + let windows: Vec = words.windows(3).map(|w| w.join(" ")).collect(); + + for artefact in [report.mintd_config.clone(), home.root.join("config.toml")] { + let text = fs::read_to_string(&artefact).expect("artefact readable"); + assert!( + !text.contains(phrase.trim()), + "{} carries the whole seed", + artefact.display() + ); + for window in &windows { + assert!( + !text.contains(window.as_str()), + "{} carries seed words {window:?}", + artefact.display() + ); + } + } + // The status surface never prints it either — it has no field for it. + let rendered = serde_json::to_string(&IssuerStatus { + mint_url: report.mint_url.clone(), + work_dir: report.work_dir.display().to_string(), + issued_sats: 0, + redeemed_sats: 0, + outstanding_sats: 0, + retired_sats: 0, + last_seen: 0, + in_accepted_mints: true, + }) + .expect("status serializes"); + for window in &windows { + assert!(!rendered.contains(window.as_str()), "{rendered}"); + } + // And no error this module can raise carries one either: the seed path appears in errors, + // the seed's CONTENTS never do. + let refusal = ensure_seed(&report.seed_path); + assert_eq!(refusal.expect("an existing seed is kept, not an error"), false); + let _ = fs::remove_dir_all(&root); + } + + /// Build a mint-shaped sqlite with the two tables the counters read, so the QUERIES are under + /// test rather than a hand-computed number. Column shapes match what `cdk-mintd 0.17.2` creates + /// (verified against a live 0.17.2 work dir on 4 Sep: `blind_signature.amount`, `proof.amount`, + /// `proof.state`). + fn mint_db_with(path: &Path, signed: &[u64], proofs: &[(u64, &str)]) { + let connection = rusqlite::Connection::open(path).expect("create"); + connection + .execute_batch( + "create table blind_signature (amount integer not null); + create table proof (y blob, amount integer not null, state text not null);", + ) + .expect("schema"); + for amount in signed { + connection + .execute("insert into blind_signature (amount) values (?1)", [*amount]) + .expect("insert sig"); + } + for (amount, state) in proofs { + connection + .execute( + "insert into proof (y, amount, state) values (randomblob(33), ?1, ?2)", + rusqlite::params![*amount, *state], + ) + .expect("insert proof"); + } + } + + /// §5's gate, with BOTH sides printed: `outstanding == issued - redeemed` is definitional, and + /// `retired <= redeemed` is the bound that keeps the seat's own count honest — every retirement + /// is one of the redemptions the mint counted, so a seat claiming to have burned more than the + /// mint ever burned is claiming an impossibility. + #[test] + fn the_counters_balance_and_retired_never_exceeds_redeemed() { + let root = temp_root("gate"); + let mut home = home_at(&root); + init(&mut home, &InitOptions::default()).expect("init"); + // 100 issued; 40 of it redeemed (a PENDING proof is not redeemed and must not count). + mint_db_with( + &mint_db_path(&home), + &[64, 32, 4], + &[(32, "SPENT"), (8, "SPENT"), (16, "PENDING")], + ); + let counters = read_counters(&mint_db_path(&home)).expect("counters"); + println!( + "issued={} redeemed={} outstanding={}", + counters.issued_sats, counters.redeemed_sats, counters.outstanding_sats + ); + assert_eq!(counters.issued_sats, 100); + assert_eq!( + counters.redeemed_sats, 40, + "a PENDING proof is not redeemed" + ); + assert_eq!( + counters.outstanding_sats, + counters.issued_sats - counters.redeemed_sats, + "outstanding ({}) != issued ({}) - redeemed ({})", + counters.outstanding_sats, + counters.issued_sats, + counters.redeemed_sats + ); + + let ledger = retired_ledger_path(&home); + for sats in [25_u64, 15] { + append_retirement( + &ledger, + &RetirementRecord { + at: now_unix(), + sats, + mint_url: home.config.issuer_mint().expect("set").to_owned(), + quote_id: format!("q{sats}"), + }, + ) + .expect("append"); + } + let retired = retired_total(&ledger).expect("total"); + println!("retired={retired} redeemed={}", counters.redeemed_sats); + assert_eq!(retired, 40); + assert!( + retired <= counters.redeemed_sats, + "retired ({retired}) > redeemed ({})", + counters.redeemed_sats + ); + + // And the beat says the same numbers. + let ad = counters_advertisement(&home).expect("the files say so"); + assert_eq!(ad.outstanding_sats, 60); + assert_eq!(ad.retired_sats, 40); + assert_eq!(ad.mint_url, "http://127.0.0.1:3338/"); + assert!(ad.last_seen > 0, "last_seen is the instant of the read"); + + let reported = status(&home).expect("status"); + assert_eq!(reported.issued_sats, 100); + assert_eq!(reported.redeemed_sats, 40); + assert_eq!(reported.outstanding_sats, 60); + assert_eq!(reported.retired_sats, 40); + assert!(reported.in_accepted_mints); + let _ = fs::remove_dir_all(&root); + } + + /// The read is READ-ONLY, and that is enforced by the handle rather than intended by the caller. + #[test] + fn the_counter_read_cannot_write_the_mints_ledger() { + let root = temp_root("ro"); + let mut home = home_at(&root); + init(&mut home, &InitOptions::default()).expect("init"); + let db = mint_db_path(&home); + mint_db_with(&db, &[8], &[(8, "SPENT")]); + let connection = rusqlite::Connection::open_with_flags( + &db, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_URI, + ) + .expect("read-only open"); + let refusal = connection + .execute("insert into blind_signature (amount) values (1)", []) + .expect_err("a read-only handle must refuse a write"); + assert!( + refusal.to_string().to_lowercase().contains("readonly"), + "{refusal}" + ); + let _ = fs::remove_dir_all(&root); + } + + /// NEGATIVE 1 of 4 (§6): the seat states no issuer mint ⇒ no tag. + #[test] + fn a_seat_with_no_issuer_mint_advertises_nothing() { + let root = temp_root("none"); + let home = home_at(&root); + assert_eq!(home.config.issuer_mint(), None); + assert!(counters_advertisement(&home).is_none()); + let _ = fs::remove_dir_all(&root); + } + + /// NEGATIVE 2 of 4 (§6): the sidecar is DOWN — the work dir has no sqlite ⇒ no tag, no error. + #[test] + fn a_down_sidecar_advertises_nothing_and_does_not_fail() { + let root = temp_root("down"); + let mut home = home_at(&root); + init(&mut home, &InitOptions::default()).expect("init"); + assert!( + !mint_db_path(&home).exists(), + "init must not create the mint's database — only cdk-mintd does" + ); + assert!(counters_advertisement(&home).is_none()); + assert!(matches!( + status(&home), + Err(IssuerError::Counters(_)), + )); + let _ = fs::remove_dir_all(&root); + } + + /// NEGATIVE 2 of 4, the half that only a LIVE check can make: a sidecar that has run, written a + /// perfectly readable database, and then DIED. The files still answer; the mint does not. A beat + /// built from the files alone would state `outstanding` with `last_seen = now` for a mint that + /// is gone — a wrong number, which §6 forbids. + #[tokio::test] + async fn a_sidecar_that_died_leaves_readable_files_and_still_advertises_nothing() { + let root = temp_root("dead"); + let mut home = home_at(&root); + // A port nothing is listening on: bound, its number taken, then released. + let port = { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + let port = listener.local_addr().expect("addr").port(); + drop(listener); + port + }; + init( + &mut home, + &InitOptions { + listen_host: DEFAULT_LISTEN_HOST.to_owned(), + listen_port: port, + }, + ) + .expect("init"); + mint_db_with(&mint_db_path(&home), &[64, 32, 4], &[(32, "SPENT")]); + + // The FILES are entirely happy... + let from_files = counters_advertisement(&home).expect("the files read fine"); + assert_eq!(from_files.outstanding_sats, 68); + // ...and the producer still states nothing, because nothing is serving that URL. + assert!( + advertisement(&home).await.is_none(), + "a dead sidecar's stale counters must never reach the wire" + ); + let _ = fs::remove_dir_all(&root); + } + + /// NEGATIVE 3 of 4 (§6): a sqlite that will not parse as a mint DB ⇒ no tag. + #[test] + fn an_unreadable_mint_database_advertises_nothing() { + let root = temp_root("badsql"); + let mut home = home_at(&root); + init(&mut home, &InitOptions::default()).expect("init"); + fs::write(mint_db_path(&home), b"this is not a sqlite database").expect("write junk"); + assert!(read_counters(&mint_db_path(&home)).is_err()); + assert!(counters_advertisement(&home).is_none()); + let _ = fs::remove_dir_all(&root); + } + + /// NEGATIVE 4 of 4 (§6): the URL is absent from `accepted_mints` ⇒ no tag, because + /// `IssuerMintAd::from_tags` would read it as UNSTATED anyway. + #[test] + fn an_issuer_url_outside_accepted_mints_advertises_nothing() { + let root = temp_root("unlisted"); + let mut home = home_at(&root); + init(&mut home, &InitOptions::default()).expect("init"); + let url = home.config.issuer_mint().expect("set by init").to_owned(); + home::save_config(&mut home, |config| { + config.accepted_mints.retain(|m| m != &url); + }) + .expect("save"); + assert!(counters_advertisement(&home).is_none()); + // ...and the mirror: a reader would have dropped it too. + let ad = IssuerMintAd { + mint_url: url.clone(), + outstanding_sats: 7, + retired_sats: 0, + last_seen: 1, + }; + assert_eq!( + IssuerMintAd::from_tags(&[ad.to_tag()], &home.config.accepted_mints), + None + ); + let _ = fs::remove_dir_all(&root); + } + + /// A ledger line that will not parse is an ERROR to `status` and SILENCE to the beat. Both + /// halves matter: the operator asking directly gets the truth; the wire gets no wrong number. + #[test] + fn a_corrupt_retirement_ledger_is_reported_and_silences_the_tag() { + let root = temp_root("ledger"); + let mut home = home_at(&root); + init(&mut home, &InitOptions::default()).expect("init"); + let ledger = retired_ledger_path(&home); + assert_eq!(retired_total(&ledger).expect("absent is zero"), 0); + + append_retirement( + &ledger, + &RetirementRecord { + at: 1, + sats: 40, + mint_url: "http://127.0.0.1:3338/".into(), + quote_id: "q1".into(), + }, + ) + .expect("append"); + append_retirement( + &ledger, + &RetirementRecord { + at: 2, + sats: 2, + mint_url: "http://127.0.0.1:3338/".into(), + quote_id: "q2".into(), + }, + ) + .expect("append"); + assert_eq!(retired_total(&ledger).expect("sums"), 42); + + let mut raw = fs::read_to_string(&ledger).expect("read"); + raw.push_str("{not json}\n"); + fs::write(&ledger, raw).expect("corrupt"); + assert!(matches!(retired_total(&ledger), Err(IssuerError::Ledger(_)))); + assert!(counters_advertisement(&home).is_none()); + let _ = fs::remove_dir_all(&root); + } +} diff --git a/crates/maxplayer-core/src/lib.rs b/crates/maxplayer-core/src/lib.rs index 834cd7c13..f4465032a 100644 --- a/crates/maxplayer-core/src/lib.rs +++ b/crates/maxplayer-core/src/lib.rs @@ -45,6 +45,11 @@ pub mod format; pub mod gateway; pub mod heartbeat; pub mod home; +// The seat's own issuer mint (§4.2) — the stage-3a sidecar's wizard, counters and burn path. +// `wallet`-gated: the counters read the mint's sqlite (rusqlite) and the burn drives the cdk +// wallet, both of which live behind that feature. +#[cfg(feature = "wallet")] +pub mod issuer; pub mod kinds; pub mod log; // Ungated on purpose: the CLI's MCP tool table reads the long-poll cap from here on a build with diff --git a/crates/maxplayer-core/src/seller_node/run.rs b/crates/maxplayer-core/src/seller_node/run.rs index 43bf0c44f..955c10c34 100644 --- a/crates/maxplayer-core/src/seller_node/run.rs +++ b/crates/maxplayer-core/src/seller_node/run.rs @@ -4472,6 +4472,12 @@ impl SellerNodeRunner { // operator-set field would be a second place to state one fact, and the ad would drift // from the gate that enforces it. crate::home::AdmissionPolicy::from_seller_config(&seller), + // §4.2 counters, read from the MINT this tick — `last_seen` is the instant of the read. + // TOTAL by construction: a seat that runs no issuer mint, a sidecar that is down, a + // sqlite that will not open, a counter that will not parse and a URL missing from + // `accepted_mints` all answer `None`, and this beat publishes without the tag. Nothing + // here can fail the publish or take the seat off the market. + crate::issuer::advertisement(self.node.home()).await, ) .to_event_draft(); self.publish_seat_announcement(draft, "heartbeat").await @@ -4521,6 +4527,11 @@ impl SellerNodeRunner { roster.names.clone(), roster.capability(&self.node.home().config.seat), crate::home::AdmissionPolicy::from_seller_config(&seller), + // Read once more on the way out rather than dropped: this beat REPLACES the seat's + // standing announcement, so omitting the tag would make the seat's permanent public + // word about its own outstanding currency "states nothing" while that currency is still + // in somebody's wallet. + crate::issuer::advertisement(self.node.home()).await, ) .to_event_draft(); diff --git a/crates/maxplayer-core/src/wallet_ops.rs b/crates/maxplayer-core/src/wallet_ops.rs index 16e9e733d..521d48a10 100644 --- a/crates/maxplayer-core/src/wallet_ops.rs +++ b/crates/maxplayer-core/src/wallet_ops.rs @@ -657,7 +657,19 @@ pub async fn send_async( // Fail closed against the real-mint gate before opening the wallet. Operator sends are a // deliberate action OUTSIDE the job-pay budget gate (BudgetGate is deliberately not wired in // here — owner decision pending), but they must still honor `allow_real_mints`. - if !home::mint_allowed(&mint_url, home.config.allow_real_mints) { + // + // CLASS-AWARE (stage 3a): the seat's OWN issuer mint passes, because a seat that cannot send its + // own currency cannot issue one — and an issuer mint carries no sats, so the fence this widens + // was never guarding anything at that URL. `issuers` is built exactly as + // `refuse_lightning_op_at_issuer` builds it, from this seat's config alone, so ONLY the + // `Own` marker admits: a mint a counterparty merely DECLARED is `Declared`, and + // `IssuerMints::admits` answers false for it — a seller's signed tag can never open this seat's + // real-mint fence to a mint the seller cares to name. + if !crate::mint_class::mint_admitted( + &mint_url, + home.config.allow_real_mints, + &crate::mint_class::IssuerMints::none().with_own(home.config.issuer_mint()), + ) { return Err(WalletOpsError::RealMintDisallowed { mint_url }); } let wallet = open_wallet_async(home, &mint_url).await?; @@ -717,7 +729,15 @@ pub async fn receive_async( // this additionally fails closed on a real mint unless the operator opted in, the same gate // send/melt enforce. Without it a real mint left in the configured list would redeem while // `allow_real_mints == false`. - if !home::mint_allowed(&mint_url, home.config.allow_real_mints) { + // + // CLASS-AWARE (stage 3a), the mirror of `send_async`: the seat must be able to take its OWN + // currency back in, or it could issue tokens it can never hold. Built from this seat's config + // alone, so only the `Own` marker admits and a counterparty's declaration widens nothing. + if !crate::mint_class::mint_admitted( + &mint_url, + home.config.allow_real_mints, + &crate::mint_class::IssuerMints::none().with_own(home.config.issuer_mint()), + ) { return Err(WalletOpsError::RealMintDisallowed { mint_url }); } let wallet = open_wallet_async(home, &mint_url).await?; @@ -1556,4 +1576,161 @@ mod tests { "control must not be a guard refusal: {control_message}" ); } + + /// Stage 3a (§7): the seat's own wallet ops ADMIT the seat's OWN issuer mint, and nothing else + /// new. Two sites only — `send_async` and `receive_async` — and each is proved with its own + /// control, because "the fence passed" is only meaningful beside an otherwise identical mint it + /// still refuses. + /// + /// `allow_real_mints = false` throughout, so `home::mint_allowed` refuses EVERY loopback + /// `http://` URL here. The only thing that can let one through is the seat's own declaration. + /// + /// NEGATIVE, and it is the load-bearing half: the SAME URL, configured and reachable but NOT + /// declared this seat's issuer mint, is still `RealMintDisallowed`. And a URL a COUNTERPARTY + /// declared is not this seat's declaration — `IssuerMints::none().with_own(...)` is built from + /// config alone, so a `Declared` marker never reaches this predicate at all. + #[tokio::test] + async fn send_and_receive_admit_this_seats_own_issuer_mint_and_nothing_else_new() { + use cdk::nuts::{Id, MintInfo, Proof, PublicKey}; + use cdk::secret::Secret; + + fn token_at(mint_url: &str) -> String { + let keyset = Id::from_str("009a1f293253e41e").expect("a keyset id"); + let blinded = PublicKey::from_hex( + "02194603ffa36356f4a56b7df9371fc3192472351453ec7398b8da8117e7c3e104", + ) + .expect("a public key"); + let proof = Proof::new(Amount::from(1), keyset, Secret::generate(), blinded); + Token::new( + MintUrl::from_str(mint_url).expect("a mint url"), + vec![proof], + None, + CurrencyUnit::Sat, + ) + .to_string() + } + + let (issuer_url, _issuer_seen) = recording_mint_stub(&MintInfo::new()); + let (other_url, _other_seen) = recording_mint_stub(&MintInfo::new()); + + let root = temp_home("send-receive-at-issuer"); + let _ = std::fs::remove_dir_all(&root); + let mut home = bootstrap(&root).expect("bootstrap"); + // Both stubs are CONFIGURED, so `mint_is_allowed` admits both and the difference the test + // measures is the class fence alone. The real-money switch is OFF, so `home::mint_allowed` + // refuses both on its own. + home.config.extra_mints.push(issuer_url.clone()); + home.config.extra_mints.push(other_url.clone()); + home.config.allow_real_mints = false; + assert!(!home::mint_allowed(&issuer_url, false)); + assert!(!home::mint_allowed(&other_url, false)); + + // ── CONTROL, before any declaration exists: BOTH are refused by the real-mint fence. ───── + for url in [&issuer_url, &other_url] { + let refused = send_async(&home, 5, Some(url)) + .await + .expect_err("an undeclared http mint is fenced"); + assert!( + matches!(refused, WalletOpsError::RealMintDisallowed { .. }), + "expected RealMintDisallowed at {url}, got: {refused}" + ); + let refused = receive_async(&home, &token_at(url)) + .await + .expect_err("an undeclared http mint is fenced"); + assert!( + matches!(refused, WalletOpsError::RealMintDisallowed { .. }), + "expected RealMintDisallowed at {url}, got: {refused}" + ); + } + + // ── Declare ONE of them this seat's own issuer mint. Nothing else changes. ─────────────── + home.config.issuer_mint = Some(issuer_url.clone()); + + // send: past the fence, and on into the wallet, where an empty balance stops it as an + // ordinary Wallet error — not a refusal. + let sent = send_async(&home, 5, Some(&issuer_url)) + .await + .expect_err("nothing has been issued yet, so there is nothing to send"); + assert!( + matches!(sent, WalletOpsError::Wallet(_)), + "the seat's own issuer mint must pass the fence, got: {sent}" + ); + assert!( + sent.to_string().contains("insufficient funds"), + "it failed past the fence, on balance: {sent}" + ); + + // receive: past the fence too. It then fails at the mint (the stub is not a mint), which is + // exactly what "past the fence" looks like from here. + let received = receive_async(&home, &token_at(&issuer_url)) + .await + .expect_err("the stub cannot honour a fabricated proof"); + assert!( + matches!(received, WalletOpsError::Wallet(_)), + "the seat's own issuer mint must pass the fence, got: {received}" + ); + + // ── NEGATIVE: the OTHER stub — same scheme, same host, same config list — is still fenced. + let refused = send_async(&home, 5, Some(&other_url)) + .await + .expect_err("a mint this seat did not declare stays fenced"); + assert!( + matches!(refused, WalletOpsError::RealMintDisallowed { .. }), + "expected RealMintDisallowed at {other_url}, got: {refused}" + ); + let refused = receive_async(&home, &token_at(&other_url)) + .await + .expect_err("a mint this seat did not declare stays fenced"); + assert!( + matches!(refused, WalletOpsError::RealMintDisallowed { .. }), + "expected RealMintDisallowed at {other_url}, got: {refused}" + ); + + // ── NEGATIVE: a COUNTERPARTY's declaration cannot reach this predicate. The fence is built + // from `home.config.issuer_mint()` alone, so a `Declared` marker is not even constructible + // here — and if one were, `IssuerMints::admits` answers false for it. + let declared_by_someone_else = + crate::mint_class::IssuerMints::none().with_declared(Some(other_url.as_str())); + assert!(!declared_by_someone_else.admits(&other_url)); + assert!(!crate::mint_class::mint_admitted( + &other_url, + false, + &declared_by_someone_else + )); + + // ── And `wallet melt` STILL REFUSES at the seat's own issuer mint. ────────────────────── + // + // Stage 3a deliberately did NOT make this site class-aware: melting is paying a Lightning + // invoice, and an issuer mint has no Lightning, so there is nothing here for the class to + // widen. Both guards above it are still in place and the call cannot reach a mint. + // + // ⚠ WHICH guard answers is worth stating, because it is not the one stage 2's message + // suggests. The class-blind real-mint fence runs FIRST, and `home::mint_allowed` refuses + // every `http://` URL under either setting of `allow_real_mints` (it admits only `https://`, + // or the dev allow-list). So for a LOOPBACK issuer mint — the normal case, and the only one + // the wizard writes — melt refuses as `RealMintDisallowed` and the issuer-specific message + // at `refuse_lightning_op_at_issuer` is never reached. The refusal stands; only its wording + // differs. An `https://` issuer mint would get the issuer message instead. + for allow_real_mints in [false, true] { + home.config.allow_real_mints = allow_real_mints; + let melt = melt_async(&home, "lnbc1-not-a-real-invoice", Some(&issuer_url)) + .await + .expect_err("melting at an issuer mint stays refused"); + assert!( + matches!(melt, WalletOpsError::RealMintDisallowed { .. }), + "with allow_real_mints={allow_real_mints}, melt at a loopback issuer mint is \ + refused by the class-blind fence first, got: {melt}" + ); + } + home.config.allow_real_mints = false; + // The issuer-specific refusal is still WIRED — it is what answers once the URL gets past + // the real-mint fence, which only an `https://` mint does. + assert_eq!( + refuse_lightning_op_at_issuer(&home, "wallet melt", &issuer_url) + .expect_err("still refuses") + .to_string() + .contains("wallet melt refused"), + true + ); + } } diff --git a/crates/maxplayer-core/tests/issuer_sidecar_live.rs b/crates/maxplayer-core/tests/issuer_sidecar_live.rs new file mode 100644 index 000000000..4fe744988 --- /dev/null +++ b/crates/maxplayer-core/tests/issuer_sidecar_live.rs @@ -0,0 +1,320 @@ +//! Live issuer-sidecar test: ONE seat, ONE `cdk-mintd`, no counterparty. +//! +//! `#[ignore]`d because it needs a real `cdk-mintd 0.17.2` on this box, so it is not part of an +//! ordinary `cargo test` run. Run it with: +//! +//! ```text +//! CDK_MINTD_BIN=/path/to/cdk-mintd \ +//! cargo test -p maxplayer-core --features wallet --test issuer_sidecar_live -- --ignored +//! ``` +//! +//! **Why it exists at all.** Every other test of this surface asserts what is *rendered* or what a +//! hand-built sqlite says. Two of the facts stage 3a rests on cannot be reached that way: +//! +//! 1. **A melt at this mint BURNS, and signs nothing new.** `retired` is a protocol-required counter +//! and nothing in the tree could burn a proof before this. The mechanism — a NUT-05 melt of a +//! bolt11 the mint never issued, that nothing pays — is a property of `cdk-mintd`'s fake-wallet +//! backend, not of our code, and only a live mint can be asked whether it holds. A fixture that +//! asserted it would be asserting our belief about somebody else's binary. +//! 2. **A dead sidecar states nothing.** Its sqlite survives the process and reads perfectly, so the +//! only way to see the difference between "up" and "down" is to kill one. +//! +//! ⛔ NO SECOND SEAT and NO SECOND MINT. The two-seat loop is stage 3b, deferred by maxie's ruling: +//! at this base only the `Own` marker admits (`mint_class.rs:79-81`), so a counterparty reading this +//! seat's `issuer_mint` tag records it `Declared` and `home::mint_allowed` refuses a loopback +//! `http://` URL outright. Nothing here may pretend otherwise. + +// `wallet` carries `crate::issuer` (the whole surface under test), the cdk wallet the seat mints and +// burns with, and rusqlite for the counter read. Gating on it is what makes this file compile at all; +// a compiled-out test and a passing test produce the same green, so verify membership with +// `cargo test … -- --list`, never by a green tick. +#![cfg(feature = "wallet")] + +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +use maxplayer_core::gateway::TagSpec; +use maxplayer_core::heartbeat::{ + self, parse_heartbeat, IssuerMintAd, SeatCapability, ISSUER_MINT_TAG, +}; +use maxplayer_core::home::{self, AdmissionPolicy, MaxplayerHome, TargetedAdmission}; +use maxplayer_core::issuer::{self, InitOptions}; + +/// The `cdk-mintd` to exercise. Deliberately REQUIRED rather than defaulted: a default would let +/// this test silently measure whatever happened to be on PATH, and its whole purpose is to measure +/// the binary the operator names. +/// +/// It is NOT `MAXPLAYER_*`-prefixed, and must not be: the whole `MAXPLAYER_` namespace is reserved +/// for config (`home.rs:1816` lists the operational seams), and an unrecognised one is refused +/// fail-closed by `home::bootstrap` — which this very test calls. Measured: the refusal names the +/// variable and aborts before the sidecar starts. +fn cdk_mintd() -> PathBuf { + let raw = std::env::var("CDK_MINTD_BIN").expect( + "set CDK_MINTD_BIN to a cdk-mintd 0.17.2 binary (see this file's module docs)", + ); + let path = PathBuf::from(raw); + assert!(path.is_file(), "CDK_MINTD_BIN is not a file: {}", path.display()); + path +} + +/// A port nothing is listening on: bound, its number taken, then released. Two live runs in +/// parallel would otherwise collide on the wizard's default 3338. +fn free_port() -> u16 { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + let port = listener.local_addr().expect("addr").port(); + drop(listener); + port +} + +/// The sidecar, owned so it is killed even when an assertion unwinds — a leaked `cdk-mintd` would +/// hold its port and poison the next run. +struct Sidecar { + child: Child, + work_dir: PathBuf, +} + +impl Sidecar { + /// Start the mint from EXACTLY the files `issuer init` wrote — the config it rendered and the + /// seed it generated, passed by `--seed-file`. That is the point: if the wizard writes a config + /// that does not parse (0.17.2's own `example.config.toml` does not), this fails here. + async fn start(binary: &Path, report: &issuer::InitReport, url: &str) -> Self { + let child = Command::new(binary) + .arg("--work-dir") + .arg(&report.work_dir) + .arg("--config") + .arg(&report.mintd_config) + .arg("--seed-file") + .arg(&report.seed_path) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn cdk-mintd"); + let sidecar = Self { + child, + work_dir: report.work_dir.clone(), + }; + sidecar.await_serving(url).await; + sidecar + } + + /// Async on purpose: `reqwest::blocking` builds its OWN runtime, and dropping one inside a + /// `#[tokio::test]` panics ("Cannot drop a runtime in a context where blocking is not allowed"). + /// Measured here before this was written this way. + async fn await_serving(&self, url: &str) { + let info = format!("{}/v1/info", url.trim_end_matches('/')); + let deadline = Instant::now() + Duration::from_secs(20); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(2)) + .build() + .expect("http client"); + while Instant::now() < deadline { + if let Ok(response) = client.get(&info).send().await { + if response.status().is_success() { + return; + } + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + panic!( + "cdk-mintd did not serve {info} within 20s; its log is under {}/logs", + self.work_dir.display() + ); + } + + fn kill(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +impl Drop for Sidecar { + fn drop(&mut self) { + self.kill(); + } +} + +fn temp_root(tag: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "maxplayer-issuer-live-{tag}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() + )) +} + +/// One beat, built through the ONE production mapping from seat state to a beat, and read back off +/// the EVENT rather than off the draft. `heartbeat_for_state` takes the advertisement as a REQUIRED +/// parameter, so this call site cannot accidentally omit it — which is exactly why it is required. +async fn beat(home: &MaxplayerHome) -> maxplayer_core::gateway::EventDraft { + heartbeat::heartbeat_for_state( + 0, + true, + 5, + home.config.accepted_mints.clone(), + vec!["claude".to_owned()], + SeatCapability::default(), + AdmissionPolicy { + pool: true, + targeted: TargetedAdmission::Open, + }, + issuer::advertisement(home).await, + ) + .to_event_draft() +} + +/// The crate's own `first_tag` is private, and widening it for a test would be a product change +/// made for a test's convenience. This is the same lookup, spelled here. +fn first_tag<'a>(tags: &'a [TagSpec], name: &str) -> Option<&'a TagSpec> { + tags.iter().find(|tag| tag.first() == Some(name)) +} + +fn counters(home: &MaxplayerHome) -> issuer::MintCounters { + issuer::read_counters(&issuer::mint_db_path(home)).expect("counters read") +} + +/// The whole of §7, in order, against a real mint: issue, read, advertise, retire, advertise, die. +#[tokio::test] +#[ignore = "needs a local cdk-mintd 0.17.2"] +async fn one_seat_issues_advertises_retires_and_falls_silent_when_its_sidecar_dies() { + const ISSUE_SATS: u64 = 100; + const RETIRE_SATS: u64 = 40; + + let binary = cdk_mintd(); + let root = temp_root("full"); + let mut home = home::bootstrap(&root).expect("bootstrap"); + + // ── 1. `issuer init` → sidecar up on 127.0.0.1 from the written config and --seed-file ────── + let port = free_port(); + let report = issuer::init( + &mut home, + &InitOptions { + listen_host: issuer::DEFAULT_LISTEN_HOST.to_owned(), + listen_port: port, + }, + ) + .expect("issuer init"); + let url = home + .config + .issuer_mint() + .expect("init set issuer_mint") + .to_owned(); + assert!(report.seed_created, "a fresh home gets a fresh seed"); + assert!( + home.config.accepted_mints.contains(&url), + "a URL outside accepted_mints reads as UNSTATED to every reader" + ); + assert!( + !issuer::mint_db_path(&home).exists(), + "init writes files but does not create the mint's database" + ); + let mut sidecar = Sidecar::start(&binary, &report, &url).await; + + let start = counters(&home); + assert_eq!(start.issued_sats, 0); + assert_eq!(start.redeemed_sats, 0); + assert_eq!(start.outstanding_sats, 0); + + // ── 2. the seat mints N sat at its OWN mint; counters read outstanding == N ───────────────── + let issued = issuer::issue(&home, ISSUE_SATS).await.expect("issue"); + assert_eq!(issued.issued_sats, ISSUE_SATS); + assert_eq!(issued.balance_sats, ISSUE_SATS); + let after_issue = counters(&home); + println!( + "after issue: issued={} redeemed={} outstanding={}", + after_issue.issued_sats, after_issue.redeemed_sats, after_issue.outstanding_sats + ); + assert_eq!(after_issue.issued_sats, ISSUE_SATS); + assert_eq!(after_issue.redeemed_sats, 0); + assert_eq!(after_issue.outstanding_sats, ISSUE_SATS); + + // ── 3. the beat carries ["issuer_mint", url, N, 0, ts] — read off the EVENT ───────────────── + let event = beat(&home).await; + let tag = first_tag(&event.tags, ISSUER_MINT_TAG).expect("issuer_mint tag on a live beat"); + assert_eq!(tag.0[0], ISSUER_MINT_TAG); + assert_eq!(tag.0[1], url); + assert_eq!(tag.0[2], ISSUE_SATS.to_string(), "outstanding on the wire"); + assert_eq!(tag.0[3], "0", "nothing retired yet"); + let read_back = IssuerMintAd::from_tags(&event.tags, &home.config.accepted_mints) + .expect("a reader parses it back"); + assert_eq!(read_back.mint_url, url); + assert_eq!(read_back.outstanding_sats, ISSUE_SATS); + assert_eq!(read_back.retired_sats, 0); + assert!(read_back.last_seen > 0); + + // ── 4. the seat retires M; counters fall; the beat carries retired == M ───────────────────── + // + // The mechanism is the one deliverable 0 proved: a melt of a bolt11 the mint never issued, that + // nothing pays. What makes it a BURN rather than a re-issue is that no new blind signature is + // signed — so `issued` must not move. + let record = issuer::retire(&home, RETIRE_SATS).await.expect("retire"); + assert_eq!(record.sats, RETIRE_SATS); + assert_eq!(record.mint_url, url); + let after_retire = counters(&home); + println!( + "after retire: issued={} redeemed={} outstanding={} retired={}", + after_retire.issued_sats, + after_retire.redeemed_sats, + after_retire.outstanding_sats, + RETIRE_SATS + ); + assert!( + after_retire.redeemed_sats >= RETIRE_SATS, + "the mint burned at least what the seat asked to retire: redeemed={} retired={RETIRE_SATS}", + after_retire.redeemed_sats + ); + assert!( + after_retire.outstanding_sats < ISSUE_SATS, + "outstanding must FALL: before={ISSUE_SATS} after={}", + after_retire.outstanding_sats + ); + assert_eq!( + after_retire.outstanding_sats, + after_retire.issued_sats - after_retire.redeemed_sats, + "outstanding ({}) != issued ({}) - redeemed ({})", + after_retire.outstanding_sats, + after_retire.issued_sats, + after_retire.redeemed_sats + ); + // §5's gate, against a live mint: the seat cannot have retired more than the mint ever burned. + let retired_total = + issuer::retired_total(&issuer::retired_ledger_path(&home)).expect("ledger total"); + assert_eq!(retired_total, RETIRE_SATS); + assert!( + retired_total <= after_retire.redeemed_sats, + "retired ({retired_total}) > redeemed ({})", + after_retire.redeemed_sats + ); + + let event = beat(&home).await; + let read_back = IssuerMintAd::from_tags(&event.tags, &home.config.accepted_mints) + .expect("still advertising"); + assert_eq!(read_back.retired_sats, RETIRE_SATS); + assert_eq!(read_back.outstanding_sats, after_retire.outstanding_sats); + + // ── 5. sidecar killed ⇒ next beat carries NO issuer_mint tag, and the seat still advertises ── + sidecar.kill(); + // The database is STILL THERE and still readable — that is the trap this step exists to catch. + assert!(issuer::mint_db_path(&home).exists()); + assert!( + issuer::read_counters(&issuer::mint_db_path(&home)).is_ok(), + "a dead mint's sqlite still reads; the tag must be absent anyway" + ); + + let event = beat(&home).await; + assert!( + first_tag(&event.tags, ISSUER_MINT_TAG).is_none(), + "a dead sidecar must publish no issuer_mint tag" + ); + let parsed = parse_heartbeat(&event).expect("the beat still parses"); + assert!( + parsed.accepting, + "an optional tag must never take a working seat off the market" + ); + assert_eq!(parsed.issuer_mint, None); + assert!(!parsed.accepted_mints.is_empty(), "the seat is still payable"); + + let _ = std::fs::remove_dir_all(&root); +} diff --git a/crates/maxplayer/src/cli.rs b/crates/maxplayer/src/cli.rs index 4264b3a66..c039206ab 100644 --- a/crates/maxplayer/src/cli.rs +++ b/crates/maxplayer/src/cli.rs @@ -52,6 +52,11 @@ where Some("accept") => crate::accept_cli::run(&args[2..], out, err), Some("collect") => crate::collect_cli::run(&args[2..], out, err), Some("doctor") => crate::doctor::run(&args[2..], out, err), + // §4.2 "Issuer mint": this seat's OWN mint — wizard, counters, issue and retire. Ungated at + // dispatch like `doctor`, and it answers a sole `--help` from its own usage at the top of + // its `run` (issue #570); on a build without `wallet` each verb says so rather than + // vanishing, because a subcommand that silently is not there reads as a typo. + Some("issuer") => crate::issuer_cli::run(&args[2..], out, err), // INTERNAL (Track B): container-side delivery orchestrator. Not advertised in usage. Some("__deliver") => crate::deliver_cli::run(&args[2..], out, err), // Run BY the boot gate, inside the configured launcher, to report what the launcher let it @@ -324,7 +329,7 @@ fn usage(err: &mut dyn Write) -> i32 { fn write_usage(out: &mut dyn Write) { let _ = write!( out, - "Usage:\n maxplayer [--help | --version]\n maxplayer version\n maxplayer mcp\n maxplayer buyer # persistent per-home daemon (exclusive lock, unix-socket RPC); `maxplayer buyer status` = thin client\n maxplayer doctor # seller environment self-check (git, credential helper, relay, mint, agent)\n maxplayer wallet ...\n maxplayer profile set [--name ] [--about ] # publish kind-0 identity\n maxplayer whoami [--home ] # print this seat's public identity (hex pubkey, npub, resolved home)\n" + "Usage:\n maxplayer [--help | --version]\n maxplayer version\n maxplayer mcp\n maxplayer buyer # persistent per-home daemon (exclusive lock, unix-socket RPC); `maxplayer buyer status` = thin client\n maxplayer doctor # seller environment self-check (git, credential helper, relay, mint, agent)\n maxplayer issuer ... # this seat's OWN mint (§4.2): stand up the sidecar, read its counters, issue and retire\n maxplayer wallet ...\n maxplayer profile set [--name ] [--about ] # publish kind-0 identity\n maxplayer whoami [--home ] # print this seat's public identity (hex pubkey, npub, resolved home)\n" ); #[cfg(feature = "stub-pay")] let _ = write!( diff --git a/crates/maxplayer/src/issuer_cli.rs b/crates/maxplayer/src/issuer_cli.rs new file mode 100644 index 000000000..3d8485311 --- /dev/null +++ b/crates/maxplayer/src/issuer_cli.rs @@ -0,0 +1,504 @@ +//! `maxplayer issuer` — this seat's OWN Cashu mint (`docs/protocol-v1.md` §4.2 "Issuer mint"). +//! +//! Four verbs, and the boundary between them is what the seat is allowed to do to its own currency: +//! +//! - `init` writes files. LOCAL ONLY — no relay, no wallet, no network. It does not install +//! `cdk-mintd`, does not spawn it and does not supervise it: it prints the exact command and the +//! operator runs it. +//! - `status` reads the counters back out of the mint. +//! - `issue` writes the seat's own IOU into its own wallet. +//! - `retire` takes some of that IOU back and burns it. +//! +//! ⛔ **The seed is never printed here, and no line below can print it.** `init` reports the seed's +//! PATH and whether it created one; nothing in this module ever reads the file's contents, and the +//! core module that writes it never returns the phrase to a caller. + +use std::io::Write; +#[cfg(any(feature = "wallet", test))] +use std::path::PathBuf; + +#[cfg(feature = "wallet")] +use maxplayer_core::home::{self, MaxplayerHome}; +#[cfg(feature = "wallet")] +use maxplayer_core::issuer; + +const SUCCESS: i32 = 0; +const USAGE_ERROR: i32 = 1; +// Every runtime failure here comes from a wallet-gated verb; a buyer-only build reaches none of +// them, so the constant is gated with the code that can return it rather than left dead. +#[cfg(feature = "wallet")] +const RUNTIME_ERROR: i32 = 2; + +/// The install line from the 3 Sep prove-out (REPORT §1), reproduced verbatim where it matters: +/// `protoc` is a HARD build dependency of `cdk-signatory 0.17.2` even with grpc off +/// (`build.rs:14` panics without it), and it is undeclared, so an operator without it gets a build +/// failure with no hint about what is missing. +#[cfg(feature = "wallet")] +const INSTALL_HINT: &str = "\ + PROTOC=/path/to/protoc \\ + cargo install cdk-mintd --version 0.17.2 --locked \\ + --no-default-features --features fakewallet,sqlite \\ + --root + (protoc is a HARD, undeclared build dependency of cdk-signatory 0.17.2 even with grpc off)"; + +#[cfg(any(feature = "wallet", test))] +#[derive(Debug, Default)] +struct CommonOpts { + home: Option, + listen_host: Option, + listen_port: Option, + json: bool, +} + +/// Entry from `cli::run` for `maxplayer issuer ...`. +pub fn run(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 { + // #570: a sole `--help` at any level prints usage to STDOUT and exits 0 BEFORE parsing options + // or taking any side effect — no home bootstrap, no file written, no mint contacted. + if crate::cli::is_help_request(args) { + issuer_usage(out); + return SUCCESS; + } + match args.first().map(String::as_str) { + Some("init") => cmd_init(&args[1..], out, err), + Some("status") => cmd_status(&args[1..], out, err), + Some("issue") => cmd_issue(&args[1..], out, err), + Some("retire") => cmd_retire(&args[1..], out, err), + _ => { + issuer_usage(err); + USAGE_ERROR + } + } +} + +fn issuer_usage(sink: &mut dyn Write) { + let _ = writeln!( + sink, + "Usage:\n\ + \x20 maxplayer issuer init [--listen-host ] [--listen-port ] [--home ]\n\ + \x20\x20\x20# writes /mint-seed (0600), /mint/mintd-config.toml, and wires config.toml.\n\ + \x20\x20\x20# LOCAL FILES ONLY: no relay, no wallet, no network. Prints the command to run the sidecar.\n\ + \x20 maxplayer issuer status [--json] [--home ]\n\ + \x20\x20\x20# url, issued, redeemed, outstanding, retired, last_seen and the work dir, read from the mint.\n\ + \x20 maxplayer issuer issue [--home ] # mint this seat's own currency at its own mint\n\ + \x20 maxplayer issuer retire [--home ] # take that currency back and BURN it\n\ + \n\ + The sidecar is `cdk-mintd 0.17.2` in fake-wallet mode, started by YOU, never by this command.\n\ + An issuer mint has no Lightning: `wallet fund` and `wallet melt` refuse it, by design.\n\ + Exit codes: 0 success, 1 usage error, 2 runtime error" + ); +} + +#[cfg(any(feature = "wallet", test))] +fn parse_common(args: &[String]) -> Result<(CommonOpts, Vec), String> { + let mut opts = CommonOpts::default(); + let mut positional = Vec::new(); + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--home" => { + index += 1; + let value = args + .get(index) + .ok_or_else(|| "--home requires a path".to_owned())?; + opts.home = Some(PathBuf::from(value)); + } + "--listen-host" => { + index += 1; + let value = args + .get(index) + .ok_or_else(|| "--listen-host requires a host".to_owned())?; + opts.listen_host = Some(value.clone()); + } + "--listen-port" => { + index += 1; + let value = args + .get(index) + .ok_or_else(|| "--listen-port requires a port".to_owned())?; + opts.listen_port = Some( + value + .parse::() + .map_err(|_| format!("invalid port: {value}"))?, + ); + } + "--json" => opts.json = true, + flag if flag.starts_with("--") => return Err(format!("unknown flag: {flag}")), + other => positional.push(other.to_owned()), + } + index += 1; + } + Ok((opts, positional)) +} + +#[cfg(feature = "wallet")] +fn bootstrap_home(opts: &CommonOpts, err: &mut dyn Write) -> Result { + let root = match opts.home.clone() { + Some(path) => path, + None => home::default_home_dir().map_err(|error| { + let _ = writeln!(err, "{error}"); + RUNTIME_ERROR + })?, + }; + home::bootstrap(&root).map_err(|error| { + let _ = writeln!(err, "{error}"); + RUNTIME_ERROR + }) +} + +#[cfg(feature = "wallet")] +fn parse_sats(raw: &str) -> Result { + raw.parse::() + .map_err(|_| format!("invalid amount: {raw}")) + .and_then(|sats| { + if sats == 0 { + Err("amount must be > 0".into()) + } else { + Ok(sats) + } + }) +} + +#[cfg(not(feature = "wallet"))] +fn cmd_init(_args: &[String], _out: &mut dyn Write, err: &mut dyn Write) -> i32 { + let _ = writeln!(err, "maxplayer issuer requires the wallet feature"); + USAGE_ERROR +} +#[cfg(not(feature = "wallet"))] +fn cmd_status(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 { + cmd_init(args, out, err) +} +#[cfg(not(feature = "wallet"))] +fn cmd_issue(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 { + cmd_init(args, out, err) +} +#[cfg(not(feature = "wallet"))] +fn cmd_retire(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 { + cmd_init(args, out, err) +} + +#[cfg(feature = "wallet")] +fn cmd_init(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 { + let (opts, positional) = match parse_common(args) { + Ok(parsed) => parsed, + Err(error) => { + let _ = writeln!(err, "{error}"); + return USAGE_ERROR; + } + }; + if !positional.is_empty() { + let _ = writeln!(err, "issuer init takes no positional arguments"); + return USAGE_ERROR; + } + let mut options = issuer::InitOptions::default(); + if let Some(host) = opts.listen_host.clone() { + options.listen_host = host; + } + if let Some(port) = opts.listen_port { + options.listen_port = port; + } + // Refuse a host cdk cannot bind BEFORE touching the home: a bad `--listen-host` must not leave + // half a sidecar on disk. + if let Err(error) = issuer::validate_listen_host(&options.listen_host) { + let _ = writeln!(err, "{error}"); + return USAGE_ERROR; + } + + let mut home = match bootstrap_home(&opts, err) { + Ok(home) => home, + Err(code) => return code, + }; + let report = match issuer::init(&mut home, &options) { + Ok(report) => report, + Err(error) => { + let _ = writeln!(err, "{error}"); + return RUNTIME_ERROR; + } + }; + + let _ = writeln!(out, "issuer mint: {}", report.mint_url); + let _ = writeln!(out, "work dir: {}", report.work_dir.display()); + let _ = writeln!(out, "mintd config: {}", report.mintd_config.display()); + // The PATH, and whether one was created. Never the phrase. + let _ = writeln!( + out, + "mint seed: {} ({})", + report.seed_path.display(), + if report.seed_created { + "created, mode 0600 — back it up; a lost mint seed is lost money-shaped state" + } else { + "already existed — KEPT, not overwritten" + } + ); + let _ = writeln!( + out, + "config.toml: issuer_mint set{}{}", + if report.added_to_accepted_mints { + ", appended to accepted_mints" + } else { + ", already in accepted_mints" + }, + if report.added_to_extra_mints { + ", appended to extra_mints" + } else { + ", already in extra_mints" + } + ); + + let binary = which_cdk_mintd(); + let _ = writeln!(out, "\nNow start the sidecar yourself — this command does not:"); + let _ = writeln!( + out, + " {} --work-dir {} --config {} --seed-file {}", + binary.as_deref().unwrap_or("cdk-mintd"), + report.work_dir.display(), + report.mintd_config.display(), + report.seed_path.display() + ); + if binary.is_none() { + let _ = writeln!( + out, + "\ncdk-mintd is not on PATH. Build it with:\n{INSTALL_HINT}" + ); + } + let _ = writeln!( + out, + "\nNote: cdk-mintd logs at DEBUG to a daily-rotated file under {}/logs and ships no\n\ + retention setting — they grow unbounded, so reap them.", + report.work_dir.display() + ); + SUCCESS +} + +/// Whether `cdk-mintd` is on PATH, and where. A plain PATH walk: this command must not execute the +/// binary to find out (running an unknown binary to check whether it exists is a worse trade than +/// printing a hint). +#[cfg(feature = "wallet")] +fn which_cdk_mintd() -> Option { + let path = std::env::var_os("PATH")?; + std::env::split_paths(&path) + .map(|dir| dir.join("cdk-mintd")) + .find(|candidate| candidate.is_file()) + .map(|found| found.display().to_string()) +} + +#[cfg(feature = "wallet")] +fn cmd_status(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 { + let (opts, positional) = match parse_common(args) { + Ok(parsed) => parsed, + Err(error) => { + let _ = writeln!(err, "{error}"); + return USAGE_ERROR; + } + }; + if !positional.is_empty() { + let _ = writeln!(err, "issuer status takes no positional arguments"); + return USAGE_ERROR; + } + let home = match bootstrap_home(&opts, err) { + Ok(home) => home, + Err(code) => return code, + }; + let status = match issuer::status(&home) { + Ok(status) => status, + Err(error) => { + let _ = writeln!(err, "{error}"); + return RUNTIME_ERROR; + } + }; + if opts.json { + match serde_json::to_string(&status) { + Ok(json) => { + let _ = writeln!(out, "{json}"); + } + Err(error) => { + let _ = writeln!(err, "{error}"); + return RUNTIME_ERROR; + } + } + return SUCCESS; + } + let _ = writeln!(out, "url: {}", status.mint_url); + let _ = writeln!(out, "work dir: {}", status.work_dir); + let _ = writeln!(out, "issued: {} sat", status.issued_sats); + let _ = writeln!(out, "redeemed: {} sat", status.redeemed_sats); + let _ = writeln!(out, "outstanding: {} sat", status.outstanding_sats); + // Labelled as OURS, because the mint cannot attribute a burn to whoever presented the proofs. + let _ = writeln!( + out, + "retired: {} sat (this seat's own count; the mint cannot attribute a burn)", + status.retired_sats + ); + let _ = writeln!(out, "last_seen: {}", status.last_seen); + if !status.in_accepted_mints { + let _ = writeln!( + out, + "\nWARNING: {} is NOT in accepted_mints, so every reader treats this seat's\n\ + issuer_mint tag as UNSTATED and the beat omits it. Run `maxplayer issuer init`.", + status.mint_url + ); + } + SUCCESS +} + +#[cfg(feature = "wallet")] +fn cmd_issue(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 { + let (opts, positional, sats) = match parse_amount_command(args, "issue", err) { + Ok(parsed) => parsed, + Err(code) => return code, + }; + let _ = positional; + let home = match bootstrap_home(&opts, err) { + Ok(home) => home, + Err(code) => return code, + }; + let runtime = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(runtime) => runtime, + Err(error) => { + let _ = writeln!(err, "issuer issue runtime: {error}"); + return RUNTIME_ERROR; + } + }; + match runtime.block_on(issuer::issue(&home, sats)) { + Ok(outcome) => { + let _ = writeln!( + out, + "issued {} sat at {} (wallet balance {} sat)", + outcome.issued_sats, outcome.mint_url, outcome.balance_sats + ); + SUCCESS + } + Err(error) => { + let _ = writeln!(err, "{error}"); + RUNTIME_ERROR + } + } +} + +#[cfg(feature = "wallet")] +fn cmd_retire(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 { + let (opts, positional, sats) = match parse_amount_command(args, "retire", err) { + Ok(parsed) => parsed, + Err(code) => return code, + }; + let _ = positional; + let home = match bootstrap_home(&opts, err) { + Ok(home) => home, + Err(code) => return code, + }; + let runtime = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(runtime) => runtime, + Err(error) => { + let _ = writeln!(err, "issuer retire runtime: {error}"); + return RUNTIME_ERROR; + } + }; + match runtime.block_on(issuer::retire(&home, sats)) { + Ok(record) => { + let _ = writeln!( + out, + "retired {} sat at {} (melt quote {}); recorded in {}", + record.sats, + record.mint_url, + record.quote_id, + issuer::retired_ledger_path(&home).display() + ); + SUCCESS + } + Err(error) => { + let _ = writeln!(err, "{error}"); + RUNTIME_ERROR + } + } +} + +/// Shared parse for the two amount-taking verbs. +#[cfg(feature = "wallet")] +fn parse_amount_command( + args: &[String], + verb: &str, + err: &mut dyn Write, +) -> Result<(CommonOpts, Vec, u64), i32> { + let (opts, positional) = parse_common(args).map_err(|error| { + let _ = writeln!(err, "{error}"); + USAGE_ERROR + })?; + let [raw] = positional.as_slice() else { + let _ = writeln!(err, "usage: maxplayer issuer {verb} [--home ]"); + return Err(USAGE_ERROR); + }; + let sats = parse_sats(raw).map_err(|error| { + let _ = writeln!(err, "{error}"); + USAGE_ERROR + })?; + Ok((opts, positional.clone(), sats)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A sole `--help` prints usage to STDOUT and exits 0, at every level, before any side effect. + #[test] + fn help_is_answered_from_our_own_usage_before_anything_happens() { + for args in [ + vec!["--help".to_owned()], + vec!["init".to_owned(), "--help".to_owned()], + vec!["status".to_owned(), "--help".to_owned()], + vec!["retire".to_owned(), "--help".to_owned()], + ] { + let mut out = Vec::new(); + let mut err = Vec::new(); + assert_eq!(run(&args, &mut out, &mut err), SUCCESS, "{args:?}"); + let text = String::from_utf8(out).expect("utf8"); + assert!(text.contains("maxplayer issuer init"), "{text}"); + assert!(err.is_empty(), "usage went to stderr for {args:?}"); + } + } + + /// Usage names every verb and never suggests this command runs the sidecar. + #[test] + fn usage_names_the_verbs_and_disclaims_supervision() { + let mut out = Vec::new(); + issuer_usage(&mut out); + let text = String::from_utf8(out).expect("utf8"); + for verb in ["init", "status", "issue", "retire"] { + assert!(text.contains(&format!("maxplayer issuer {verb}")), "{text}"); + } + assert!(text.contains("started by YOU, never by this command"), "{text}"); + } + + #[test] + fn an_unknown_verb_is_a_usage_error() { + let mut out = Vec::new(); + let mut err = Vec::new(); + assert_eq!( + run(&["burn-it-all".to_owned()], &mut out, &mut err), + USAGE_ERROR + ); + assert!(out.is_empty()); + } + + #[test] + fn flags_parse_and_an_unknown_one_is_named() { + let (opts, positional) = parse_common(&[ + "--listen-port".to_owned(), + "3400".to_owned(), + "--listen-host".to_owned(), + "[::1]".to_owned(), + "17".to_owned(), + ]) + .expect("parses"); + assert_eq!(opts.listen_port, Some(3400)); + assert_eq!(opts.listen_host.as_deref(), Some("[::1]")); + assert_eq!(positional, vec!["17".to_owned()]); + assert_eq!( + parse_common(&["--nope".to_owned()]).expect_err("unknown flag"), + "unknown flag: --nope" + ); + } +} diff --git a/crates/maxplayer/src/main.rs b/crates/maxplayer/src/main.rs index 146a39074..2609b2492 100644 --- a/crates/maxplayer/src/main.rs +++ b/crates/maxplayer/src/main.rs @@ -11,6 +11,7 @@ mod deliver_cli; mod daemon; mod doctor; mod exec; +mod issuer_cli; mod mcp; mod profile_cli; // The `sell` surface is the seller advertise path: it publishes the kind-0 identity and boots the From 1d7869f35d00822408c3fe6cdf46e8e4f67bafd0 Mon Sep 17 00:00:00 2001 From: w-ecash-s3a-issuer-sidecar Date: Fri, 4 Sep 2026 06:10:02 -0700 Subject: [PATCH 08/10] issuer: validate an EXISTING mint seed instead of trusting it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ensure_seed` took `path.exists()` as the whole answer, so the 0600 promise only ever covered a seed this seat created. A file some earlier hand left at 0644, or a symlink pointing somewhere the seat does not own, was adopted silently and the wizard still reported success. Now an existing path is checked before it is kept: - Anything that is not a regular file is refused by name, symlink included. The check is `symlink_metadata`, which does not follow links — plain `metadata` reports the TARGET's type and would let a link through. A symlink is refused even when its target is a fine 0600 file, because the seat cannot promise the mode of a path it does not own. - On Unix a mode with any bit beyond owner read+write (`mode & 0o177`) is narrowed to 0600 in place. `set_permissions` does not open the file, so no byte is read or rewritten. A mode already tighter than 0600 is left alone. The phrase stays unread, unreturned, unlogged: the added errors name the path and the file type and never open the file. Two regressions, both proved load-bearing against the previous body (they FAIL on it, PASS here): - `an_existing_over_readable_seed_is_narrowed_to_0600_without_touching_its_bytes` plants a 0644 non-mnemonic file, then asserts the bytes and the mtime are unchanged and the final mode is 0600. - `a_seed_path_that_is_not_a_regular_file_is_refused` covers the symlink and the directory branches and checks nothing was written through the link. `init_keeps_an_existing_seed_and_is_idempotent` is untouched — it reuses the already-safe file the first init wrote, which is exactly why it could not catch this. --- crates/maxplayer-core/src/issuer.rs | 157 +++++++++++++++++++++++++++- 1 file changed, 153 insertions(+), 4 deletions(-) diff --git a/crates/maxplayer-core/src/issuer.rs b/crates/maxplayer-core/src/issuer.rs index a5d98aa63..4674d9791 100644 --- a/crates/maxplayer-core/src/issuer.rs +++ b/crates/maxplayer-core/src/issuer.rs @@ -519,13 +519,69 @@ max_outputs = 1000 ) } -/// Generate a fresh BIP39 mnemonic and write it `0600`, or KEEP an existing one. +/// Generate a fresh BIP39 mnemonic and write it `0600`, or KEEP an existing one — after checking +/// that what is already there deserves to be kept. +/// +/// An existing path is VALIDATED, never trusted. `0600` on the file this function creates says +/// nothing about a file some earlier hand left behind, and the earlier version of this function +/// took `path.exists()` as the whole answer: a `0644` seed, or a symlink pointing at a file the +/// operator never meant to be a seed, was adopted silently. So: +/// +/// - Anything that is not a regular file is REFUSED, symlink included. The check uses +/// [`fs::symlink_metadata`], which does not follow links — `metadata` would report the target's +/// type and let a link through. A symlink is refused even when its target is a fine `0600` file, +/// because the seat cannot promise the mode of a path it does not own. +/// - On Unix an over-broad mode is narrowed to `0600` in place. Only the mode changes: +/// [`fs::set_permissions`] does not open the file for writing and no byte is read or rewritten. +/// A mode already at or tighter than `0600` (`0400`, say) is left alone — the fix is for +/// permissions that are too broad, not for an operator who chose to be stricter. /// /// ⛔ The phrase is written and never returned, never printed, never logged and never put in an -/// error message. The only reader is `cdk-mintd`, through `--seed-file`. +/// error message — including the errors this validation adds, which name the path and the file +/// type and never open the file. fn ensure_seed(path: &Path) -> Result { - if path.exists() { - return Ok(false); + match fs::symlink_metadata(path) { + Ok(metadata) => { + let file_type = metadata.file_type(); + if !file_type.is_file() { + let kind = if file_type.is_symlink() { + "a symlink" + } else if file_type.is_dir() { + "a directory" + } else { + "not a regular file" + }; + return Err(IssuerError::Io(format!( + "{}: the mint seed path is {kind}; refusing to adopt it as this seat's seed. \ + Move it aside and re-run, or point the seat at a different home.", + path.display(), + ))); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + // 0o177 is every bit BEYOND owner read+write: owner-execute, and all of group and + // other. Non-zero means the file is readable, writable or executable by someone + // this seat does not answer for. + let mode = metadata.permissions().mode() & 0o7777; + if mode & 0o177 != 0 { + fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err( + |error| { + IssuerError::Io(format!( + "{}: found mode {mode:04o} on an existing mint seed and could not \ + narrow it to 0600: {error}", + path.display(), + )) + }, + )?; + } + } + return Ok(false); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(IssuerError::Io(format!("{}: {error}", path.display()))); + } } if let Some(parent) = path.parent() { fs::create_dir_all(parent) @@ -917,6 +973,99 @@ mod tests { let _ = fs::remove_dir_all(&root); } + /// A seed that was ALREADY on disk, and already too readable, is narrowed — not rewritten. + /// + /// `init_keeps_an_existing_seed_and_is_idempotent` cannot catch this: its "existing" seed is + /// the one the first `init` wrote, which was born `0600`, so the keep path is never asked to + /// judge a file it did not create. This starts from a `0644` file some earlier hand left and + /// asserts the two halves separately — the bytes are IDENTICAL (a seed is money-shaped state; + /// rewriting it is losing it) and the mode ends at `0600`. + #[cfg(unix)] + #[test] + fn an_existing_over_readable_seed_is_narrowed_to_0600_without_touching_its_bytes() { + use std::os::unix::fs::PermissionsExt; + + let root = temp_root("seed-mode"); + let mut home = home_at(&root); + let path = seed_path(&home); + + // Not a real mnemonic on purpose: this test must never depend on, or produce, a usable + // seed. What is under test is custody of the bytes, whatever they are. + let planted = b"planted seed bytes, not a mnemonic\n"; + fs::write(&path, planted).expect("plant a seed"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).expect("make it too broad"); + let before = fs::metadata(&path).expect("planted metadata"); + let mtime_before = before.modified().expect("planted mtime"); + assert_eq!(before.permissions().mode() & 0o777, 0o644, "precondition"); + + let report = init(&mut home, &InitOptions::default()).expect("init over a planted seed"); + assert!(!report.seed_created, "an existing seed is KEPT, never regenerated"); + + assert_eq!( + fs::read(&path).expect("seed still readable"), + planted, + "the seed bytes changed — set_permissions must not rewrite the file" + ); + let after = fs::metadata(&path).expect("metadata after init"); + assert_eq!( + after.permissions().mode() & 0o777, + 0o600, + "mode after init was {:04o}, not 0600", + after.permissions().mode() & 0o777 + ); + assert_eq!( + after.modified().expect("mtime after"), + mtime_before, + "the file's contents were touched, not just its mode" + ); + + let _ = fs::remove_dir_all(&root); + } + + /// A seed PATH that is not a regular file is refused by name, and nothing is written through it. + /// + /// A symlink is refused even though its target here is a perfectly good `0600` file: the seat + /// cannot promise the mode, or the identity, of a path it does not own. `symlink_metadata` is + /// what makes this reachable — plain `metadata` would report the target and let the link pass. + #[cfg(unix)] + #[test] + fn a_seed_path_that_is_not_a_regular_file_is_refused() { + use std::os::unix::fs::PermissionsExt; + + let root = temp_root("seed-type"); + let mut home = home_at(&root); + let path = seed_path(&home); + + let target = root.join("elsewhere"); + fs::write(&target, b"target bytes\n").expect("write link target"); + fs::set_permissions(&target, fs::Permissions::from_mode(0o600)).expect("0600 target"); + std::os::unix::fs::symlink(&target, &path).expect("symlink into the seed path"); + + let refusal = init(&mut home, &InitOptions::default()).expect_err("a symlink is refused"); + let text = refusal.to_string(); + assert!(text.contains("symlink"), "the refusal must name what it found: {text}"); + assert!( + text.contains(&path.display().to_string()), + "the refusal must name the path: {text}" + ); + assert_eq!( + fs::read(&target).expect("target still readable"), + b"target bytes\n", + "the refusal wrote through the link" + ); + + // And a directory in the same place is refused too, by a different branch. + fs::remove_file(&path).expect("remove the symlink"); + fs::create_dir(&path).expect("plant a directory"); + let refusal = init(&mut home, &InitOptions::default()).expect_err("a directory is refused"); + assert!( + refusal.to_string().contains("directory"), + "{refusal}" + ); + + let _ = fs::remove_dir_all(&root); + } + /// The seed is `0600` and its words appear in NO artefact this seat writes. #[test] fn the_seed_is_owner_only_and_never_leaves_its_file() { From a22c554c9dbe8d718d01fd633378b557da8cec52 Mon Sep 17 00:00:00 2001 From: w-ecash-s3a-issuer-sidecar Date: Fri, 4 Sep 2026 06:54:21 -0700 Subject: [PATCH 09/10] issuer: the seed mode check must judge the bits it reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing-seed narrowing read the mode with `& 0o7777` and then tested it against `0o177`. Those masks disagree: `0o4600 & 0o177 == 0`, so a regular seed file carrying setuid scored clean, the branch was skipped, and the bit survived on money-shaped state while the code reported the file narrowed to 0600. The regression could not see it either. It asserted `mode & 0o777 == 0o600`, and `0o4600 & 0o777 == 0o600` passes. Both mistakes are one mistake — a mask narrower than the value being judged. - The disallowed set is now spelled `const DISALLOWED: u32 = 0o7000 | 0o177`: the special bits (setuid, setgid, sticky) as well as everything below owner read+write. `0600` is the one mode with neither, so `mode & DISALLOWED != 0` is exactly "not 0600 or tighter". Masking the read down to `0o777` instead would have hidden the bit rather than cleared it — the check has to see what `set_permissions` is about to overwrite. - Both mode regressions now assert `& 0o7777`, never `& 0o777`. - `an_existing_setuid_seed_has_its_special_bits_cleared` plants a regular file at `04600` and asserts bytes unchanged, mtime unchanged, and `mode & 0o7777 == 0o600`. It also asserts the two properties of the old code that made this invisible, so the hole cannot be reopened quietly. - The `ensure_seed` doc comment now describes the mask the code actually uses. Everything already passing is unchanged: symlink refusal through `symlink_metadata`, refusal by name and file type, no byte read or rewritten, a mode already tighter than 0600 left alone, and `init_keeps_an_existing_seed_and_is_idempotent` untouched. Proved load-bearing against the previous body: the new test FAILS there, leaving the mode at decimal 2432 (0o4600) against an expected 384 (0o600), and passes here. The other 14 tests pass in both arms. --- crates/maxplayer-core/src/issuer.rs | 104 +++++++++++++++++++++++++--- 1 file changed, 93 insertions(+), 11 deletions(-) diff --git a/crates/maxplayer-core/src/issuer.rs b/crates/maxplayer-core/src/issuer.rs index 4674d9791..16bdf58d0 100644 --- a/crates/maxplayer-core/src/issuer.rs +++ b/crates/maxplayer-core/src/issuer.rs @@ -531,10 +531,14 @@ max_outputs = 1000 /// [`fs::symlink_metadata`], which does not follow links — `metadata` would report the target's /// type and let a link through. A symlink is refused even when its target is a fine `0600` file, /// because the seat cannot promise the mode of a path it does not own. -/// - On Unix an over-broad mode is narrowed to `0600` in place. Only the mode changes: -/// [`fs::set_permissions`] does not open the file for writing and no byte is read or rewritten. -/// A mode already at or tighter than `0600` (`0400`, say) is left alone — the fix is for -/// permissions that are too broad, not for an operator who chose to be stricter. +/// - On Unix a mode carrying any bit outside owner read+write is narrowed to `0600` in place — +/// the special bits `0o7000` (setuid, setgid, sticky) as well as the `0o177` below owner rw. +/// Only the mode changes: [`fs::set_permissions`] does not open the file for writing and no +/// byte is read or rewritten. A mode already at or tighter than `0600` (`0400`, say) is left +/// alone — the fix is for permissions that are too broad, not for an operator who chose to be +/// stricter. The first cut of this check masked the read with `0o7777` and then tested `0o177`, +/// so a seed at `04600` scored 0 and kept its setuid bit; that is why the mask below is spelled +/// out and why the regressions assert against `0o7777`, never `0o777`. /// /// ⛔ The phrase is written and never returned, never printed, never logged and never put in an /// error message — including the errors this validation adds, which name the path and the file @@ -560,11 +564,19 @@ fn ensure_seed(path: &Path) -> Result { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - // 0o177 is every bit BEYOND owner read+write: owner-execute, and all of group and - // other. Non-zero means the file is readable, writable or executable by someone - // this seat does not answer for. + // DISALLOWED is every bit a seat-owned seed may not carry: 0o7000 the special + // bits (setuid, setgid, sticky) and 0o177 everything below owner read+write + // (owner-execute, and all of group and other). `0o600` is the one mode with + // neither, so `mode & DISALLOWED != 0` is exactly "not 0600 or tighter". + // + // The special bits are in the mask because they are read out of it: `mode` below + // is taken with `& 0o7777`, so a seed at `04600` yields 0o4600, and a mask of + // 0o177 alone would score that 0 and leave the setuid bit standing on a file + // holding a mint seed. Masking the read down to 0o777 instead would hide the bit + // rather than clear it — the check must see what set_permissions will overwrite. + const DISALLOWED: u32 = 0o7000 | 0o177; let mode = metadata.permissions().mode() & 0o7777; - if mode & 0o177 != 0 { + if mode & DISALLOWED != 0 { fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err( |error| { IssuerError::Io(format!( @@ -980,6 +992,11 @@ mod tests { /// judge a file it did not create. This starts from a `0644` file some earlier hand left and /// asserts the two halves separately — the bytes are IDENTICAL (a seed is money-shaped state; /// rewriting it is losing it) and the mode ends at `0600`. + /// + /// Every mode assertion here masks `0o7777`, never `0o777`. A `0o777` mask cannot witness the + /// special bits at all: it scores `04600` as `0600` and calls a setuid seed narrowed. That is + /// the exact hole the first cut of this test left open, and + /// [`an_existing_setuid_seed_has_its_special_bits_cleared`] is the case that walks through it. #[cfg(unix)] #[test] fn an_existing_over_readable_seed_is_narrowed_to_0600_without_touching_its_bytes() { @@ -996,7 +1013,7 @@ mod tests { fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).expect("make it too broad"); let before = fs::metadata(&path).expect("planted metadata"); let mtime_before = before.modified().expect("planted mtime"); - assert_eq!(before.permissions().mode() & 0o777, 0o644, "precondition"); + assert_eq!(before.permissions().mode() & 0o7777, 0o644, "precondition"); let report = init(&mut home, &InitOptions::default()).expect("init over a planted seed"); assert!(!report.seed_created, "an existing seed is KEPT, never regenerated"); @@ -1008,10 +1025,75 @@ mod tests { ); let after = fs::metadata(&path).expect("metadata after init"); assert_eq!( - after.permissions().mode() & 0o777, + after.permissions().mode() & 0o7777, 0o600, "mode after init was {:04o}, not 0600", - after.permissions().mode() & 0o777 + after.permissions().mode() & 0o7777 + ); + assert_eq!( + after.modified().expect("mtime after"), + mtime_before, + "the file's contents were touched, not just its mode" + ); + + let _ = fs::remove_dir_all(&root); + } + + /// A REGULAR seed carrying a special bit is narrowed too — `04600` must not survive `init`. + /// + /// This is the class the first cut of the check could not see. It masked the read with + /// `0o7777` and then tested `mode & 0o177`, and `0o4600 & 0o177 == 0`, so the branch was + /// skipped and the setuid bit stood on a file holding a mint seed. The sibling regression + /// could not catch it either, because it asserted `mode & 0o777 == 0o600` and + /// `0o4600 & 0o777 == 0o600` passes. Both mistakes are the same mistake — a mask narrower + /// than the value being judged — so this test asserts `0o7777` end to end. + /// + /// `04600` is a regular file, not a special one: `symlink_metadata().file_type().is_file()` + /// is true for it, so it reaches the mode branch rather than the refusal branch. Setuid on a + /// non-executable data file grants nothing by itself; it is cleared because a seat that + /// promises "0600" must not leave a bit it never inspected on money-shaped state. + #[cfg(unix)] + #[test] + fn an_existing_setuid_seed_has_its_special_bits_cleared() { + use std::os::unix::fs::PermissionsExt; + + let root = temp_root("seed-setuid"); + let mut home = home_at(&root); + let path = seed_path(&home); + + let planted = b"planted seed bytes, not a mnemonic\n"; + fs::write(&path, planted).expect("plant a seed"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o4600)).expect("plant setuid 04600"); + let before = fs::metadata(&path).expect("planted metadata"); + let mtime_before = before.modified().expect("planted mtime"); + assert_eq!( + before.permissions().mode() & 0o7777, + 0o4600, + "precondition: the plant must really carry the setuid bit" + ); + assert!( + before.file_type().is_file(), + "precondition: 04600 is a REGULAR file, so it reaches the mode branch" + ); + // The trap, asserted so it can never be re-introduced silently: under the old 0o177 mask + // this mode scores zero, and under a 0o777 assertion it reads as already-correct. + assert_eq!(0o4600 & 0o177, 0, "the old mask really was blind to this"); + assert_eq!(0o4600 & 0o777, 0o600, "the old assertion really did pass this"); + + let report = init(&mut home, &InitOptions::default()).expect("init over a setuid seed"); + assert!(!report.seed_created, "an existing seed is KEPT, never regenerated"); + + assert_eq!( + fs::read(&path).expect("seed still readable"), + planted, + "the seed bytes changed — set_permissions must not rewrite the file" + ); + let after = fs::metadata(&path).expect("metadata after init"); + assert_eq!( + after.permissions().mode() & 0o7777, + 0o600, + "mode after init was {:04o}, not 0600 — the special bits survived", + after.permissions().mode() & 0o7777 ); assert_eq!( after.modified().expect("mtime after"), From e0a36d5e129b02ef84f90af5c85dc0d28e925b04 Mon Sep 17 00:00:00 2001 From: w-ecash-s3a-issuer-sidecar Date: Fri, 4 Sep 2026 07:25:02 -0700 Subject: [PATCH 10/10] issuer: the last file-mode assertion masks the whole mode word MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `the_seed_is_owner_only_and_never_leaves_its_file` read a real seed file's mode and masked it with `0o777` before asserting `0o600`. Every other mode assertion in this module moved to `0o7777` with the mask fix; this one was missed. No production hole follows and the assertion was never wrong: that seed is created fresh by `init` in the same test, so no special bit can be present and the two masks cannot disagree there. It was weaker than it claimed to be, which is the entire defect — a file-mode assertion masks the whole mode word so the special bits are witnessed rather than discarded. `:1081` is deliberately left at `0o777`. It is arithmetic about the OLD mask (`0o4600 & 0o777 == 0o600`), asserted so the trap cannot be reopened quietly, not an assertion about any file's mode. --- crates/maxplayer-core/src/issuer.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/maxplayer-core/src/issuer.rs b/crates/maxplayer-core/src/issuer.rs index 16bdf58d0..4fdfc4079 100644 --- a/crates/maxplayer-core/src/issuer.rs +++ b/crates/maxplayer-core/src/issuer.rs @@ -1157,11 +1157,15 @@ mod tests { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; + // 0o7777, not 0o777: a file-mode assertion masks the WHOLE mode word, so the + // special bits are witnessed rather than discarded. This seed is created fresh by + // `init` above, so no special bit can be present and the two masks cannot disagree + // here — which is exactly why it had to be changed by hand rather than caught. let mode = fs::metadata(&report.seed_path) .expect("seed metadata") .permissions() .mode() - & 0o777; + & 0o7777; assert_eq!(mode, 0o600, "seed mode {mode:#o}"); } let phrase = fs::read_to_string(&report.seed_path).expect("seed");