diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d3ad296..dac4508 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -2,17 +2,7 @@ name: CI on: push: - branches: - - main - - production - - staging - - ecs-cd pull_request: - branches: - - main - - production - - staging - - ecs-cd env: CARGO_TERM_COLOR: always @@ -65,6 +55,7 @@ jobs: SMTP_USERNAME: ${{ secrets.SMTP_USERNAME }} SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }} JWT_KEY: ${{ secrets.JWT_KEY }} + D_D_CLOUD_API_KEY: ${{ secrets.D_D_CLOUD_API_KEY }} run: cargo test --workspace ${{ matrix.flags }} clippy: diff --git a/Cargo.lock b/Cargo.lock index c574bab..3b344ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1784,6 +1784,7 @@ dependencies = [ "tower-http 0.5.2", "tracing", "tracing-subscriber", + "url", ] [[package]] @@ -5502,9 +5503,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.7" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", diff --git a/Cargo.toml b/Cargo.toml index 031f761..18a51e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ tracing-subscriber = "0.3.18" alloy = {version = "1.0", features = ["node-bindings", "network", "rpc-types"]} thiserror = "1.0.63" mimalloc = "0.1.45" +url = "2.5.8" hyper = "1.6.0" hyper-util = { version = "0.1", features = ["full"] } reqwest = { version = "0.12.24", features = ["json", "rustls-tls", "cookies", "stream"] } diff --git a/infra/opentofu/ecs/main.tf b/infra/opentofu/ecs/main.tf index ede2eb5..67db5e6 100644 --- a/infra/opentofu/ecs/main.tf +++ b/infra/opentofu/ecs/main.tf @@ -67,7 +67,7 @@ module "ecs" { cpu = 1024 memory = 2048 essential = false - image = "ghcr.io/pokt-network/path:sha-ca7acdd-rc" + image = "ghcr.io/pokt-network/path:sha-f812b42-rc" memory_reservation = 50 port_mappings = [ { diff --git a/migrations/0003_developer_dao_rpc.sql b/migrations/0003_developer_dao_rpc.sql new file mode 100644 index 0000000..d49d3a8 --- /dev/null +++ b/migrations/0003_developer_dao_rpc.sql @@ -0,0 +1,2 @@ +ALTER TABLE Customers ADD COLUMN suppression_list BOOL NOT NULL DEFAULT FALSE; +ALTER TABLE Customers ADD COLUMN marketing_email_consent BOOL NOT NULL DEFAULT FALSE; diff --git a/src/main.rs b/src/main.rs index f84f3cb..ff84f65 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,10 @@ use crate::middleware::{ }; use crate::routes::payment::{cancel, downgrade, upgrade}; use crate::routes::relayer::websockets::ws_handler; +use crate::routes::token_queries::{ + aggregate_balances, aggregate_single_token_bals, aggregate_token_bals_for_user, + get_batch_nft_info, +}; use crate::routes::types::{EmailLogin, JWTKey}; use crate::routes::{ activate::activate_account, @@ -53,21 +57,52 @@ async fn main() { .with_target(false) .init(); + let origin = if cfg!(feature = "dev") { + "http://localhost:5173" + } else { + "https://cloud.developerdao.com" + }; + let cors_api = CorsLayer::new() .allow_credentials(true) - .allow_origin("https://cloud.developerdao.com".parse::().unwrap()) + .allow_origin(origin.parse::().unwrap()) .allow_methods([Method::GET, Method::POST, Method::DELETE]) .allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION, header::COOKIE]); + #[cfg(feature = "dev")] + let relayer = Router::new() + .route("/rpc/{chain}/{api_key}", post(route_call)) + .route("/ws/{chain}/{api_key}", axum::routing::any(ws_handler)); + + #[cfg(not(feature = "dev"))] let relayer = Router::new() .route("/rpc/{chain}/{api_key}", post(route_call)) .route("/ws/{chain}/{api_key}", axum::routing::any(ws_handler)) .route_layer(from_fn(validate_subscription_and_update_user_calls)); + let token_queries = Router::new() + .route( + "/v1/tokens/balances/{chain}/{api_key}", + get(aggregate_balances), + ) + .route( + "/v1/tokens/single_token_balances/{chain}/{api_key}", + get(aggregate_single_token_bals), + ) + .route( + "/v1/tokens/many_token_balances/{chain}/{api_key}", + get(aggregate_token_bals_for_user), + ) + .route( + "/v1/nfts/ownership/{chain}/{api_key}", + get(get_batch_nft_info), + ); + let api_keys = Router::new() .route("/api/keys", get(get_all_api_keys).post(generate_api_keys)) .route("/api/keys/{key}", delete(delete_key)) .route_layer(from_fn(verify_jwt)); + let payments = Router::new() .route("/api/pay/eth", post(process_ethereum_payment)) .route("/api/upgrade", post(upgrade)) @@ -76,6 +111,7 @@ async fn main() { .route("/api/balances", get(get_calls_and_balance)) .route("/api/payments", get(get_payments)) .route_layer(from_fn(verify_jwt)); + let siwe = Router::new() .route("/api/refresh", post(refresh)) .route("/api/siwe/add_wallet", post(siwe_add_wallet)) @@ -98,6 +134,7 @@ async fn main() { .merge(siwe) .merge(payments) .layer(cors_api) + .merge(token_queries) .merge(relayer); info!("Initialized D_D RPC on 0.0.0.0:3000"); diff --git a/src/routes/api_keys.rs b/src/routes/api_keys.rs index 41ea8d2..b075c54 100644 --- a/src/routes/api_keys.rs +++ b/src/routes/api_keys.rs @@ -16,7 +16,6 @@ pub struct KeygenLimit { count: Option, } -#[axum::debug_handler] pub async fn generate_api_keys( Extension(jwt): Extension>>, ) -> Result { diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 0c57185..1fd5dc5 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -1,4 +1,5 @@ pub mod activate; +pub mod token_queries; pub mod api_keys; pub mod login; pub mod payment; diff --git a/src/routes/payment.rs b/src/routes/payment.rs index cd97c30..b15f323 100644 --- a/src/routes/payment.rs +++ b/src/routes/payment.rs @@ -380,7 +380,7 @@ pub async fn process_ethereum_payment( let hash = hex::decode(&payload.hash)?; let mut fixed = [0u8; 32]; fixed.copy_from_slice(&hash); - + #[allow(clippy::needless_borrow)] let eth = reqwest::Url::parse(&_endpoint).unwrap(); let provider = ProviderBuilder::new().connect_http(eth); diff --git a/src/routes/relayer/types.rs b/src/routes/relayer/types.rs index e4e32f0..2eb217a 100644 --- a/src/routes/relayer/types.rs +++ b/src/routes/relayer/types.rs @@ -1,6 +1,7 @@ use http::{HeaderValue, header::CONTENT_TYPE}; use axum::body::{Body, Bytes}; use reqwest::Client; +use serde::{Deserialize, Serialize}; use std::{ fmt::{self, Display, Formatter}, future::Future, @@ -28,7 +29,7 @@ impl From for HeaderValue { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub enum PoktChains { #[cfg(any(test, feature = "dev"))] Anvil, diff --git a/src/routes/token_queries.rs b/src/routes/token_queries.rs new file mode 100644 index 0000000..4dd6272 --- /dev/null +++ b/src/routes/token_queries.rs @@ -0,0 +1,254 @@ +use alloy::{ + primitives::{Address, U256, address}, + providers::ProviderBuilder, + sol, +}; +use axum::{ + Json, + extract::{Path, Query}, + response::IntoResponse, +}; +use http::StatusCode; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::routes::{ + relayer::types::PoktChains, + token_queries::TokenQueryContract::{TokenBalanceQuery, TokenQueryContractInstance}, +}; + +static TOKEN_QUERY_UTIL_DEPLOYMENTS: std::sync::LazyLock< + std::collections::HashMap, +> = std::sync::LazyLock::new(|| { + let mut map = std::collections::HashMap::new(); + map.insert( + PoktChains::Eth, + address!("0x96a7B30FD0B97BfF5bEdB343049b378011Cc62fd"), + ); + map.insert( + PoktChains::Base, + address!("0x8B52358d9d2651f9264Df0ceA60333263427b86F"), + ); + map.insert( + PoktChains::Poly, + address!("0x87bd0e6aA53B21A9FB8f465cd90801a479321048"), + ); + map.insert( + PoktChains::ArbOne, + address!("2791Bca1f2de4661ED88A30C99A7a9449Aa84174"), + ); + map +}); + +sol! { + #[sol(rpc)] + contract TokenQueryContract { + #[derive(Debug, Serialize, Deserialize)] + struct TokenBalanceQuery { + address contract_addr; + address user; + } + + #[derive(Debug, Serialize, Deserialize)] + struct TokenBalance { + address contract_addr; + uint256 amount; + address user; + uint8 decimals; + } + + #[derive(Debug, Serialize, Deserialize)] + struct NftInfo { + address owner; + uint256 token_num; + } + + function getBatchNftInfo(address, uint256, uint256) external view returns (NftInfo[] memory); + function aggregateTokenBalsForUser(address[] memory, address) external view returns (TokenBalance[] memory); + function aggregateSingleTokenBals(address[] memory, address) external view returns (TokenBalance[] memory); + function aggregateBalances(TokenBalanceQuery[] memory) external view returns(TokenBalance[] memory); + } +} + +// Many tokens, many users +pub async fn aggregate_balances( + Path((chain, api_key)): Path<(PoktChains, String)>, + Json(payload): Json>, +) -> Result { + if payload.len() > 1000 { + Err(QueryError::ERC20QueryLimit)? + } + + if matches!( + chain, + PoktChains::Op | PoktChains::Bsc | PoktChains::Sui | PoktChains::Solana + ) { + return Err(QueryError::ChainError)?; + } + + let endpoint: String = format!("https://api.cloud.developerdao.com/rpc/{chain}/{api_key}"); + let eth = reqwest::Url::parse(&endpoint)?; + let provider = ProviderBuilder::new().connect_http(eth); + let c_addr = *TOKEN_QUERY_UTIL_DEPLOYMENTS + .get(&chain) + .ok_or_else(|| QueryError::ChainError)?; + + let contract = TokenQueryContractInstance::new(c_addr, provider); + let res = contract.aggregateBalances(payload).call().await?; + + Ok((StatusCode::OK, serde_json::to_string(&res)?).into_response()) +} + +// one token, many users +pub async fn aggregate_token_bals_for_user( + Path((chain, api_key)): Path<(PoktChains, String)>, + Query((address, tokens)): Query<(Address, Vec
)>, +) -> Result { + if tokens.len() > 1000 { + Err(QueryError::ERC20QueryLimit)? + } + + if matches!( + chain, + PoktChains::Op | PoktChains::Bsc | PoktChains::Sui | PoktChains::Solana + ) { + return Err(QueryError::ChainError)?; + } + + let endpoint: String = format!("https://api.cloud.developerdao.com/rpc/{chain}/{api_key}"); + let eth = reqwest::Url::parse(&endpoint)?; + + let provider = ProviderBuilder::new().connect_http(eth); + let contract = TokenQueryContractInstance::new( + address!("0x96a7B30FD0B97BfF5bEdB343049b378011Cc62fd"), + provider, + ); + let res = contract + .aggregateTokenBalsForUser(tokens, address) + .call() + .await?; + + Ok((StatusCode::OK, serde_json::to_string(&res)?).into_response()) +} + +// many users, one token +pub async fn aggregate_single_token_bals( + Path((chain, api_key)): Path<(PoktChains, String)>, + Query((token_address, users)): Query<(Address, Vec
)>, +) -> Result { + if users.len() > 1000 { + Err(QueryError::ERC20QueryLimit)? + } + + if matches!( + chain, + PoktChains::Op | PoktChains::Bsc | PoktChains::Sui | PoktChains::Solana + ) { + return Err(QueryError::ChainError)?; + } + + let endpoint: String = format!("https://api.cloud.developerdao.com/rpc/{chain}/{api_key}"); + let eth = reqwest::Url::parse(&endpoint)?; + let provider = ProviderBuilder::new().connect_http(eth); + let c_addr = *TOKEN_QUERY_UTIL_DEPLOYMENTS + .get(&chain) + .ok_or_else(|| QueryError::ChainError)?; + let contract = TokenQueryContractInstance::new(c_addr, provider); + let res = contract + .aggregateSingleTokenBals(users, token_address) + .call() + .await?; + + Ok((StatusCode::OK, serde_json::to_string(&res)?).into_response()) +} + +pub async fn get_batch_nft_info( + Path((chain, api_key)): Path<(PoktChains, String)>, + Query((collection, offset, limit)): Query<(Address, u16, u16)>, +) -> Result { + if limit > 10000 { + Err(QueryError::NFTQueryLimit)? + } + + if matches!( + chain, + PoktChains::Op | PoktChains::Bsc | PoktChains::Sui | PoktChains::Solana + ) { + return Err(QueryError::ChainError)?; + } + + let endpoint: String = format!("https://api.cloud.developerdao.com/rpc/{chain}/{api_key}"); + let eth = reqwest::Url::parse(&endpoint)?; + let provider = ProviderBuilder::new().connect_http(eth); + let c_addr = *TOKEN_QUERY_UTIL_DEPLOYMENTS + .get(&chain) + .ok_or_else(|| QueryError::ChainError)?; + let contract = TokenQueryContractInstance::new(c_addr, provider); + + let res = contract + .getBatchNftInfo(collection, U256::from(offset), U256::from(limit)) + .call() + .await?; + + Ok((StatusCode::OK, serde_json::to_string(&res)?).into_response()) +} + +// nft token owners + +#[derive(Error, Debug)] +pub enum QueryError { + #[error("The max number of entries you can request for NFT owners is 10,000")] + NFTQueryLimit, + #[error("The max number of entries you can request for ERC20 balances is 1,000")] + ERC20QueryLimit, + #[error(transparent)] + ParseError(#[from] url::ParseError), + #[error(transparent)] + ContractError(#[from] alloy::contract::Error), + #[error(transparent)] + SerdeError(#[from] serde_json::Error), + #[error("This chain is not yet supported for the token query endpoints.")] + ChainError, +} + +impl IntoResponse for QueryError { + fn into_response(self) -> axum::response::Response { + (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()).into_response() + } +} + +#[cfg(test)] +pub mod test { + + use crate::routes::payment::D_D_CLOUD_API_KEY; + + pub use super::*; + #[tokio::test] + async fn basic() { + let endpoint: String = format!( + "https://api.cloud.developerdao.com/rpc/{}/{}", + PoktChains::Eth, + *D_D_CLOUD_API_KEY + ); + let eth = reqwest::Url::parse(&endpoint).unwrap(); + let provider = ProviderBuilder::new().connect_http(eth); + let contract = TokenQueryContractInstance::new( + // mainnet + address!("0x96a7B30FD0B97BfF5bEdB343049b378011Cc62fd"), + provider, + ); + let res = contract + .aggregateBalances(vec![TokenBalanceQuery { + contract_addr: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + .parse() + .unwrap(), + user: "0x940ACd9375b46EC2FA7C0E8aAd9D7241fb01e205" + .parse() + .unwrap(), + }]) + .call() + .await + .unwrap(); + println!("{res:?}"); + } +}