From ffcfb79caba9460924ae0a16f84cfd13b8fb48e6 Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Wed, 9 Sep 2026 14:59:29 +0300 Subject: [PATCH] feat(attribution): carry a miner's name across chains by reward preimage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live telemetry names the miners who win often and leaves everyone else as a hex string. Measured on mainnet: of 22 miners, 2 were named and 14 had no name available anywhere — but 6 already had one on Planck, under the same reward preimage. Those 6 are exactly the small operators the naming is worst for, because a name needs a block whose first reporter the feed could separate and they win few blocks. The preimage is derived from a secret only its owner holds, so the same preimage on two chains is the same operator. That is an inference the site can stand behind, unlike guessing from timing — and it is the only lever that works without waiting on the feed at all. It is a separate `AttributionSource` and renders as one. `Carried` names carry no confidence and no votes, because they assert nothing about this chain: only that this is who the operator was last known to be. Any live attribution here outranks one, and the row shows an `elsewhere` chip rather than the telemetry `node` chip so the two can never be read as the same claim. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5 --- ...9691fd491bcda14f98fbd1c71e20cbe97d0bf.json | 28 +++++++ crates/blackbeard-api/src/ingest.rs | 17 ++++ crates/blackbeard-core/src/attribution.rs | 78 +++++++++++++++++-- crates/blackbeard-data/src/store.rs | 39 ++++++++++ crates/blackbeard-entities/src/miner.rs | 10 +++ web/src/App.tsx | 3 +- web/src/api/generated/AttributionSource.ts | 2 +- web/src/components/Leaderboard.tsx | 20 ++++- 8 files changed, 188 insertions(+), 9 deletions(-) create mode 100644 .sqlx/query-56b27b0c55bcca16be18d3222c19691fd491bcda14f98fbd1c71e20cbe97d0bf.json diff --git a/.sqlx/query-56b27b0c55bcca16be18d3222c19691fd491bcda14f98fbd1c71e20cbe97d0bf.json b/.sqlx/query-56b27b0c55bcca16be18d3222c19691fd491bcda14f98fbd1c71e20cbe97d0bf.json new file mode 100644 index 0000000..59ef228 --- /dev/null +++ b/.sqlx/query-56b27b0c55bcca16be18d3222c19691fd491bcda14f98fbd1c71e20cbe97d0bf.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "\n select distinct on (a.miner) a.miner, a.node_name as \"node_name!\"\n from miner_attribution a\n where a.chain <> $1\n and a.node_name is not null\n and not exists (\n select 1 from miner_attribution mine\n where mine.chain = $1 and mine.miner = a.miner\n )\n order by a.miner, a.updated_at desc\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "miner", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "node_name!", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + true + ] + }, + "hash": "56b27b0c55bcca16be18d3222c19691fd491bcda14f98fbd1c71e20cbe97d0bf" +} diff --git a/crates/blackbeard-api/src/ingest.rs b/crates/blackbeard-api/src/ingest.rs index b09e54a..3ff8319 100644 --- a/crates/blackbeard-api/src/ingest.rs +++ b/crates/blackbeard-api/src/ingest.rs @@ -125,6 +125,23 @@ pub async fn warm_start(chain: &Arc, store: &Store) { } Err(e) => tracing::warn!(chain = %id, error = %e, "warm start: no attributions restored"), } + + // Names this operator earned elsewhere, for miners this chain's telemetry + // has not resolved. A chain hours old has named almost nobody, and a miner + // winning a block an hour may wait a long time for a feed report it can + // use — meanwhile the operator already has a name on the testnet they came + // from, under the same reward preimage. + match store.carried_names(&id).await { + Ok(carried) => { + let count = carried.len(); + let mut inner = chain.write(); + for (miner, name) in carried { + inner.attributor.carry(miner, name); + } + tracing::info!(chain = %id, names = count, "warm start: names carried from other chains"); + } + Err(e) => tracing::warn!(chain = %id, error = %e, "warm start: no names carried"), + } } /// Consume the head stream. diff --git a/crates/blackbeard-core/src/attribution.rs b/crates/blackbeard-core/src/attribution.rs index c84bfb3..19874bf 100644 --- a/crates/blackbeard-core/src/attribution.rs +++ b/crates/blackbeard-core/src/attribution.rs @@ -117,6 +117,9 @@ struct AuthorVotes { /// Rolling per-author attribution state. #[derive(Debug, Default)] pub struct Attributor { + /// Names this operator earned on another chain, keyed by reward preimage. + /// Consulted only when no live attribution has been held on this one. + carried: HashMap, authors: HashMap, } @@ -151,6 +154,18 @@ impl Attributor { entry.votes.push_back(vote); } + /// Record a name this operator earned on another chain. + /// + /// Seeded at startup from the same reward preimage on a chain we no longer + /// hold an endpoint for. It is a floor, not a claim: the moment live + /// telemetry names this miner here, that wins. + pub fn carry(&mut self, miner: MinerId, name: String) { + if name.is_empty() { + return; + } + self.carried.insert(miner, name); + } + /// Seed a held name from persisted state, so a restart does not strip every /// author of its name for a few blocks. /// @@ -196,11 +211,22 @@ impl Attributor { where F: Fn(&NodeKey) -> Option, { - let fallback = Attribution { - display: miner.abbreviated(), - source: AttributionSource::Preimage, - confidence: 0.0, - votes: 0, + // A name carried from another chain stands in until this chain's feed + // names the miner itself — better than an abbreviated preimage, and + // labelled so nobody reads it as a live observation. + let fallback = match self.carried.get(miner) { + Some(name) => Attribution { + display: name.clone(), + source: AttributionSource::Carried, + confidence: 0.0, + votes: 0, + }, + None => Attribution { + display: miner.abbreviated(), + source: AttributionSource::Preimage, + confidence: 0.0, + votes: 0, + }, }; let Some(entry) = self.authors.get_mut(miner) else { return fallback; @@ -289,6 +315,48 @@ impl NodeKey { #[cfg(test)] mod tests { + /// A carried name stands in until this chain's own feed resolves the miner. + #[test] + fn a_carried_name_fills_in_for_an_unresolved_miner() { + let mut a = Attributor::new(); + let m = MinerId("0x11".into()); + a.carry(m.clone(), "aria-dev".into()); + + // No votes at all on this chain yet. + let got = a.attribute(&m, |_| None); + assert_eq!(got.source, AttributionSource::Carried); + assert_eq!(got.display, "aria-dev"); + // Carried names assert nothing about current agreement. + assert_eq!(got.confidence, 0.0); + assert_eq!(got.votes, 0); + } + + /// And yields the moment this chain has something better. + #[test] + fn a_live_attribution_outranks_a_carried_name() { + let mut a = Attributor::new(); + let m = MinerId("0x22".into()); + a.carry(m.clone(), "old-testnet-name".into()); + for _ in 0..4 { + a.observe(&m, Some((NodeKey::Peer("QmLive".into()), Some(80)))); + } + let got = a.attribute(&m, |_| Some("live-name".into())); + assert_eq!(got.source, AttributionSource::Telemetry); + assert_eq!(got.display, "live-name"); + } + + /// A miner with no name anywhere is still its preimage. + #[test] + fn carrying_nothing_leaves_the_preimage() { + let mut a = Attributor::new(); + let m = MinerId("0x33".into()); + a.carry(m.clone(), String::new()); // empty names are not names + assert_eq!( + a.attribute(&m, |_| None).source, + AttributionSource::Preimage + ); + } + /// A name, once earned, must not evaporate because the feed went quiet. /// /// Reported from the live site: names appeared and were "often forgotten if diff --git a/crates/blackbeard-data/src/store.rs b/crates/blackbeard-data/src/store.rs index 8c8d931..fa20961 100644 --- a/crates/blackbeard-data/src/store.rs +++ b/crates/blackbeard-data/src/store.rs @@ -593,6 +593,45 @@ impl Store { Ok(rows.into_iter().map(|r| r.hash).collect()) } + /// Names this chain's miners earned on *other* chains, by reward preimage. + /// + /// The preimage is derived from a secret only its owner holds, so the same + /// preimage on two chains is the same operator. This is what lets a miner + /// who was named on a testnet keep an identity on a chain whose telemetry + /// has not yet resolved them — which is most small miners, because naming + /// needs a block whose first reporter the feed could separate, and they + /// win few blocks. + /// + /// Rows already attributed on `chain` are excluded: a live name always + /// outranks a carried one, and returning both would only make the caller + /// choose. Where an operator was named on several chains the most recently + /// updated wins. + pub async fn carried_names( + &self, + chain: &ChainId, + ) -> Result, DataError> { + let rows = sqlx::query!( + r#" + select distinct on (a.miner) a.miner, a.node_name as "node_name!" + from miner_attribution a + where a.chain <> $1 + and a.node_name is not null + and not exists ( + select 1 from miner_attribution mine + where mine.chain = $1 and mine.miner = a.miner + ) + order by a.miner, a.updated_at desc + "#, + chain.as_str(), + ) + .fetch_all(&self.pool) + .await?; + Ok(rows + .into_iter() + .map(|r| (MinerId(r.miner), r.node_name)) + .collect()) + } + /// Persist the attributions currently held. pub async fn save_attributions( &self, diff --git a/crates/blackbeard-entities/src/miner.rs b/crates/blackbeard-entities/src/miner.rs index d79830f..ff96762 100644 --- a/crates/blackbeard-entities/src/miner.rs +++ b/crates/blackbeard-entities/src/miner.rs @@ -68,6 +68,16 @@ pub enum AttributionSource { /// A substrate-telemetry node name, inferred because that node consistently /// reported this author's blocks before anyone else. Carries a confidence. Telemetry, + /// A name this operator earned on a *different* chain, carried across + /// because the reward preimage is the same. + /// + /// The preimage is derived from a secret only its owner holds, so the same + /// preimage on two chains is the same operator — a sound inference rather + /// than a guess. It is nonetheless a separate source from [`Self::Telemetry`] + /// and must render as one: nothing here says the name is current, only that + /// it is what this operator was called when we last had a live feed naming + /// them. Any live attribution on this chain outranks it. + Carried, } /// One row of the leaderboard. diff --git a/web/src/App.tsx b/web/src/App.tsx index 10cdeb5..91e6332 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -247,7 +247,8 @@ export default function App() { Node names are a best-effort mapping, inferred from which node reported a block to telemetry first — never a claim the miner made. A node? tag marks one resting on a single block or a narrow lead; later blocks correct it. The reward preimage beside it - is the only identity the chain itself asserts. + is the only identity the chain itself asserts. An elsewhere tag means the + name was earned by the same reward preimage on another chain, not observed on this one. diff --git a/web/src/api/generated/AttributionSource.ts b/web/src/api/generated/AttributionSource.ts index 43252fe..9d1333a 100644 --- a/web/src/api/generated/AttributionSource.ts +++ b/web/src/api/generated/AttributionSource.ts @@ -7,4 +7,4 @@ * inference from who reported a block first, not a claim the miner made, and * the UI must not present the two identically. */ -export type AttributionSource = "preimage" | "telemetry"; +export type AttributionSource = "preimage" | "telemetry" | "carried"; diff --git a/web/src/components/Leaderboard.tsx b/web/src/components/Leaderboard.tsx index bfdd62f..204b0ed 100644 --- a/web/src/components/Leaderboard.tsx +++ b/web/src/components/Leaderboard.tsx @@ -29,6 +29,11 @@ function Row({ onSelect: (miner: string) => void }) { const named = row.attribution === 'telemetry' + // A name this operator earned on another chain, under the same reward + // preimage. Shown because a preimage tells a miner nothing about themselves, + // but never dressed as a live observation: nothing here says the node is on + // this chain, only that this is who the operator was last known to be. + const carried = row.attribution === 'carried' // Thin evidence: a single supporting block, or a lead the rest of the votes // do not back. Either way the mapping is a guess that a later block may undo. const tentative = named && (row.attribution_votes < 3 || row.confidence < 0.6) @@ -47,16 +52,27 @@ function Row({ { e.preventDefault() onSelect(row.miner) }} > - {named ? row.display : shortMiner(row.miner)} + {named || carried ? row.display : shortMiner(row.miner)} {mine && You} + {carried && ( + + elsewhere + + )} {named && (