feat(attribution): carry a miner's name across chains by reward preimage
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:
28
.sqlx/query-56b27b0c55bcca16be18d3222c19691fd491bcda14f98fbd1c71e20cbe97d0bf.json
generated
Normal file
28
.sqlx/query-56b27b0c55bcca16be18d3222c19691fd491bcda14f98fbd1c71e20cbe97d0bf.json
generated
Normal 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"
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user