Skip to content

mint class: the issuer class is declared, never sniffed (stage 2) - #966

Open
maxy-player wants to merge 6 commits into
MakePrisms:mainfrom
maxy-player:feat/ecash-mutual-credit-s2
Open

mint class: the issuer class is declared, never sniffed (stage 2)#966
maxy-player wants to merge 6 commits into
MakePrisms:mainfrom
maxy-player:feat/ecash-mutual-credit-s2

Conversation

@maxy-player

@maxy-player maxy-player commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

The issuer class of a mint is DECLARED by an operator — this seat's own config, or the counterparty's kind‑30340 advertisement — and is never inferred from the mint's /v1/info document. Owner's ruling, 3 Sep 2026 ("lets do 2"). Stage 2 of the ecash / mutual‑credit lane.

Stacking — read this first

What this commit does (fc67661)

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 — each named with the header of the function it lived in at 3f13a1c:

  1. mint_class.rs:303 in pub async fn probe_issuer_mints(mint_urls: &[String], timeout: Duration) -> IssuerMints — the whole function is gone.
  2. crossmint_hop.rs:1089 in async fn class_of(wallet: &Wallet) -> MintClass (impl CdkHopEffects, :1079), consumed by HopEffects::mint_classes (:1096) — the function is gone; see below for what mint_classes returns now.
  3. wallet_ops.rs:459–466 in async fn refuse_lightning_op_at_issuer(wallet: &Wallet, op: &str, mint_url: &str) — now fn refuse_lightning_op_at_issuer(home: &MaxplayerHome, op: &str, mint_url: &str), synchronous, and it runs before the wallet is opened.

Replaces.

  • IssuerMarker::admits (mint_class.rs:79 at head) is matches!(self, Self::Own). Nothing else about admission changes: Declared refuses the hop and does not admit, exactly as its tests already asserted at 3f13a1c.
  • CdkHopEffects::open(home, source, target, issuers: &IssuerMints) fixes both legs' classes at open from the caller's declared knowledge; HopEffects::mint_classes returns those two values and asks nothing of either mint. On the pay path the caller passes the accept‑bind's seal (authorize_pay.rs, the same issuers the plan was derived from). On the recovery sweep, which holds no bind, it passes this seat's own config (IssuerMints::none().with_own(home.config.issuer_mint())).
  • Accept (job_lifecycle.rs:1363) builds IssuerMints::none().with_own(home.config.issuer_mint()).with_declared(declared_issuer_mint.as_deref()). The one bounded relay read that fetches the seller's advertisement is unchanged; it reads a declaration, not a mint.
  • IssuerMintSeal and accept‑time sealing are unchanged; from_seal is still the pay path's only constructor.

Legacy seals — one compatibility decision, disclosed. A bind sealed before this change may carry "marker":"info". IssuerMarker::Declared now carries #[serde(alias = "info")], so such a bind still loads: the entry refuses the hop as it did, admits nothing (no operator ever stated it), and re‑serializes as declared. The alternative — dropping the entry — would have silently forgotten a hop refusal. No info value is ever written again.

The properties, each with the test that fails if it breaks

All lines below are from ci.yml:146 (cargo test -p maxplayer-core --release --features acp,gateway,git-delivery,wallet --locked) at fc67661: running 1388 tests, 0 filtered out.

§ Property Test (full path) Line
4.1 No production path reads /v1/info to obtain a class grep gate below; plus the two tests in 4.3 that would see a request if one were made
4.2a Config‑declared (Own) admits mint_class::tests::every_marker_refuses_but_only_own_admits ok
4.2b Ad‑declared (Declared) refuses and does not admit mint_class::tests::a_sellers_declaration_does_not_widen_the_fence, crossmint::tests::a_sellers_declared_issuer_mint_refuses_the_hop_but_widens_nothing ok, ok
4.3 An undeclared mint is fenced exactly as home::mint_allowed fences it — unreachable, unroutable, unresolvable, malformed included mint_class::tests::an_undeclared_mint_is_fenced_exactly_as_before_whatever_it_is, mint_class::tests::the_fence_admits_a_known_issuer_mint_and_nothing_else_new ok, ok
4.3 (negative, wire) A declared mint receives no request at all — not a quote, not /v1/info — while its stub would answer as a Lightning mint if asked wallet_ops::tests::completing_a_mint_quote_at_an_issuer_mint_is_refused_before_any_quote_call ok
4.3 (negative, hop) plan_quotes at a declared issuer leg refuses on the declaration alone; the same unreachable address undeclared is MintUnreachable from the quote crossmint_hop::tests::crossmint_hop_plan_quotes_refuses_a_declared_issuer_leg_without_asking_the_mint, crossmint_hop::tests::an_issuer_mint_on_either_leg_refuses_before_any_leg_is_touched ok, ok
4.4 The seal round‑trips unchanged and re‑derives the identical decision mint_class::tests::the_seal_round_trips_with_its_markers, job_lifecycle::tests::accept_bind_round_trips_on_disk, authorize_pay::tests::sealed_realized_mint_stabilizes_attempt_id_across_config_default_change ok, ok, ok
4.4 A seal written before this change still reads mint_class::tests::a_legacy_info_seal_still_reads_as_a_declaration_and_admits_nothing, job_lifecycle::tests::accept_bind_deserializes_legacy_json_without_realized_mint ok, ok
4.5 The literal sat gates are untouched git diff 3f13a1c..fc67661 -- crates | grep -E '^[-+].*\b[Ss]at\b': 5 lines, every one a unit: CurrencyUnit::Sat field inside a MintMethodSettings/MeltMethodSettings literal in #[cfg(test)] code (4 removed with the deleted info‑classification test helpers, 1 added in the rewritten wallet_ops test). No relative unit check was written.

