From 7547e5aedbfe6103c8b30b3b614ff9f380143d4a Mon Sep 17 00:00:00 2001 From: w-ecash-mutual-credit Date: Wed, 2 Sep 2026 15:58:28 -0700 Subject: [PATCH 1/3] protocol: optional `issuer_mint` tag on the kind-30340 seat announcement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 1 of the ecash mutual-credit build. One new OPTIONAL tag on the seat announcement, `["issuer_mint", url, cap, outstanding, retired, last_seen]`, carrying the seat's own mint URL and its issuance counters. Emitted from `HeartbeatDraft` (`with_issuer_mint`), parsed into `ParsedHeartbeat.issuer_mint`. Absent or malformed reads as unstated, never a rejection. Announcement only, never on the kind-3402 claim (protocol-v1 §4.2 "Issuer mint"). No version bump: a reader MUST ignore unrecognised tags (§2.1). No production publish path sets it yet; stage 3 wires live counters. Co-Authored-By: Claude Fable 5.1 --- crates/maxplayer-core/src/heartbeat.rs | 317 ++++++++++++++++++++++++- docs/protocol-v1.md | 37 +++ 2 files changed, 351 insertions(+), 3 deletions(-) diff --git a/crates/maxplayer-core/src/heartbeat.rs b/crates/maxplayer-core/src/heartbeat.rs index 932094033..d69ff1c8a 100644 --- a/crates/maxplayer-core/src/heartbeat.rs +++ b/crates/maxplayer-core/src/heartbeat.rs @@ -280,6 +280,90 @@ pub const ADMITS_POOL_TAG: &str = "admits_pool"; /// nothing about its list. pub const ADMITS_TARGETED_TAG: &str = "admits_targeted"; +/// `["issuer_mint", url, cap, outstanding, retired, last_seen]` — the seat runs its OWN Cashu mint +/// and issues tokens that are an IOU for its own future work (§4.2 "Issuer mint"). The unit stays +/// `sat`: one token is one sat of the issuer's work at its published `rate`, and the mint URL is the +/// sole thing that distinguishes this currency from any other `sat` on the wire. +/// +/// ONE tag, FIVE positional values, all required when the tag is present — see [`IssuerMintAd`] for +/// what each one means. Absent means the seat states no issuer mint. Malformed reads as UNSTATED, +/// never as a rejection: an optional tag must not be able to take a working seat off the market. +/// +/// BEAT ONLY — never on a kind-3402 claim. It is not an award filter: a buyer pays on the mint the +/// claim's `creq` names, and whether to accept an issuer's currency at all is a decision the +/// operator of the OTHER seat makes by hand, by adding this URL to its own `accepted_mints`, +/// before any offer is sent. Nothing in the protocol derives that decision from these values. +pub const ISSUER_MINT_TAG: &str = "issuer_mint"; + +/// The seat's issuer-mint advertisement (§4.2 "Issuer mint"), read off or written to +/// [`ISSUER_MINT_TAG`]. +/// +/// The counters are the ISSUER's own statement. The seat's signature on the beat covers them, so a +/// reader knows WHO said them — not that they are true. They exist for the operator of another seat +/// to read before extending credit; no code path acts on them automatically. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct IssuerMintAd { + /// The seat's own mint URL. MUST also appear in `accepted_mints`: a seat announcing a currency + /// it will not itself take back is not announcing an issuer mint, and the reader treats it so. + pub mint_url: String, + /// Ceiling on tokens OUTSTANDING the issuer enforces on its own minting, in sats. + pub cap_sats: u64, + /// Minted minus retired, in sats, as of `last_seen`. + pub outstanding_sats: u64, + /// Tokens the issuer has taken back and burned, in sats, as of `last_seen`. + pub retired_sats: u64, + /// Unix seconds at which the counters were read from the mint. + pub last_seen: u64, +} + +impl IssuerMintAd { + /// The wire tag: `["issuer_mint", url, cap, outstanding, retired, last_seen]`, counters as + /// plain decimal digit strings. + pub fn to_tag(&self) -> TagSpec { + TagSpec(vec![ + ISSUER_MINT_TAG.to_owned(), + self.mint_url.clone(), + self.cap_sats.to_string(), + self.outstanding_sats.to_string(), + self.retired_sats.to_string(), + self.last_seen.to_string(), + ]) + } + + /// Read the issuer-mint advertisement off a seat announcement's tags. `None` ⇒ the seat STATED + /// NOTHING. + /// + /// Absent is unstated. So is every malformed shape — wrong arity, a counter that is not plain + /// decimal digits, an empty URL, or a URL the seat does not list in `accepted_mints`. None of + /// those is an error: this tag is optional, and a reader that dropped a payable seat over an + /// optional tag it could not read would be stricter than §2.1 lets it be. The same rule + /// [`admission_from_tags`] applies to a half-stated policy. + pub fn from_tags(tags: &[TagSpec], accepted_mints: &[String]) -> Option { + let tag = first_tag(tags, ISSUER_MINT_TAG)?; + let [_, url, cap, outstanding, retired, last_seen] = tag.0.as_slice() else { + return None; + }; + if url.is_empty() || !accepted_mints.contains(url) { + return None; + } + Some(Self { + mint_url: url.clone(), + cap_sats: decimal_sats(cap)?, + outstanding_sats: decimal_sats(outstanding)?, + retired_sats: decimal_sats(retired)?, + last_seen: decimal_sats(last_seen)?, + }) + } +} + +/// A counter on the wire is plain ASCII decimal digits and nothing else. `str::parse::` also +/// takes a leading `+`, which would put two spellings of one number on the wire; this pins one. +fn decimal_sats(raw: &str) -> Option { + if raw.is_empty() || !raw.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + raw.parse().ok() +} /// Wire tag carrying operator colour about the machine (#784) — e.g. "mac studio, 64GB". Free text, /// single value. @@ -473,6 +557,10 @@ pub struct HeartbeatDraft { /// caller that has no [`crate::home::SellerConfig`] in hand emits the tag set it always did; /// the production publish paths always have one, so a real seat always answers. pub admission: Option, + /// The seat's issuer-mint advertisement (§4.2 "Issuer mint"), or `None` to state none. `None` + /// is the default and emits no tag: a seat that runs no mint of its own publishes exactly the + /// tag set it always did. + pub issuer_mint: Option, } impl HeartbeatDraft { @@ -490,6 +578,7 @@ impl HeartbeatDraft { agents: Vec::new(), capability: SeatCapability::default(), admission: None, + issuer_mint: None, } } @@ -511,9 +600,17 @@ impl HeartbeatDraft { self } + /// Advertise the seat's own issuer mint and its issuance counters on this heartbeat (§4.2 + /// "Issuer mint"). + pub fn with_issuer_mint(mut self, issuer_mint: IssuerMintAd) -> Self { + self.issuer_mint = Some(issuer_mint); + self + } + /// The §4.2 tag set, in the order the spec table lists it: `d`, `t`, `v`, `rate`, `accepting`, - /// `queue_depth`, `accepted_mints`, and `agents` when the seat states a roster — followed by the - /// #784 capability tags, filterable first then display-only. + /// `queue_depth`, `accepted_mints`, `agents` when the seat states a roster, the admission tags + /// when it states a policy, `issuer_mint` when it runs one — followed by the #784 capability + /// tags, filterable first then display-only. /// /// The beat emits BOTH capability sets; a claim emits only the filterable one. Both take them /// from [`SeatCapability`], so the two events cannot spell a shared field differently. @@ -537,6 +634,9 @@ impl HeartbeatDraft { if let Some(admission) = self.admission.as_ref() { tags.extend(admission_tags(admission)); } + if let Some(issuer_mint) = self.issuer_mint.as_ref() { + tags.push(issuer_mint.to_tag()); + } tags.extend(self.capability.filterable_tags()); tags.extend(self.capability.display_tags()); EventDraft::new(SELLER_HEARTBEAT_KIND, tags, "") @@ -925,6 +1025,10 @@ pub struct ParsedHeartbeat { /// ⛔ `None` is UNKNOWN, never a refusal. A seat that predates this tag publishes neither half, /// and a reader that treated that as "admits nobody" would drop every seat running today. pub admission: Option, + /// The seat's issuer-mint advertisement (§4.2 "Issuer mint"), or `None` when the seat stated + /// none — absent OR unreadable. Never a rejection: the tag is optional, and the counters are the + /// issuer's own unverified statement for an operator to read, not a value any code acts on. + pub issuer_mint: Option, /// The seat's #784 capability advertisement, read back off the same tags the beat emitted. /// Every field defaults to unstated, so a beat from a seat that predates #784 parses to a /// [`SeatCapability::default`] rather than failing — that is what lets emitters and readers ship @@ -1057,10 +1161,11 @@ pub fn parse_heartbeat(event: &EventDraft) -> Result IssuerMintAd { + IssuerMintAd { + mint_url: mints()[0].clone(), + cap_sats: 100_000, + outstanding_sats: 2_500, + retired_sats: 750, + last_seen: 1_788_390_000, + } + } + + /// The wire shape, pinned by hand in both directions: one tag, five positional values, counters + /// as plain decimal digits. + #[test] + fn issuer_mint_wire_shape_round_trips() { + let event = draft(true, 0, 5) + .with_issuer_mint(issuer()) + .to_event_draft(); + let tag = first_tag(&event.tags, ISSUER_MINT_TAG).expect("issuer_mint tag"); + assert_eq!( + tag.0, + vec![ + "issuer_mint", + "https://testnut.example/Bitcoin", + "100000", + "2500", + "750", + "1788390000", + ] + ); + let parsed = parse_heartbeat(&event).expect("round-trip"); + assert_eq!(parsed.issuer_mint, Some(issuer())); + assert_eq!( + IssuerMintAd::from_tags(&event.tags, &mints()), + Some(issuer()) + ); + + // The URL may sit anywhere in `accepted_mints`, not only first: a seat that lists a shared + // mint ahead of its own currency still states an issuer mint. + let two = vec![ + "https://shared.example/Bitcoin".to_owned(), + mints()[0].clone(), + ]; + let second = HeartbeatDraft::new(true, 0, 5, two) + .with_issuer_mint(issuer()) + .to_event_draft(); + assert_eq!( + parse_heartbeat(&second).expect("parses").issuer_mint, + Some(issuer()) + ); + } + + /// Absent is UNSTATED, and a seat stating none emits nothing new — the tag set it always did. + #[test] + fn an_absent_issuer_mint_tag_is_unstated_and_adds_nothing() { + let event = draft(true, 0, 5).to_event_draft(); + assert!( + first_tag(&event.tags, ISSUER_MINT_TAG).is_none(), + "a draft that states no issuer mint must emit no tag" + ); + let parsed = parse_heartbeat(&event).expect("a beat with no issuer_mint tag still parses"); + assert_eq!(parsed.issuer_mint, None); + assert_eq!(IssuerMintAd::from_tags(&event.tags, &mints()), None); + } + + /// Stating an issuer mint adds exactly ONE tag, leaves every other tag byte-identical, and + /// changes nothing a reader that predates the tag can see. That last clause is the "old reader + /// ignores it" proof: an old reader is this reader minus the field, and §2.1 has it skip a tag + /// it does not recognise — so its whole view is `parsed_with` with the new field blanked, which + /// must equal what it reads off a beat that never carried the tag. + #[test] + fn issuer_mint_is_additive_beat_only_and_invisible_to_an_old_reader() { + let without = draft(true, 2, 5) + .with_agents(vec!["claude".into()]) + .with_admission(TEST_POLICY) + .to_event_draft(); + let with = draft(true, 2, 5) + .with_agents(vec!["claude".into()]) + .with_admission(TEST_POLICY) + .with_issuer_mint(issuer()) + .to_event_draft(); + + let before = tag_names(&without); + let added: Vec<&str> = tag_names(&with) + .into_iter() + .filter(|name| !before.contains(name)) + .collect(); + assert_eq!( + added, + vec![ISSUER_MINT_TAG], + "stating an issuer mint must add exactly one tag and nothing else" + ); + + // Every tag an old reader knows is byte-identical, in the same order. + let known: Vec<&Vec> = with + .tags + .iter() + .filter(|tag| tag.first() != Some(ISSUER_MINT_TAG)) + .map(|tag| &tag.0) + .collect(); + let original: Vec<&Vec> = without.tags.iter().map(|tag| &tag.0).collect(); + assert_eq!(known, original); + + // The old reader's view: identical in every field it has. + let mut parsed_with = parse_heartbeat(&with).expect("parses with the tag"); + let parsed_without = parse_heartbeat(&without).expect("parses without the tag"); + assert_eq!(parsed_with.issuer_mint, Some(issuer())); + parsed_with.issuer_mint = None; + assert_eq!( + parsed_with, parsed_without, + "a reader that ignores issuer_mint must see exactly the beat it always saw" + ); + + // And the converse §2.1 property on THIS reader: a tag it does not know, sitting beside + // the one it does, changes nothing. + let mut with_stranger = with.clone(); + with_stranger + .tags + .push(TagSpec::new(["issuer_mint_v2", "https://x.example", "1"])); + let parsed_stranger = parse_heartbeat(&with_stranger).expect("an unknown tag is ignored"); + assert_eq!(parsed_stranger, parse_heartbeat(&with).expect("parses")); + + // Beat only: the claim carries `SeatCapability::filterable_tags` and nothing else from + // this module, so there is no path by which the tag reaches a kind-3402 claim. + assert!( + SeatCapability::default() + .filterable_tags() + .iter() + .all(|tag| tag.first() != Some(ISSUER_MINT_TAG)), + "issuer_mint must never be a filterable claim tag" + ); + } + + /// Every malformed shape is UNSTATED — never a rejection of the beat, never a guessed value. + #[test] + fn a_malformed_issuer_mint_tag_is_unstated_never_a_rejection() { + let good = issuer().to_tag().0; + let mut cases: Vec<(String, Vec)> = Vec::new(); + + let mut short = good.clone(); + short.pop(); + cases.push(("four values".to_owned(), short)); + + let mut long = good.clone(); + long.push("extra".to_owned()); + cases.push(("six values".to_owned(), long)); + + cases.push(("bare tag".to_owned(), vec![ISSUER_MINT_TAG.to_owned()])); + + let mut empty_url = good.clone(); + empty_url[1] = String::new(); + cases.push(("empty url".to_owned(), empty_url)); + + let mut unlisted = good.clone(); + unlisted[1] = "https://elsewhere.example/Bitcoin".to_owned(); + cases.push(("url not in accepted_mints".to_owned(), unlisted)); + + for (index, field) in [ + (2, "cap"), + (3, "outstanding"), + (4, "retired"), + (5, "last_seen"), + ] { + for bad in [ + "", + "-1", + "+5", + "1.5", + "1e3", + "abc", + " 5", + "5 ", + "99999999999999999999", + ] { + let mut case = good.clone(); + case[index] = bad.to_owned(); + cases.push((format!("{field}={bad:?}"), case)); + } + } + + for (label, tag) in cases { + let mut event = draft(true, 0, 5).to_event_draft(); + event.tags.push(TagSpec(tag.clone())); + let parsed = parse_heartbeat(&event).unwrap_or_else(|err| { + panic!("{label}: an optional tag must never reject the beat, got {err}") + }); + assert_eq!( + parsed.issuer_mint, None, + "{label}: {tag:?} must read as unstated, not as a value this reader invented" + ); + // The rest of the beat is untouched by the bad tag. + assert_eq!(parsed.accepted_mints, mints()); + assert_eq!(parsed.rate_sats, 5); + } + + // The well-formed tag, for contrast, on the same fixture. + let mut event = draft(true, 0, 5).to_event_draft(); + event.tags.push(TagSpec(good)); + assert_eq!( + parse_heartbeat(&event).expect("parses").issuer_mint, + Some(issuer()) + ); + } + #[test] fn heartbeat_addressable() { // Kind is in NIP-01's addressable range so the relay replaces it in place by (pubkey, d). diff --git a/docs/protocol-v1.md b/docs/protocol-v1.md index 10b8c5e82..d50479638 100644 --- a/docs/protocol-v1.md +++ b/docs/protocol-v1.md @@ -102,6 +102,7 @@ replaces it on every beat. Every fact below is current as of that beat, EXCEPT ` | `["agents", id, ...]` | 0..1 | no | Harnesses the seat can run | | `["admits_pool", "open"` or `"closed"]` | 0..1 | no | Whether the seat claims untargeted (open-pool) offers | | `["admits_targeted", "open"`, `"named"` or `"closed"]` | 0..1 | no | Who the seat admits on the targeted surface | +| `["issuer_mint", url, cap, outstanding, retired, last_seen]` | 0..1 | no | The seat's own mint and its issuance counters | | `["harness_family", family, ...]` | 0..1 | no | Harness families the seat serves | | `["harness_model", family, model]` | 0..N | no | One resolved model, paired to its family | | `["capabilities", token, ...]` | 0..1 | no | Capability tokens the seat proved | @@ -173,6 +174,42 @@ sets above, states no policy — a reader MUST NOT infer the missing or unrecogn These tags appear on the announcement ONLY. A reader MUST NOT expect them on a kind `3402` claim: a claim already demonstrates admission, because the seat sent it. +#### Issuer mint + +`issuer_mint` states that the seat runs its OWN Cashu mint and issues tokens that are an IOU for its +own future work. It pays other seats with them. Whoever holds them may later hire this seat and pay +with them, and the seat retires what comes back. No outside money enters or leaves such a mint. + +The unit stays `sat`. One token is one sat of this seat's work at its published `rate`. The mint URL +is the sole thing that distinguishes this currency from any other `sat` on the wire. + +It is one tag with five positional values, every one required when the tag is present: + +| Position | Value | Meaning | +|---|---|---| +| 1 | `url` | The seat's own mint. MUST also appear in the seat's `accepted_mints` | +| 2 | `cap` | Ceiling on tokens outstanding that the issuer enforces on its own minting, in sats | +| 3 | `outstanding` | Minted minus retired, in sats, as of `last_seen` | +| 4 | `retired` | Tokens the issuer has taken back and burned, in sats, as of `last_seen` | +| 5 | `last_seen` | Unix seconds at which the counters were read from the mint | + +`cap`, `outstanding`, `retired` and `last_seen` are strings of decimal digits and nothing else. + +The counters are the issuer's own statement. The seat's signature on the announcement covers them, so +a reader knows WHO said them, not that they are true. A reader MUST NOT treat them as verified and +MUST NOT extend credit on them automatically. Accepting an issuer's currency is a manual act: the +operator of another seat reads this tag and, if it chooses, adds the URL to its own `accepted_mints`. +Nothing in this protocol derives that decision. + +An absent tag means the seat states no issuer mint. A tag with the wrong number of values, a counter +that is not decimal digits, an empty URL, or a URL that is not in the seat's `accepted_mints` states +no issuer mint either: a reader MUST read it as unstated and MUST NOT reject the announcement over +it. A reader that predates this tag ignores it under §2.1 and behaves as it did before. + +This tag appears on the announcement ONLY. A reader MUST NOT expect it on a kind `3402` claim. It is +not an award filter: a buyer pays on a mint the claim's `creq` names, and whether to accept an +issuer's mint at all was decided by an operator before any offer was sent. + `queue_depth` is a live count. It returns to `0` when the seat holds no non-terminal job. `accepting` is the seat's own statement of intent. A reader MUST NOT treat it as a guarantee. The From 2446900bc8b37cdf59b96e5bf70c32a4cd708b5d Mon Sep 17 00:00:00 2001 From: w-ecash-mutual-credit Date: Wed, 2 Sep 2026 17:42:20 -0700 Subject: [PATCH 2/3] =?UTF-8?q?protocol:=20drop=20`cap`=20from=20the=20`is?= =?UTF-8?q?suer=5Fmint`=20tag=20=E2=80=94=20there=20is=20no=20cap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner decision (Bob, 2 Sep, "lets keep it simple for now - no wrapper, no limit"): nothing enforces a ceiling on an issuer's outstanding tokens, so the tag no longer declares one. The tag is positional and shrinks to four values: `["issuer_mint", url, outstanding, retired, last_seen]`. Both index tables (the doc's position table and the malformed-shape test's field table) are renumbered: outstanding 3->2, retired 4->3, last_seen 5->4. `IssuerMintAd` loses `cap_sats`; serializer, parser destructure, the wire-shape fixture and the arity labels follow. The counters stay: they are the only trust signal an operator reads before extending credit. --- crates/maxplayer-core/src/heartbeat.rs | 27 ++++++++------------------ docs/protocol-v1.md | 13 ++++++------- 2 files changed, 14 insertions(+), 26 deletions(-) diff --git a/crates/maxplayer-core/src/heartbeat.rs b/crates/maxplayer-core/src/heartbeat.rs index d69ff1c8a..c15ec1435 100644 --- a/crates/maxplayer-core/src/heartbeat.rs +++ b/crates/maxplayer-core/src/heartbeat.rs @@ -280,7 +280,7 @@ pub const ADMITS_POOL_TAG: &str = "admits_pool"; /// nothing about its list. pub const ADMITS_TARGETED_TAG: &str = "admits_targeted"; -/// `["issuer_mint", url, cap, outstanding, retired, last_seen]` — the seat runs its OWN Cashu mint +/// `["issuer_mint", url, outstanding, retired, last_seen]` — the seat runs its OWN Cashu mint /// and issues tokens that are an IOU for its own future work (§4.2 "Issuer mint"). The unit stays /// `sat`: one token is one sat of the issuer's work at its published `rate`, and the mint URL is the /// sole thing that distinguishes this currency from any other `sat` on the wire. @@ -306,8 +306,6 @@ pub struct IssuerMintAd { /// The seat's own mint URL. MUST also appear in `accepted_mints`: a seat announcing a currency /// it will not itself take back is not announcing an issuer mint, and the reader treats it so. pub mint_url: String, - /// Ceiling on tokens OUTSTANDING the issuer enforces on its own minting, in sats. - pub cap_sats: u64, /// Minted minus retired, in sats, as of `last_seen`. pub outstanding_sats: u64, /// Tokens the issuer has taken back and burned, in sats, as of `last_seen`. @@ -317,13 +315,12 @@ pub struct IssuerMintAd { } impl IssuerMintAd { - /// The wire tag: `["issuer_mint", url, cap, outstanding, retired, last_seen]`, counters as - /// plain decimal digit strings. + /// The wire tag: `["issuer_mint", url, outstanding, retired, last_seen]`, counters as plain + /// decimal digit strings. pub fn to_tag(&self) -> TagSpec { TagSpec(vec![ ISSUER_MINT_TAG.to_owned(), self.mint_url.clone(), - self.cap_sats.to_string(), self.outstanding_sats.to_string(), self.retired_sats.to_string(), self.last_seen.to_string(), @@ -340,7 +337,7 @@ impl IssuerMintAd { /// [`admission_from_tags`] applies to a half-stated policy. pub fn from_tags(tags: &[TagSpec], accepted_mints: &[String]) -> Option { let tag = first_tag(tags, ISSUER_MINT_TAG)?; - let [_, url, cap, outstanding, retired, last_seen] = tag.0.as_slice() else { + let [_, url, outstanding, retired, last_seen] = tag.0.as_slice() else { return None; }; if url.is_empty() || !accepted_mints.contains(url) { @@ -348,7 +345,6 @@ impl IssuerMintAd { } Some(Self { mint_url: url.clone(), - cap_sats: decimal_sats(cap)?, outstanding_sats: decimal_sats(outstanding)?, retired_sats: decimal_sats(retired)?, last_seen: decimal_sats(last_seen)?, @@ -1482,14 +1478,13 @@ mod tests { fn issuer() -> IssuerMintAd { IssuerMintAd { mint_url: mints()[0].clone(), - cap_sats: 100_000, outstanding_sats: 2_500, retired_sats: 750, last_seen: 1_788_390_000, } } - /// The wire shape, pinned by hand in both directions: one tag, five positional values, counters + /// The wire shape, pinned by hand in both directions: one tag, four positional values, counters /// as plain decimal digits. #[test] fn issuer_mint_wire_shape_round_trips() { @@ -1502,7 +1497,6 @@ mod tests { vec![ "issuer_mint", "https://testnut.example/Bitcoin", - "100000", "2500", "750", "1788390000", @@ -1619,11 +1613,11 @@ mod tests { let mut short = good.clone(); short.pop(); - cases.push(("four values".to_owned(), short)); + cases.push(("three values".to_owned(), short)); let mut long = good.clone(); long.push("extra".to_owned()); - cases.push(("six values".to_owned(), long)); + cases.push(("five values".to_owned(), long)); cases.push(("bare tag".to_owned(), vec![ISSUER_MINT_TAG.to_owned()])); @@ -1635,12 +1629,7 @@ mod tests { unlisted[1] = "https://elsewhere.example/Bitcoin".to_owned(); cases.push(("url not in accepted_mints".to_owned(), unlisted)); - for (index, field) in [ - (2, "cap"), - (3, "outstanding"), - (4, "retired"), - (5, "last_seen"), - ] { + for (index, field) in [(2, "outstanding"), (3, "retired"), (4, "last_seen")] { for bad in [ "", "-1", diff --git a/docs/protocol-v1.md b/docs/protocol-v1.md index d50479638..fb4a996eb 100644 --- a/docs/protocol-v1.md +++ b/docs/protocol-v1.md @@ -102,7 +102,7 @@ replaces it on every beat. Every fact below is current as of that beat, EXCEPT ` | `["agents", id, ...]` | 0..1 | no | Harnesses the seat can run | | `["admits_pool", "open"` or `"closed"]` | 0..1 | no | Whether the seat claims untargeted (open-pool) offers | | `["admits_targeted", "open"`, `"named"` or `"closed"]` | 0..1 | no | Who the seat admits on the targeted surface | -| `["issuer_mint", url, cap, outstanding, retired, last_seen]` | 0..1 | no | The seat's own mint and its issuance counters | +| `["issuer_mint", url, outstanding, retired, last_seen]` | 0..1 | no | The seat's own mint and its issuance counters | | `["harness_family", family, ...]` | 0..1 | no | Harness families the seat serves | | `["harness_model", family, model]` | 0..N | no | One resolved model, paired to its family | | `["capabilities", token, ...]` | 0..1 | no | Capability tokens the seat proved | @@ -183,17 +183,16 @@ with them, and the seat retires what comes back. No outside money enters or leav The unit stays `sat`. One token is one sat of this seat's work at its published `rate`. The mint URL is the sole thing that distinguishes this currency from any other `sat` on the wire. -It is one tag with five positional values, every one required when the tag is present: +It is one tag with four positional values, every one required when the tag is present: | Position | Value | Meaning | |---|---|---| | 1 | `url` | The seat's own mint. MUST also appear in the seat's `accepted_mints` | -| 2 | `cap` | Ceiling on tokens outstanding that the issuer enforces on its own minting, in sats | -| 3 | `outstanding` | Minted minus retired, in sats, as of `last_seen` | -| 4 | `retired` | Tokens the issuer has taken back and burned, in sats, as of `last_seen` | -| 5 | `last_seen` | Unix seconds at which the counters were read from the mint | +| 2 | `outstanding` | Minted minus retired, in sats, as of `last_seen` | +| 3 | `retired` | Tokens the issuer has taken back and burned, in sats, as of `last_seen` | +| 4 | `last_seen` | Unix seconds at which the counters were read from the mint | -`cap`, `outstanding`, `retired` and `last_seen` are strings of decimal digits and nothing else. +`outstanding`, `retired` and `last_seen` are strings of decimal digits and nothing else. The counters are the issuer's own statement. The seat's signature on the announcement covers them, so a reader knows WHO said them, not that they are true. A reader MUST NOT treat them as verified and From e78b1775d570e293eed727e7e97bebc0f2a500e8 Mon Sep 17 00:00:00 2001 From: w-ecash-mutual-credit Date: Wed, 2 Sep 2026 18:19:06 -0700 Subject: [PATCH 3/3] 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. ///