//! W — the size of the Ğ1 web of trust, the electorate of Monnaie libre. //! //! Read on chain first (`Membership.CounterForMembership`, JSON-RPC //! `state_getStorage` on a Duniter v2s node), from an indexer otherwise, and //! kept in `settings`. A failed refresh keeps the last known value; the very //! first start falls back to `LD_WOT_FALLBACK`. use crate::AppState; use crate::util; use serde_json::json; use std::time::Duration; const REFRESH: Duration = Duration::from_secs(6 * 3600); const PLAUSIBLE: std::ops::RangeInclusive = 100..=10_000_000; fn twox_128(data: &[u8]) -> [u8; 16] { let mut out = [0u8; 16]; out[..8].copy_from_slice(&twox_hash::XxHash64::oneshot(0, data).to_le_bytes()); out[8..].copy_from_slice(&twox_hash::XxHash64::oneshot(1, data).to_le_bytes()); out } /// Storage key of a plain storage value: `twox_128(pallet) ++ twox_128(item)`. pub fn storage_key(pallet: &str, item: &str) -> String { let mut key = Vec::with_capacity(32); key.extend_from_slice(&twox_128(pallet.as_bytes())); key.extend_from_slice(&twox_128(item.as_bytes())); format!("0x{}", hex::encode(key)) } /// SCALE `u32`, little endian. pub fn decode_u32(hex_value: &str) -> Option { let bytes = hex::decode(hex_value.trim_start_matches("0x")).ok()?; let arr: [u8; 4] = bytes.get(..4)?.try_into().ok()?; Some(u32::from_le_bytes(arr)) } async fn from_chain(state: &AppState) -> Option<(u32, String)> { let key = storage_key("Membership", "CounterForMembership"); for node in &state.cfg.rpc_endpoints { let body = json!({ "jsonrpc": "2.0", "id": 1, "method": "state_getStorage", "params": [key] }); let res = state.http.post(node).json(&body).send().await; let value: Option = match res { Ok(r) if r.status().is_success() => r.json().await.ok(), _ => None, }; if let Some(n) = value.as_ref().and_then(|v| v["result"].as_str()).and_then(decode_u32) && PLAUSIBLE.contains(&n) { return Some((n, format!("chain:{node}"))); } } None } async fn from_indexer(state: &AppState) -> Option<(u32, String)> { let query = json!({ "query": "{ identities(filter:{isMember:{equalTo:true}}) { totalCount } }" }); for url in &state.cfg.indexers { let res = state.http.post(url).json(&query).send().await; let value: Option = match res { Ok(r) if r.status().is_success() => r.json().await.ok(), _ => None, }; let n = value .as_ref() .and_then(|v| v["data"]["identities"]["totalCount"].as_u64()) .and_then(|n| u32::try_from(n).ok()); if let Some(n) = n.filter(|n| PLAUSIBLE.contains(n)) { return Some((n, format!("indexer:{url}"))); } } None } pub async fn refresh(state: &AppState) -> anyhow::Result> { let found = match from_chain(state).await { Some(v) => Some(v), None => from_indexer(state).await, }; let Some((n, source)) = found else { tracing::warn!("web of trust size: no node nor indexer answered, keeping the last value"); return Ok(None); }; let now = util::now(); for (key, value) in [("wot_size", n.to_string()), ("wot_source", source.clone())] { sqlx::query( "INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at", ) .bind(key) .bind(value) .bind(&now) .execute(&state.db) .await?; } tracing::info!(size = n, %source, "web of trust size"); Ok(Some(n)) } pub async fn refresher(state: AppState) { let mut tick = tokio::time::interval(REFRESH); loop { tick.tick().await; if let Err(e) = refresh(&state).await { tracing::warn!(error = %e, "web of trust refresh"); } } } #[cfg(test)] mod tests { use super::*; #[test] fn storage_keys_match_substrate() { // Well-known key of System.Number (block number) on any Substrate chain. assert_eq!( storage_key("System", "Number"), "0x26aa394eea5630e07c48ae0c9558cef702a5c1b19ab7a04f536c519aca4983ac" ); } #[test] fn scale_u32() { assert_eq!(decode_u32("0x71160000"), Some(5745)); assert_eq!(decode_u32("0x01"), None); } }