feat(attribution): carry a miner's name across chains by reward preimage
All checks were successful
deploy / build (push) Successful in 7m6s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 14s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
This commit is contained in:
2026-09-09 14:59:29 +03:00
parent a177e805f4
commit ffcfb79cab
8 changed files with 188 additions and 9 deletions

View File

@@ -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"
}

View File

@@ -125,6 +125,23 @@ pub async fn warm_start(chain: &Arc<ChainRuntime>, 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.

View File

@@ -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<MinerId, String>,
authors: HashMap<MinerId, AuthorVotes>,
}
@@ -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<String>,
{
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

View File

@@ -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<Vec<(MinerId, String)>, 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,

View File

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

View File

@@ -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 <code>node?</code> 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 <code>elsewhere</code> tag means the
name was earned by the same reward preimage on another chain, not observed on this one.
</span>
</footer>
</div>

View File

@@ -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";

View File

@@ -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({
</button>
<a
href={`#/miner/${row.miner}`}
className={named ? 'miner-name' : 'miner-name anonymous'}
className={named || carried ? 'miner-name' : 'miner-name anonymous'}
title={row.miner}
onClick={(e) => {
e.preventDefault()
onSelect(row.miner)
}}
>
{named ? row.display : shortMiner(row.miner)}
{named || carried ? row.display : shortMiner(row.miner)}
</a>
{mine && <span className="chip chip-you">You</span>}
{carried && (
<span
className="chip chip-named chip-guess"
title={
'Name carried from another chain, matched on the same reward preimage — ' +
'this operator has not yet been resolved by telemetry here.'
}
>
elsewhere
</span>
)}
{named && (
<span
// An attributed name is an inference from who reported the block