diff --git a/config.template.toml b/config.template.toml index 14096a5..dc75e0c 100644 --- a/config.template.toml +++ b/config.template.toml @@ -22,6 +22,11 @@ argent_multicall = "0xXXXXXXXXXXXX" rpc_url = "xxxxxx" refresh_delay = 60 # in seconds ipfs_gateway = "https://gateway.pinata.cloud/ipfs/" # or https://ipfs.io/ipfs/ +discord_token = "xxxxxx" +discord_api_url = "https://discord.com/api" +twitter_api_key = "xxxxxx" +twitter_api_url = "xxxxxx" +github_api_url = "https://api.github.com" [starkscan] api_url = "https://api-testnet.starkscan.co/api/v0" @@ -83,4 +88,21 @@ private_key = "0xXXXXXXXXXXXX" polygon = 2147483785 optimism = 2147483658 base = 2147492101 -arbitrum = 2147525809 \ No newline at end of file +arbitrum = 2147525809 + +[evm_records_verifiers] + +[evm_records_verifiers."com.discord"] +verifier_contract = "0x0182EcE8173C216A395f4828e1523541b7e3600bf190CB252E1a1A0cE219d184" +field = "discord" +handler = "GetDiscordName" + +[evm_records_verifiers."com.github"] +verifier_contract = "0x0182EcE8173C216A395f4828e1523541b7e3600bf190CB252E1a1A0cE219d184" +field = "github" +handler = "GetGithubName" + +[evm_records_verifiers."com.twitter"] +verifier_contract = "0x0182EcE8173C216A395f4828e1523541b7e3600bf190CB252E1a1A0cE219d184" +field = "twitter" +handler = "GetTwitterName" \ No newline at end of file diff --git a/src/config.rs b/src/config.rs index 54cab54..2a8918a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -6,6 +6,8 @@ use std::collections::HashMap; use std::env; use std::fs; +use crate::endpoints::crosschain::ethereum::text_records::HandlerType; + macro_rules! pub_struct { ($($derive:path),*; $name:ident {$($field:ident: $t:ty),* $(,)?}) => { #[derive($($derive),*)] @@ -73,6 +75,11 @@ pub_struct!(Clone, Debug, Deserialize; Variables { rpc_url: String, refresh_delay: f64, ipfs_gateway: String, + discord_token: String, + discord_api_url: String, + twitter_api_key: String, + twitter_api_url: String, + github_api_url: String, }); #[derive(Deserialize)] @@ -94,6 +101,12 @@ pub_struct!(Clone, Debug, Deserialize; Evm { #[derive(Debug, Clone)] pub struct OffchainResolvers(HashMap); +pub_struct!(Clone, Debug, Deserialize; EvmRecordVerifier { + verifier_contract: FieldElement, + field: String, + handler: HandlerType, +}); + #[derive(Deserialize)] struct RawConfig { server: Server, @@ -107,6 +120,7 @@ struct RawConfig { offchain_resolvers: OffchainResolvers, evm: Evm, evm_networks: HashMap, + evm_records_verifiers: HashMap, } pub_struct!(Clone, Deserialize; Config { @@ -122,6 +136,7 @@ pub_struct!(Clone, Deserialize; Config { offchain_resolvers: OffchainResolvers, evm: Evm, evm_networks: HashMap, + evm_records_verifiers: HashMap, }); impl Altcoins { @@ -229,6 +244,7 @@ impl From for Config { offchain_resolvers: raw.offchain_resolvers, evm: raw.evm, evm_networks: reversed_evm_networks, + evm_records_verifiers: raw.evm_records_verifiers, } } } diff --git a/src/endpoints/crosschain/ethereum/mod.rs b/src/endpoints/crosschain/ethereum/mod.rs index 61616ce..a89f482 100644 --- a/src/endpoints/crosschain/ethereum/mod.rs +++ b/src/endpoints/crosschain/ethereum/mod.rs @@ -1,3 +1,4 @@ pub mod lookup; pub mod resolve; +pub mod text_records; pub mod utils; diff --git a/src/endpoints/crosschain/ethereum/resolve.rs b/src/endpoints/crosschain/ethereum/resolve.rs index 62a93bb..363ec41 100644 --- a/src/endpoints/crosschain/ethereum/resolve.rs +++ b/src/endpoints/crosschain/ethereum/resolve.rs @@ -37,6 +37,8 @@ use starknet::{ }; use starknet_id::encode; +use super::text_records::{get_unbounded_user_data, get_verifier_data}; + #[derive(Deserialize, Debug, Clone)] pub struct ResolveQuery { data: String, // data encoded @@ -147,7 +149,6 @@ pub async fn handler(State(state): State>, query: Query) -> impl I let payload: Vec = match resolver_function_call { ResolverFunctionCall::Text(_alt_hash, record) => { match record.as_str() { - // Records available "com.discord" "com.github" "com.twitter" "avatar" => { match get_profile_picture( &state.conf, @@ -170,8 +171,50 @@ pub async fn handler(State(state): State>, query: Query) -> impl I } } _ => { - println!("Record not implemented: {:?}", record); - return get_error(format!("Record not implemented {}", record)); + // we check if this data was added through a verifier + // let record_config = state.conf.evm_records_verifiers.get(&record).unwrap(); + match state.conf.evm_records_verifiers.get(&record) { + Some(record_config) => { + let record_data = get_verifier_data( + &state.conf, + &provider, + id, + record_config, + ) + .await; + match record_data { + Some(record_data) => { + vec![Token::String(record_data)] + } + None => { + return get_error(format!( + "No data found for record: {}", + record + )); + } + } + } + None => { + // if not we fetch user data for this record + // existing records : header (image url), display, name, url, description, email, mail, notice, location, phone + match get_unbounded_user_data( + &state.conf, + &provider, + id, + &record, + ) + .await + { + Some(data) => vec![Token::String(data)], + None => { + return get_error(format!( + "No data found for record: {}", + record + )); + } + } + } + } } } } diff --git a/src/endpoints/crosschain/ethereum/text_records.rs b/src/endpoints/crosschain/ethereum/text_records.rs new file mode 100644 index 0000000..b146069 --- /dev/null +++ b/src/endpoints/crosschain/ethereum/text_records.rs @@ -0,0 +1,203 @@ +use anyhow::{anyhow, Context, Result}; +use reqwest::{Client, StatusCode}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use starknet::{ + core::{ + types::{BlockId, BlockTag, FieldElement, FunctionCall}, + utils::{cairo_short_string_to_felt, parse_cairo_short_string}, + }, + macros::selector, + providers::{jsonrpc::HttpTransport, JsonRpcClient, Provider}, +}; + +use crate::config::{Config, EvmRecordVerifier}; + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub enum HandlerType { + Static, + GetDiscordName, + GetGithubName, + GetTwitterName, +} + +#[derive(Deserialize, Debug)] +struct GithubUser { + login: String, +} + +#[derive(Deserialize, Debug)] +struct DiscordUser { + username: String, +} + +impl EvmRecordVerifier { + pub async fn execute_handler(&self, config: &Config, id: FieldElement) -> Result { + match self.handler { + HandlerType::Static => Ok(FieldElement::to_string(&id)), + HandlerType::GetDiscordName => self.get_discord_name(config, id).await, + HandlerType::GetGithubName => self.get_github_name(config, id).await, + HandlerType::GetTwitterName => self.get_twitter_name(config, id).await, + } + } + + async fn get_discord_name(&self, config: &Config, id: FieldElement) -> Result { + let social_id = FieldElement::to_string(&id); + let url = format!("{}/users/{}", config.variables.discord_api_url, social_id); + let client = Client::new(); + let resp = client + .get(&url) + .header("Content-Type", "application/json") + .header( + "Authorization", + format!("Bot {}", config.variables.discord_token), + ) + .send() + .await? + .json::() + .await + .context("Failed to parse JSON response from Discord API")?; + + Ok(resp.username) + } + async fn get_github_name(&self, config: &Config, id: FieldElement) -> Result { + let social_id = FieldElement::to_string(&id); + let url = format!("{}/user/{}", config.variables.github_api_url, social_id); + let client = Client::builder() + .user_agent("request") + .build() + .context("Failed to build HTTP client")?; + let response = client + .get(&url) + .send() + .await + .context("Failed to send request to GitHub")?; + + if response.status() != StatusCode::OK { + anyhow::bail!("GitHub API returned non-OK status: {}", response.status()); + } + + let text = response + .text() + .await + .context("Failed to read response text")?; + let user: GithubUser = + serde_json::from_str(&text).context("Failed to deserialize GitHub response")?; + + Ok(user.login) + } + + async fn get_twitter_name(&self, config: &Config, id: FieldElement) -> Result { + let social_id = FieldElement::to_string(&id); + let client = Client::new(); + let response = client + .get(format!( + "{}/get-user-by-id", + config.variables.twitter_api_url + )) + .header("X-RapidAPI-Key", config.variables.twitter_api_key.clone()) + .header("X-RapidAPI-Host", "twttrapi.p.rapidapi.com") + .query(&[("user_id", &social_id)]) + .send() + .await?; + + if response.status() != StatusCode::OK { + anyhow::bail!("Twitter API returned non-OK status: {}", response.status()); + } + let response_body = response.text().await?; + let json: Value = serde_json::from_str(&response_body)?; + let screen_name = json + .get("data") + .and_then(|data| data.get("user_result")) + .and_then(|user_result| user_result.get("result")) + .and_then(|result| result.get("legacy")) + .and_then(|legacy| legacy.get("screen_name")) + .and_then(|screen_name| screen_name.as_str()) + .ok_or_else(|| anyhow!("Failed to extract screen name")); + + Ok(screen_name.map(|name| name.to_string()).unwrap()) + } +} + +pub async fn get_verifier_data( + config: &Config, + provider: &JsonRpcClient, + id: FieldElement, + record_config: &EvmRecordVerifier, +) -> Option { + let call_result = provider + .call( + FunctionCall { + contract_address: config.contracts.starknetid, + entry_point_selector: selector!("get_verifier_data"), + calldata: vec![ + id, + cairo_short_string_to_felt(&record_config.field).unwrap(), + record_config.verifier_contract, + FieldElement::ZERO, + ], + }, + BlockId::Tag(BlockTag::Latest), + ) + .await; + + match call_result { + Ok(result) => { + if result[0] == FieldElement::ZERO { + return None; + } + + match record_config.execute_handler(config, result[0]).await { + Ok(name) => Some(name), + Err(e) => { + println!("Error while executing handler: {:?}", e); + None + } + } + } + Err(e) => { + println!("Error while fetchingverifier data: {:?}", e); + None + } + } +} + +pub async fn get_unbounded_user_data( + config: &Config, + provider: &JsonRpcClient, + id: FieldElement, + field: &str, +) -> Option { + let call_result = provider + .call( + FunctionCall { + contract_address: config.contracts.starknetid, + entry_point_selector: selector!("get_unbounded_user_data"), + calldata: vec![ + id, + cairo_short_string_to_felt(field).unwrap(), + FieldElement::ZERO, + ], + }, + BlockId::Tag(BlockTag::Latest), + ) + .await; + match call_result { + Ok(result) => { + if result[0] == FieldElement::ZERO { + return None; + } + let res = result + .iter() + .skip(1) + .filter_map(|val| parse_cairo_short_string(val).ok()) + .collect::>() // Collect into a vector of strings + .join(""); + Some(res) + } + Err(e) => { + println!("Error while fetchingverifier data: {:?}", e); + None + } + } +}