use std::future::Future;
use std::sync::OnceLock;
pub use host_encoding::identity::ConsumerInfo;
use host_encoding::identity::{
account_id_to_hex, consumers_key, decode_consumer_info, decode_username_info_owner,
decode_username_owner, normalize_username, username_info_of_key, username_owner_of_key,
};
use crate::chain::ChainId;
fn http_endpoints(chain: ChainId) -> &'static [&'static str] {
match chain {
ChainId::PolkadotPeople => &[
"https://polkadot-people-rpc.polkadot.io",
"https://people-polkadot.dotters.network",
],
ChainId::PaseoPeople => &[
"https://people-paseo.dotters.network",
"https://sys.ibp.network/people-paseo",
],
ChainId::Individuality => &["https://pop3-testnet.parity-lab.parity.io/people"],
_ => &[],
}
}
fn shared_client() -> &'static reqwest::Client {
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
CLIENT.get_or_init(|| {
#[cfg(not(target_arch = "wasm32"))]
{
reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.expect("failed to build reqwest client")
}
#[cfg(target_arch = "wasm32")]
{
reqwest::Client::new()
}
})
}
pub async fn resolve_username(username: &str, chain: ChainId) -> Result<Option<String>, String> {
let endpoints = http_endpoints(chain);
if endpoints.is_empty() {
return Err(format!(
"no HTTP endpoint configured for chain {chain:?}; \
use resolve_username_with() to supply a custom transport"
));
}
resolve_username_with_async(username, chain, |req| {
let req_owned = req.to_owned();
async move { http_transport(&req_owned, endpoints).await }
})
.await
}
pub async fn resolve_username_with<F, Fut>(
username: &str,
chain: ChainId,
transport: F,
) -> Result<Option<String>, String>
where
F: Fn(&str) -> Fut,
Fut: Future<Output = Result<String, String>>,
{
resolve_username_with_async(username, chain, transport).await
}
pub async fn resolve_identity(
account_id: &str,
chain: ChainId,
) -> Result<Option<ConsumerInfo>, String> {
let endpoints = http_endpoints(chain);
if endpoints.is_empty() {
return Err(format!(
"no HTTP endpoint configured for chain {chain:?}; \
use resolve_identity_with() to supply a custom transport"
));
}
resolve_identity_with_async(account_id, |req| {
let req_owned = req.to_owned();
async move { http_transport(&req_owned, endpoints).await }
})
.await
}
pub async fn resolve_identity_with<F, Fut>(
account_id: &str,
transport: F,
) -> Result<Option<ConsumerInfo>, String>
where
F: Fn(&str) -> Fut,
Fut: Future<Output = Result<String, String>>,
{
resolve_identity_with_async(account_id, transport).await
}
async fn resolve_username_with_async<F, Fut>(
username: &str,
chain: ChainId,
transport: F,
) -> Result<Option<String>, String>
where
F: Fn(&str) -> Fut,
Fut: Future<Output = Result<String, String>>,
{
let normalised = normalize_username(username).map_err(|e| e.to_string())?;
type DecodeFn = fn(&[u8]) -> Result<Option<[u8; 32]>, host_encoding::identity::IdentityError>;
let (key, decode): (Vec<u8>, DecodeFn) = match chain {
ChainId::Individuality => (username_owner_of_key(&normalised), decode_username_owner),
ChainId::PaseoPeople | ChainId::PolkadotPeople => (
username_info_of_key(&normalised),
decode_username_info_owner,
),
other => {
return Err(format!(
"resolve_username is not supported for chain {other:?}; \
only Individuality, PaseoPeople, and PolkadotPeople are supported"
));
}
};
let key_hex = host_encoding::hex_encode(&key);
let request = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "state_getStorage",
"params": [key_hex]
})
.to_string();
let resp_str = transport(&request).await?;
let raw_bytes = extract_storage_bytes(&resp_str)?;
match raw_bytes {
None => Ok(None),
Some(bytes) => {
let account_opt = decode(&bytes).map_err(|e| e.to_string())?;
Ok(account_opt.as_ref().map(account_id_to_hex))
}
}
}
async fn resolve_identity_with_async<F, Fut>(
account_id: &str,
transport: F,
) -> Result<Option<ConsumerInfo>, String>
where
F: Fn(&str) -> Fut,
Fut: Future<Output = Result<String, String>>,
{
let account_bytes = host_encoding::hex_decode(account_id)
.ok_or_else(|| format!("invalid hex account_id: {account_id}"))?;
if account_bytes.len() != 32 {
return Err(format!(
"account_id must be 32 bytes (64 hex chars), got {} bytes",
account_bytes.len()
));
}
let mut account_arr = [0u8; 32];
account_arr.copy_from_slice(&account_bytes);
let key = consumers_key(&account_arr);
let key_hex = host_encoding::hex_encode(&key);
let request = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "state_getStorage",
"params": [key_hex]
})
.to_string();
let resp_str = transport(&request).await?;
let raw_bytes = extract_storage_bytes(&resp_str)?;
match raw_bytes {
None => Ok(None),
Some(bytes) => {
let info = decode_consumer_info(&bytes).map_err(|e| e.to_string())?;
Ok(Some(info))
}
}
}
fn extract_storage_bytes(resp_str: &str) -> Result<Option<Vec<u8>>, String> {
let body: serde_json::Value = serde_json::from_str(resp_str)
.map_err(|e| format!("failed to parse JSON-RPC response: {e}"))?;
if let Some(err) = body.get("error") {
return Err(format!("JSON-RPC error from node: {err}"));
}
match body.get("result") {
None => Err("JSON-RPC response missing 'result' field".into()),
Some(serde_json::Value::Null) => Ok(None),
Some(serde_json::Value::String(hex)) => {
let bytes = host_encoding::hex_decode(hex).ok_or_else(|| {
format!(
"invalid hex in state_getStorage response ({} chars)",
hex.len()
)
})?;
Ok(Some(bytes))
}
Some(other) => Err(format!(
"unexpected result type in state_getStorage response: {other}"
)),
}
}
async fn http_transport(request: &str, endpoints: &[&str]) -> Result<String, String> {
let client = shared_client();
for endpoint in endpoints {
log::info!("[identity] trying RPC endpoint: {endpoint}");
let result = client
.post(*endpoint)
.header("Content-Type", "application/json")
.body(request.to_owned())
.send()
.await;
match result {
Ok(resp) => {
let bytes = match resp.bytes().await {
Ok(b) => b,
Err(e) => {
log::warn!("[identity] failed to read response from {endpoint}: {e}");
continue;
}
};
match String::from_utf8(bytes.to_vec()) {
Ok(s) => return Ok(s),
Err(e) => {
log::warn!("[identity] non-UTF-8 response from {endpoint}: {e}");
continue;
}
}
}
Err(e) => {
log::warn!("[identity] HTTP error for {endpoint}: {e}");
continue;
}
}
}
Err("all RPC endpoints failed for identity resolution".into())
}
#[cfg(test)]
mod tests {
use super::*;
use host_encoding::identity::Credibility;
#[tokio::test]
async fn test_resolves_none_for_absent_username() {
let result = resolve_username_with("alice", ChainId::PaseoPeople, |_req| async {
Ok(r#"{"jsonrpc":"2.0","id":1,"result":null}"#.to_string())
})
.await;
assert_eq!(result, Ok(None));
}
#[tokio::test]
async fn test_resolves_some_account_id_paseo_people() {
let hex_payload = format!("0x{}00", "ab".repeat(32));
let resp = format!(r#"{{"jsonrpc":"2.0","id":1,"result":"{hex_payload}"}}"#);
let result = resolve_username_with("alice", ChainId::PaseoPeople, move |_req| {
let resp = resp.clone();
async move { Ok(resp) }
})
.await;
let expected_hex = format!("0x{}", "ab".repeat(32));
assert_eq!(result, Ok(Some(expected_hex)));
}
#[tokio::test]
async fn test_returns_error_on_rpc_error() {
let result = resolve_username_with("alice", ChainId::PaseoPeople, |_req| async {
Ok(
r#"{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"node error"}}"#
.to_string(),
)
})
.await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("JSON-RPC error"));
}
#[tokio::test]
async fn test_rejects_empty_username_before_network() {
let result = resolve_username_with("", ChainId::PaseoPeople, |_req| async {
Ok(r#"{"jsonrpc":"2.0","id":1,"result":null}"#.to_string())
})
.await;
assert!(result.is_err(), "empty username must be rejected");
}
#[tokio::test]
async fn test_rejects_invalid_username_before_network() {
let result = resolve_username_with("alice!", ChainId::PaseoPeople, |_req| async {
unreachable!("transport must not be called for invalid username")
})
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_returns_error_when_transport_fails() {
let result = resolve_username_with("alice", ChainId::PaseoPeople, |_req| async {
Err("simulated network failure".to_string())
})
.await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("simulated network failure"));
}
#[tokio::test]
async fn test_returns_error_for_chain_without_endpoint() {
let result = resolve_username("alice", ChainId::PolkadotAssetHub).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("no HTTP endpoint configured"));
}
#[tokio::test]
async fn test_returns_error_for_unsupported_chain_in_with_variant() {
let result = resolve_username_with("alice", ChainId::PolkadotAssetHub, |_req| async {
unreachable!("transport must not be called for unsupported chain")
})
.await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("not supported for chain"));
}
#[tokio::test]
async fn test_individuality_resolves_none_for_absent_username() {
let result = resolve_username_with("alice", ChainId::Individuality, |_req| async {
Ok(r#"{"jsonrpc":"2.0","id":1,"result":null}"#.to_string())
})
.await;
assert_eq!(result, Ok(None));
}
#[tokio::test]
async fn test_individuality_resolves_some_account_id() {
let hex_payload = format!("0x{}", "cd".repeat(32));
let resp = format!(r#"{{"jsonrpc":"2.0","id":1,"result":"{hex_payload}"}}"#);
let result = resolve_username_with("alice", ChainId::Individuality, move |_req| {
let resp = resp.clone();
async move { Ok(resp) }
})
.await;
let expected_hex = format!("0x{}", "cd".repeat(32));
assert_eq!(result, Ok(Some(expected_hex)));
}
fn make_consumer_info_bytes() -> Vec<u8> {
let mut buf = Vec::new();
buf.extend_from_slice(&[0x04u8; 65]); buf.push(0x00); buf.push(b"alice".len() as u8 * 4); buf.extend_from_slice(b"alice"); buf.push(0x00); buf
}
#[tokio::test]
async fn test_resolve_identity_returns_none_for_absent_slot() {
let account_id = format!("0x{}", "ab".repeat(32));
let result = resolve_identity_with(&account_id, |_req| async {
Ok(r#"{"jsonrpc":"2.0","id":1,"result":null}"#.to_string())
})
.await;
assert_eq!(result, Ok(None));
}
#[tokio::test]
async fn test_resolve_identity_decodes_consumer_info() {
let account_id = format!("0x{}", "ab".repeat(32));
let payload = make_consumer_info_bytes();
let hex_payload = host_encoding::hex_encode(&payload);
let resp = format!(r#"{{"jsonrpc":"2.0","id":1,"result":"{hex_payload}"}}"#);
let result = resolve_identity_with(&account_id, move |_req| {
let resp = resp.clone();
async move { Ok(resp) }
})
.await;
let info = result.unwrap().unwrap();
assert_eq!(info.lite_username, "alice");
assert_eq!(info.full_username, None);
assert_eq!(info.credibility, Credibility::Lite);
assert_eq!(info.identifier_key, vec![0x04u8; 65]);
}
#[tokio::test]
async fn test_resolve_identity_rejects_invalid_account_id_hex() {
let result = resolve_identity_with("not-hex", |_req| async {
unreachable!("transport must not be called for invalid account_id")
})
.await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("invalid hex"));
}
#[tokio::test]
async fn test_resolve_identity_rejects_wrong_length_account_id() {
let result = resolve_identity_with(&format!("0x{}", "ab".repeat(16)), |_req| async {
unreachable!("transport must not be called for invalid account_id")
})
.await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("32 bytes"));
}
#[tokio::test]
async fn test_resolve_identity_returns_error_for_chain_without_endpoint() {
let account_id = format!("0x{}", "ab".repeat(32));
let result = resolve_identity(&account_id, ChainId::PolkadotAssetHub).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("no HTTP endpoint configured"));
}
}