data3 0.2.0

SDK for accessing ethereum data hosted by indexers.
Documentation
use crate::{error::Error, network::Network, serde_override::SerdeResponseParse};
use alloy::primitives::Address;
use reqwest::Client;
use serde_json::{Value, json};

pub mod types;
use types::{AlchemyData, AlchemyTokens, TokenBalancesByWalletEntry, TokensByWalletEntry};

pub struct Alchemy {
    api_key: String,
}

impl Alchemy {
    pub fn new(api_key: String) -> Self {
        Alchemy { api_key }
    }

    pub fn network(network: &Network) -> &'static str {
        match network {
            Network::EthereumMainnet => "eth-mainnet",
            Network::EthereumSepolia => "eth-sepolia",
            Network::EthereumHolesky => "eth-holesky",
            Network::EthereumHoodi => "eth-hoodi",
            Network::OptimismMainnet => "opt-mainnet",
            Network::OptimismSepolia => "opt-sepolia",
            Network::ArbitrumMainnet => "arb-mainnet",
            Network::ArbitrumSepolia => "arb-sepolia",
            Network::BaseMainnet => "base-mainnet",
            Network::BaseSepolia => "base-sepolia",
            _ => unimplemented!(),
        }
    }

    // TODO improve this
    pub async fn get_price(&self, symbol: &str) -> crate::Result<(f64, String)> {
        let client = reqwest::Client::new();

        let res = client
            .get(format!(
                "https://api.g.alchemy.com/prices/v1/{}/tokens/by-symbol?symbols={symbol}",
                self.api_key
            ))
            .header("accept", "application/json")
            .send()
            .await?
            .json::<Value>()
            .await?;
        let res = res.as_object().ok_or("response not an object")?;

        let data = res.get("data").ok_or("data not found in response")?;

        let data = data
            .as_array()
            .ok_or("data not an object")?
            .first()
            .ok_or("data array is empty")?;

        let data_symbol = data
            .get("symbol")
            .ok_or("symbol not found in response")?
            .as_str()
            .ok_or("symbol not a string")?;

        if data_symbol != symbol {
            return Err("symbol in response does not match requested symbol".into());
        }

        let prices = data
            .get("prices")
            .ok_or("prices not found in response")?
            .as_array()
            .ok_or("prices not array")?
            .first()
            .ok_or("prices array is empty")?
            .as_object()
            .ok_or("prices[0] is not object")?;

        let currency = prices
            .get("currency")
            .ok_or("currency not found in prices[0]")?
            .as_str()
            .ok_or("currency not a string")?;

        if currency != "usd" {
            return Err("currency is not USD".into());
        }

        let value = prices
            .get("value")
            .ok_or("value not found in prices[0]")?
            .as_str()
            .ok_or("currency not a string")?;

        let last_updated_at = prices
            .get("lastUpdatedAt")
            .ok_or("lastUpdatedAt not found in prices[0]")?
            .as_str()
            .ok_or("currency not a string")?;

        Ok((value.parse()?, last_updated_at.to_string()))
    }

    // docs: https://docs.alchemy.com/reference/get-tokens-by-address
    pub async fn get_tokens_by_wallet(
        &self,
        address: Address,
        networks: Vec<Network>,
    ) -> Result<Vec<TokensByWalletEntry>, Error> {
        let mut result = Vec::new();
        for networks in networks.chunks(5) {
            let body = json!({
                "addresses": [
                    {
                        "address": address,
                        "networks": networks.iter().map(Alchemy::network).collect::<Vec<&str>>()
                    }
                ],
                "withMetadata": true,
                "withPrices": true
            });

            let client = Client::new();
            let response = client
                .post(format!(
                    "https://api.g.alchemy.com/data/v1/{}/assets/tokens/by-address",
                    self.api_key
                ))
                .header("accept", "application/json")
                .header("content-type", "application/json")
                .json(&body)
                .send()
                .await?;

            let parsed = response
                .serde_parse_custom::<AlchemyData<AlchemyTokens<TokensByWalletEntry>>>()
                .await?;

            result.extend(parsed.data.tokens);
        }

        Ok(result)
    }

    pub async fn get_token_balances_by_wallet(
        &self,
        address: Address,
        networks: Vec<Network>,
    ) -> Result<Vec<TokenBalancesByWalletEntry>, Error> {
        let body = json!({
            "addresses": [
                {
                    "address": address,
                    "networks": networks.iter().map(Alchemy::network).collect::<Vec<&str>>()
                }
            ]
        });

        let client = Client::new();
        let response = client
            .post(format!(
                "https://api.g.alchemy.com/data/v1/{}/assets/tokens/balances/by-address",
                self.api_key
            ))
            .header("accept", "application/json")
            .header("content-type", "application/json")
            .json(&body)
            .send()
            .await?;

        let parsed = response
            .serde_parse_custom::<AlchemyData<AlchemyTokens<TokenBalancesByWalletEntry>>>()
            .await?;
        Ok(parsed.data.tokens)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn test_get_price() {
        let api_key = std::env::var("ALCHEMY_API_KEY").expect("ALCHEMY_API_KEY key not set in env");
        let alchemy = Alchemy::new(api_key);
        let (price, last_updated_at) = alchemy.get_price("ETH").await.unwrap();
        assert!(price > 0.0);
        assert!(!last_updated_at.is_empty());
    }

    #[tokio::test]
    async fn test_get_tokens_by_wallet() {
        let api_key = std::env::var("ALCHEMY_API_KEY").expect("ALCHEMY_API_KEY key not set in env");
        let alchemy = Alchemy::new(api_key);
        let tokens = alchemy
            .get_tokens_by_wallet(
                Address::ZERO,
                vec![
                    Network::EthereumMainnet,
                    Network::ArbitrumMainnet,
                    Network::OptimismMainnet,
                ],
            )
            .await
            .unwrap();
        assert!(!tokens.is_empty());
    }
}