diff --git a/config.template.toml b/config.template.toml index 3ffc035..b84f1d2 100644 --- a/config.template.toml +++ b/config.template.toml @@ -27,3 +27,36 @@ api_key = "xxxxxx" [solana] rpc_url = "https://xxxxxxx.solana-mainnet.quiknode.pro/xxxxxxx" private_key = "xxxxxxx" + +[altcoins] +avnu_api = "https://starknet.impulse.avnu.fi/v1" +private_key = "123" + +# Ethereum (ETH) is not enabled for the moment as it is already supported by default buy. +# [altcoins.ETH] +# address = "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7" +# min_price = 1 +# max_price = 1 +# decimals = 18 +# max_quote_validity = 3600 # in seconds for ETH + +[altcoins.STRK] +address = "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d" +min_price = 500 +max_price = 5000 +decimals = 18 +max_quote_validity = 300 # it moves faster so we reduce the quote validity + +[altcoins.USDC] +address = "0x053c91253bc9682c04929ca02ed00b3e423f6710d2ee7e0d5ebb06f3ecf368a8" +min_price = 2000 +max_price = 10000 +decimals = 6 # not sure really +max_quote_validity = 600 + +[altcoins.USDT] +address = "0x068f5c6a61780768455de69077e07e89787839bf8166decfbf92b645209c0fb8" +min_price = 2000 +max_price = 10000 +decimals = 18 +max_quote_validity = 600 diff --git a/src/config.rs b/src/config.rs index 81ccd13..ad6f38c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,4 @@ -use serde::Deserialize; +use serde::{Deserialize, Deserializer}; use starknet::core::types::FieldElement; use std::collections::HashMap; use std::env; @@ -44,6 +44,28 @@ pub_struct!(Clone, Deserialize; Solana { private_key: FieldElement, }); +pub_struct!(Clone, Debug, Deserialize; AltcoinData { + address: FieldElement, + min_price: u64, + max_price: u64, + decimals: u32, + max_quote_validity: i64 +}); + +#[derive(Debug, Deserialize)] +struct TempAltcoins { + avnu_api: String, + private_key: FieldElement, + #[serde(flatten)] + data: HashMap, +} + +pub_struct!(Clone, Debug; Altcoins { + avnu_api: String, + private_key: FieldElement, + data: HashMap, +}); + #[derive(Deserialize)] struct RawConfig { server: Server, @@ -52,6 +74,7 @@ struct RawConfig { starkscan: Starkscan, custom_resolvers: HashMap>, solana: Solana, + altcoins: Altcoins, } pub_struct!(Clone, Deserialize; Config { @@ -62,8 +85,44 @@ pub_struct!(Clone, Deserialize; Config { custom_resolvers: HashMap>, reversed_resolvers: HashMap, solana: Solana, + altcoins: Altcoins, }); +impl Altcoins { + fn new(temp: TempAltcoins) -> Self { + let data: HashMap = temp + .data + .into_values() + .map(|val| { + let altcoin_data = AltcoinData { + address: val.address, + min_price: val.min_price, + max_price: val.max_price, + decimals: val.decimals, + max_quote_validity: val.max_quote_validity, + }; + (val.address, altcoin_data) + }) + .collect(); + + Altcoins { + avnu_api: temp.avnu_api, + private_key: temp.private_key, + data, + } + } +} + +impl<'de> Deserialize<'de> for Altcoins { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let temp = TempAltcoins::deserialize(deserializer)?; + Ok(Altcoins::new(temp)) + } +} + impl From for Config { fn from(raw: RawConfig) -> Self { let mut reversed_resolvers = HashMap::new(); @@ -80,6 +139,7 @@ impl From for Config { custom_resolvers: raw.custom_resolvers, reversed_resolvers, solana: raw.solana, + altcoins: raw.altcoins, } } } diff --git a/src/endpoints/get_altcoin_quote.rs b/src/endpoints/get_altcoin_quote.rs new file mode 100644 index 0000000..aa71cbc --- /dev/null +++ b/src/endpoints/get_altcoin_quote.rs @@ -0,0 +1,128 @@ +use std::sync::Arc; + +use axum::{ + extract::{Query, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use axum_auto_routes::route; +use chrono::Duration; +use serde::Deserialize; +use serde_json::json; +use starknet::core::{ + crypto::{ecdsa_sign, pedersen_hash}, + types::FieldElement, +}; + +use crate::{models::AppState, utils::get_error}; + +#[derive(Deserialize)] +pub struct AddrQuery { + erc20_addr: FieldElement, +} + +#[derive(Deserialize, Debug)] +pub struct AvnuApiResult { + address: FieldElement, + currentPrice: f64, +} + +lazy_static::lazy_static! { + static ref QUOTE_STR: FieldElement = FieldElement::from_dec_str("724720344857006587549020016926517802128122613457935427138661").unwrap(); +} + +#[route(get, "/get_altcoin_quote", crate::endpoints::get_altcoin_quote)] +pub async fn handler( + State(state): State>, + Query(query): Query, +) -> impl IntoResponse { + // check if erc20_addr is whitelisted + if !state.conf.altcoins.data.contains_key(&query.erc20_addr) { + return get_error("Token not supported".to_string()); + } + + let altcoin_data = state.conf.altcoins.data.get(&query.erc20_addr).unwrap(); + // fetch quote from avnu api + let url = format!( + "{}/tokens/short?in=0x49d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + state.conf.altcoins.avnu_api + ); + let client = reqwest::Client::new(); + match client.get(&url).send().await { + Ok(response) => match response.text().await { + Ok(text) => match serde_json::from_str::>(&text) { + Ok(res) => { + let result = res + .iter() + .find(|&api_response| api_response.address == query.erc20_addr); + match result { + Some(data) => { + // compute message hash + let now = chrono::Utc::now(); + let max_validity_timestamp = (now + + Duration::seconds(altcoin_data.max_quote_validity)) + .timestamp(); + let quote = 1.0 / data.currentPrice; + // check if quote is within the valid range + if quote < altcoin_data.min_price as f64 + || quote > altcoin_data.max_price as f64 + { + return get_error("Quote out of range".to_string()); + } + // convert current price to wei and return an integer as AVNU api can use more than 18 decimals + let current_price_wei = + ((quote * (10u128.pow(altcoin_data.decimals) as f64)) as u128) + .to_string(); + let message_hash = pedersen_hash( + &pedersen_hash( + &pedersen_hash( + &query.erc20_addr, + &FieldElement::from_dec_str( + current_price_wei.to_string().as_str(), + ) + .unwrap(), + ), + &FieldElement::from_dec_str( + max_validity_timestamp.to_string().as_str(), + ) + .unwrap(), + ), + "E_STR, + ); + match ecdsa_sign( + &state.conf.altcoins.private_key.clone(), + &message_hash, + ) { + Ok(signature) => ( + StatusCode::OK, + Json(json!({ + "quote": current_price_wei, + "r": signature.r, + "s": signature.s, + "max_quote_validity": max_validity_timestamp + })), + ) + .into_response(), + Err(e) => get_error(format!( + "Error while generating Starknet signature: {}", + e + )), + } + } + None => get_error("Token address not found".to_string()), + } + } + Err(e) => get_error(format!( + "Failed to deserialize result from AVNU API: {} for response: {}", + e, text + )), + }, + Err(e) => get_error(format!( + "Failed to get JSON response while fetching token quote: {}", + e + )), + }, + Err(e) => get_error(format!("Failed to fetch quote from AVNU api: {}", e)), + } +} diff --git a/src/endpoints/mod.rs b/src/endpoints/mod.rs index 4adf95e..42d14df 100644 --- a/src/endpoints/mod.rs +++ b/src/endpoints/mod.rs @@ -5,6 +5,7 @@ pub mod addr_to_external_domains; pub mod addr_to_full_ids; pub mod addr_to_token_id; pub mod addrs_to_domains; +pub mod get_altcoin_quote; pub mod crosschain; pub mod data_to_ids; pub mod domain_to_addr; diff --git a/src/endpoints/stats/count_club_domains.rs b/src/endpoints/stats/count_club_domains.rs index 87278da..4e9fe5c 100644 --- a/src/endpoints/stats/count_club_domains.rs +++ b/src/endpoints/stats/count_club_domains.rs @@ -9,8 +9,8 @@ use axum_auto_routes::route; use futures::TryStreamExt; use mongodb::bson::{self, doc, Bson}; use serde::{Deserialize, Serialize}; -use std::sync::Arc; use std::collections::HashMap; +use std::sync::Arc; #[derive(Serialize)] pub struct CountClubDomainsData { @@ -23,7 +23,11 @@ pub struct CountClubDomainsQuery { since: i64, } -#[route(get, "/stats/count_club_domains", crate::endpoints::stats::count_club_domains)] +#[route( + get, + "/stats/count_club_domains", + crate::endpoints::stats::count_club_domains +)] pub async fn handler( State(state): State>, Query(query): Query, @@ -31,7 +35,9 @@ pub async fn handler( let mut headers = HeaderMap::new(); headers.insert("Cache-Control", HeaderValue::from_static("max-age=60")); - let domain_collection = state.starknetid_db.collection::("domains"); + let domain_collection = state + .starknetid_db + .collection::("domains"); let subdomain_collection = state .starknetid_db .collection::("custom_resolutions"); @@ -162,51 +168,66 @@ pub async fn handler( } ], None).await.unwrap().try_collect::>().await.unwrap(); - let mut count_99 = 0; - let mut count_999 = 0; - let mut count_10k = 0; - - let mut output: Vec> = Vec::new(); - let mut output_map: HashMap = HashMap::new(); + let mut count_99 = 0; + let mut count_999 = 0; + let mut count_10k = 0; - for doc in &db_output { - if let Ok(club) = doc.get_str("club") { - match club { - "99" => count_99 = doc.get_i32("count").unwrap_or_default(), - "999" => count_999 = doc.get_i32("count").unwrap_or_default(), - "10k" => count_10k = doc.get_i32("count").unwrap_or_default(), - _ => (), - } + let mut output: Vec> = Vec::new(); + let mut output_map: HashMap = HashMap::new(); + + for doc in &db_output { + if let Ok(club) = doc.get_str("club") { + match club { + "99" => count_99 = doc.get_i32("count").unwrap_or_default(), + "999" => count_999 = doc.get_i32("count").unwrap_or_default(), + "10k" => count_10k = doc.get_i32("count").unwrap_or_default(), + _ => (), } } + } - for doc in db_output { - if let Ok(club) = doc.get_str("club") { - match club { - "two_letters" => { - output_map.insert(club.to_string(), doc.get_i32("count").unwrap_or_default() + count_99); - } - "three_letters" => { - output_map.insert(club.to_string(), doc.get_i32("count").unwrap_or_default() + count_999); - } - "four_letters" => { - output_map.insert(club.to_string(), doc.get_i32("count").unwrap_or_default() + count_10k); - } - _ => { - output_map.insert(club.to_string(), doc.get_i32("count").unwrap_or_default()); - } + for doc in db_output { + if let Ok(club) = doc.get_str("club") { + match club { + "two_letters" => { + output_map.insert( + club.to_string(), + doc.get_i32("count").unwrap_or_default() + count_99, + ); + } + "three_letters" => { + output_map.insert( + club.to_string(), + doc.get_i32("count").unwrap_or_default() + count_999, + ); + } + "four_letters" => { + output_map.insert( + club.to_string(), + doc.get_i32("count").unwrap_or_default() + count_10k, + ); + } + _ => { + output_map.insert(club.to_string(), doc.get_i32("count").unwrap_or_default()); } } - output.push(output_map.clone()); - output_map.clear(); } + output.push(output_map.clone()); + output_map.clear(); + } - for doc in subdomain_output { - output_map.insert(doc.get_str("club").unwrap_or_default().to_string(), doc.get_i32("count").unwrap_or_default()); - output_map.insert(doc.get_str("club").unwrap_or_default().to_string(), doc.get_i32("count").unwrap_or_default()); - output.push(output_map.clone()); - output_map.clear(); - } + for doc in subdomain_output { + output_map.insert( + doc.get_str("club").unwrap_or_default().to_string(), + doc.get_i32("count").unwrap_or_default(), + ); + output_map.insert( + doc.get_str("club").unwrap_or_default().to_string(), + doc.get_i32("count").unwrap_or_default(), + ); + output.push(output_map.clone()); + output_map.clear(); + } - (StatusCode::OK, headers, Json(output)).into_response() + (StatusCode::OK, headers, Json(output)).into_response() }