§4.1 gate — git grep -nw -E 'class_from_info|load_mint_info|get_mint_info' <rev> -- 'crates/*'

rev hits per file
8c3bc9b (base main) 1 doctor.rs 1
e78b177 (#962 head) 1 doctor.rs 1
3f13a1c (this branch before the reshape) 17 mint_class.rs 9 · wallet_ops.rs 4 · crossmint_hop.rs 3 · doctor.rs 1
fc67661 (head) 1 doctor.rs 1

The one survivor, read: doctor.rs:93 in pub async fn probe_mint(mint_url: &str, timeout: Duration) -> Result<(), String> — the doctor's reachability probe. It returns Ok(()) iff the mint answers a well‑formed info document and obtains no class from it; it is unchanged from base.

Test inventory delta (cargo test … -- --list, full feature set, 3f13a1cfc67661)

Removed 3: mint_class::tests::a_mint_listing_no_bolt11_method_is_an_issuer_mint, mint_class::tests::bolt11_on_either_table_reads_as_lightning (both tested the deleted classifier), mint_class::tests::every_marker_refuses_but_only_own_and_info_admit (renamed). Added 4: mint_class::tests::every_marker_refuses_but_only_own_admits, mint_class::tests::a_legacy_info_seal_still_reads_as_a_declaration_and_admits_nothing, mint_class::tests::an_undeclared_mint_is_fenced_exactly_as_before_whatever_it_is, crossmint_hop::tests::crossmint_hop_plan_quotes_refuses_a_declared_issuer_leg_without_asking_the_mint.

CI ladder at fc67661 (exact ci.yml commands, --locked, run just now)

ci.yml command result
:21 cargo build --workspace --locked rc 0
:41 cargo test -p maxplayer-core --locked ok, 384 passed
:48 cargo test -p maxplayer --locked ok, 143 + 2 + 3 + 3 + 6 passed
:53 cargo test -p maxplayer-relay-write-policy --locked ok, 12 passed
:66 cargo build -p maxplayer --release --features acp,wallet --locked rc 0
:68 cargo build -p maxplayer-core --features acp,gateway,git-delivery,wallet --locked rc 0
:73 cargo test -p maxplayer-core --features acp --locked ok, 433 passed, 1 ignored
:81 cargo test -p maxplayer --features acp,wallet --locked ok, 169 + 2 + 3 + 5 + 3 + 6 passed, 1 ignored
:146 cargo test -p maxplayer-core --release --features acp,gateway,git-delivery,wallet --locked 1384 passed, 1 failed, 3 ignored — the one failure is credential_proxy::tests::a_declared_over_cap_body_is_refused_before_the_upstream_sees_it (credential_proxy.rs:3520), a known pre‑existing flake in a file this branch does not touch (git diff --name-only 3f13a1c fc67661 | grep -c credential_proxy = 0). Not re‑run, not investigated here.
:171 cargo build -p maxplayer --no-default-features --locked rc 0
:233 live‑mints not run — it touches real mints; no money is moved by anything in this PR's verification.

rustfmt --check --edition 2024 per changed file, hunks at 3f13a1cfc67661: mint_class.rs 0→0, crossmint_hop.rs 2→2, wallet_ops.rs 25→24, job_lifecycle.rs 96→96, crossmint.rs 30→30, authorize_pay.rs 38→38 (the pre‑existing hunks are not this PR's to fix).

Out of scope, named

Network reachability of an issuer mint for third parties (the second question in the owner's message) is not in this PR. docs/protocol-v1.md §"Issuer mint" never described the info sniff, so no doc change is needed there. The narrowing this reshape implies — no marker by which a counterparty's mint can widen this seat's real‑mint fence — is the minimal reading of the ruling and is implemented as such; if the owner rules the other way it is a one‑clause change to IssuerMarker::admits.

No merge, no tag, no release: the owner merges.

w-ecash-mutual-credit and others added 6 commits September 2, 2026 15:58
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 <noreply@anthropic.com>
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.
…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.
…stage 2)

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.
`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.
…ape)

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).
@vercel

vercel Bot commented Sep 4, 2026

Copy link
Copy Markdown

Someone is attempting to deploy a commit to the MakePrisms Team on Vercel.

A member of the Team first needs to authorize it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant