diff --git a/Cargo.lock b/Cargo.lock index ebe8a57a..bd84b534 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1946,8 +1946,8 @@ dependencies = [ "serde_json", "sha2 0.10.8", "slog", - "strum", - "strum_macros", + "strum 0.26.3", + "strum_macros 0.26.4", "thiserror 1.0.69", "tokio", "tracing", @@ -2714,7 +2714,11 @@ name = "sol_rpc_types" version = "0.1.0" dependencies = [ "candid", + "ic-cdk", "serde", + "strum 0.27.0", + "thiserror 2.0.11", + "url", ] [[package]] @@ -3555,7 +3559,16 @@ version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" dependencies = [ - "strum_macros", + "strum_macros 0.26.4", +] + +[[package]] +name = "strum" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1475c515a4f03a8a7129bb5228b81a781a86cb0b3fbbc19e1c556d491a401f" +dependencies = [ + "strum_macros 0.27.0", ] [[package]] @@ -3571,6 +3584,19 @@ dependencies = [ "syn 2.0.98", ] +[[package]] +name = "strum_macros" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9688894b43459159c82bfa5a5fa0435c19cbe3c9b427fa1dd7b1ce0c279b18a7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.98", +] + [[package]] name = "subtle" version = "2.6.1" diff --git a/Cargo.toml b/Cargo.toml index a5790682..312d9345 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,7 +46,10 @@ solana-program = "2.2.0" solana-pubkey = "2.2.0" solana-signature = "2.2.0" solana-transaction = { version = "2.2.0", features = ["bincode"] } +strum = { version = "0.27.0", features = ["derive"] } +thiserror = "2.0.11" tokio = "1.43.0" +url = "2.5" # TODO XC-297: Currently, the solana-* crates have a dependency on wasm-bindgen # when they are built for wasm32-unknown-unknown target. For this reason, we diff --git a/canister/Cargo.toml b/canister/Cargo.toml index 30f07d1b..09a0d2c9 100644 --- a/canister/Cargo.toml +++ b/canister/Cargo.toml @@ -13,9 +13,9 @@ name = "sol_rpc_canister" path = "src/main.rs" [dependencies] -candid = {workspace = true} -ic-cdk = {workspace = true} -sol_rpc_types = {path = "../libs/types"} +candid = { workspace = true } +ic-cdk = { workspace = true } +sol_rpc_types = { path = "../libs/types" } [dev-dependencies] -candid_parser = {workspace = true} \ No newline at end of file +candid_parser = { workspace = true } \ No newline at end of file diff --git a/canister/sol_rpc_canister.did b/canister/sol_rpc_canister.did index fb59958f..28e4ccc3 100644 --- a/canister/sol_rpc_canister.did +++ b/canister/sol_rpc_canister.did @@ -1,6 +1,43 @@ -type DummyRequest = record {input : text}; -type DummyResponse = record {output : text}; - +type Provider = record { + providerId : ProviderId; + cluster : SolanaCluster; + access : RpcAccess; + alias : opt RpcService; +}; +type ProviderId = text; +type SolanaCluster = variant { + Mainnet; + Devnet; + Testnet; +}; +type RpcAccess = variant { + Authenticated : record { + auth : RpcAuth; + publicUrl : opt text; + }; + Unauthenticated : record { + publicUrl : text; + }; +}; +type RpcAuth = variant { + BearerToken : record { url : text }; + UrlParameter : record { urlPattern : text }; +}; +type RpcService = variant { + Provider : ProviderId; + // TODO: Custom : RpcApi; + SolMainnet : SolMainnetService; + SolDevnet : SolDevnetService; +}; +type SolMainnetService = variant { + Alchemy; + Ankr; + PublicNode; +}; +type SolDevnetService = variant { + Alchemy; + Ankr; +}; service : { - greet: (DummyRequest) -> (DummyResponse) query; -} \ No newline at end of file + getProviders : () -> (vec Provider) query; +}; \ No newline at end of file diff --git a/canister/src/constants.rs b/canister/src/constants.rs new file mode 100644 index 00000000..b294e0a7 --- /dev/null +++ b/canister/src/constants.rs @@ -0,0 +1 @@ +pub const API_KEY_REPLACE_STRING: &str = "{API_KEY}"; diff --git a/canister/src/lib.rs b/canister/src/lib.rs index 8b137891..0b8fbf92 100644 --- a/canister/src/lib.rs +++ b/canister/src/lib.rs @@ -1 +1,2 @@ - +pub mod constants; +pub mod providers; diff --git a/canister/src/main.rs b/canister/src/main.rs index 089b4e68..53440411 100644 --- a/canister/src/main.rs +++ b/canister/src/main.rs @@ -1,10 +1,11 @@ -use sol_rpc_types::{DummyRequest, DummyResponse}; +use candid::candid_method; +use ic_cdk::query; +use sol_rpc_canister::providers::PROVIDERS; -#[ic_cdk::query] -fn greet(request: DummyRequest) -> DummyResponse { - DummyResponse { - output: format!("Hello, {}!", request.input), - } +#[query(name = "getProviders")] +#[candid_method(query, rename = "getProviders")] +fn get_providers() -> Vec { + PROVIDERS.with(|providers| providers.clone().into_iter().collect()) } fn main() {} diff --git a/canister/src/providers/mod.rs b/canister/src/providers/mod.rs new file mode 100644 index 00000000..de70de39 --- /dev/null +++ b/canister/src/providers/mod.rs @@ -0,0 +1,77 @@ +#[cfg(test)] +mod tests; + +use sol_rpc_types::{Provider, ProviderId, RpcAccess, RpcAuth}; +use sol_rpc_types::{RpcService, SolDevnetService, SolMainnetService, SolanaCluster}; +use std::collections::HashMap; + +thread_local! { + pub static PROVIDERS: [Provider; 5] = [ + Provider { + provider_id: "alchemy-mainnet".to_string(), + cluster: SolanaCluster::Mainnet, + access: RpcAccess::Authenticated { + auth: RpcAuth::BearerToken { + url: "https://solana-mainnet.g.alchemy.com/v2".to_string(), + }, + public_url: Some("https://solana-mainnet.g.alchemy.com/v2/demo".to_string()), + }, + alias: Some(RpcService::SolMainnet(SolMainnetService::Alchemy)), + }, + Provider { + provider_id: "alchemy-devnet".to_string(), + cluster: SolanaCluster::Devnet, + access: RpcAccess::Authenticated { + auth: RpcAuth::BearerToken { + url: "https://solana-devnet.g.alchemy.com/v2".to_string(), + }, + public_url: Some("https://solana-devnet.g.alchemy.com/v2/demo".to_string()), + }, + alias: Some(RpcService::SolDevnet(SolDevnetService::Alchemy)), + }, + Provider { + provider_id: "ankr-mainnet".to_string(), + cluster: SolanaCluster::Mainnet, + access: RpcAccess::Authenticated { + auth: RpcAuth::UrlParameter { + url_pattern: "https://rpc.ankr.com/solana/{API_KEY}".to_string(), + }, + public_url: None, + }, + alias: Some(RpcService::SolMainnet(SolMainnetService::Ankr)), + }, + Provider { + provider_id: "ankr-devnet".to_string(), + cluster: SolanaCluster::Devnet, + access: RpcAccess::Authenticated { + auth: RpcAuth::UrlParameter { + url_pattern: "https://rpc.ankr.com/solana_devnet/{API_KEY}".to_string(), + }, + public_url: Some("https://rpc.ankr.com/solana_devnet/".to_string()), + }, + alias: Some(RpcService::SolDevnet(SolDevnetService::Ankr)), + }, + Provider { + provider_id: "publicnode-mainnet".to_string(), + cluster: SolanaCluster::Mainnet, + access: RpcAccess::Unauthenticated { + public_url: "https://solana-rpc.publicnode.com".to_string(), + }, + alias: Some(RpcService::SolMainnet(SolMainnetService::PublicNode)), + }, + ]; + + pub static PROVIDER_MAP: HashMap = PROVIDERS.with(|providers| { + providers + .iter() + .map(|provider| (provider.provider_id.clone(), provider.clone())) + .collect() + }); + + pub static SERVICE_PROVIDER_MAP: HashMap = PROVIDERS.with(|providers| { + providers + .iter() + .filter_map(|provider| Some((provider.alias.clone()?, provider.provider_id.clone()))) + .collect() + }); +} diff --git a/canister/src/providers/tests.rs b/canister/src/providers/tests.rs new file mode 100644 index 00000000..c9c295f9 --- /dev/null +++ b/canister/src/providers/tests.rs @@ -0,0 +1,74 @@ +use super::{PROVIDERS, SERVICE_PROVIDER_MAP}; +use crate::constants::API_KEY_REPLACE_STRING; +use sol_rpc_types::{Provider, RpcAccess, RpcAuth}; +use std::collections::{HashMap, HashSet}; + +#[test] +fn test_rpc_provider_url_patterns() { + PROVIDERS.with(|providers| { + for provider in providers { + fn assert_not_url_pattern(url: &str, provider: &Provider) { + assert!( + !url.contains(API_KEY_REPLACE_STRING), + "Unexpected API key in URL for provider: {}", + provider.provider_id + ) + } + fn assert_url_pattern(url: &str, provider: &Provider) { + assert!( + url.contains(API_KEY_REPLACE_STRING), + "Missing API key in URL pattern for provider: {}", + provider.provider_id + ) + } + match &provider.access { + RpcAccess::Authenticated { auth, public_url } => { + match auth { + RpcAuth::BearerToken { url } => assert_not_url_pattern(url, provider), + RpcAuth::UrlParameter { url_pattern } => { + assert_url_pattern(url_pattern, provider) + } + } + if let Some(public_url) = public_url { + assert_not_url_pattern(public_url, provider); + } + } + RpcAccess::Unauthenticated { public_url } => { + assert_not_url_pattern(public_url, provider); + } + } + } + }) +} + +#[test] +fn test_no_duplicate_service_providers() { + SERVICE_PROVIDER_MAP.with(|map| { + assert_eq!( + map.len(), + map.keys().collect::>().len(), + "Duplicate service in mapping" + ); + assert_eq!( + map.len(), + map.values().collect::>().len(), + "Duplicate provider in mapping" + ); + }) +} + +#[test] +fn test_service_provider_coverage() { + SERVICE_PROVIDER_MAP.with(|map| { + let inverse_map: HashMap<_, _> = map.iter().map(|(k, v)| (v, k)).collect(); + PROVIDERS.with(|providers| { + for provider in providers { + assert!( + inverse_map.contains_key(&provider.provider_id), + "Missing service mapping for provider with ID: {}", + provider.provider_id, + ); + } + }) + }) +} diff --git a/integration_tests/src/lib.rs b/integration_tests/src/lib.rs index 3b9fdfd7..5491227c 100644 --- a/integration_tests/src/lib.rs +++ b/integration_tests/src/lib.rs @@ -3,7 +3,7 @@ use candid::utils::ArgumentEncoder; use candid::{decode_args, encode_args, CandidType, Encode, Principal}; use ic_cdk::api::call::RejectionCode; use pocket_ic::management_canister::{CanisterId, CanisterSettings}; -use pocket_ic::{nonblocking::PocketIc, PocketIcBuilder, WasmResult}; +use pocket_ic::{nonblocking::PocketIc, PocketIcBuilder, UserError, WasmResult}; use serde::de::DeserializeOwned; use sol_rpc_client::{Runtime, SolRpcClient}; use std::path::PathBuf; @@ -84,7 +84,7 @@ pub struct PocketIcRuntime<'a> { #[async_trait] impl<'a> Runtime for PocketIcRuntime<'a> { - async fn call( + async fn update_call( &self, id: Principal, method: &str, @@ -95,12 +95,46 @@ impl<'a> Runtime for PocketIcRuntime<'a> { In: ArgumentEncoder + Send + 'static, Out: CandidType + DeserializeOwned + 'static, { - let args_raw = encode_args(args).expect("Failed to encode arguments."); - match self - .env - .update_call(id, self.caller, method, args_raw) - .await - { + PocketIcRuntime::decode_call_result( + self.env + .update_call(id, self.caller, method, PocketIcRuntime::encode_args(args)) + .await, + ) + } + + async fn query_call( + &self, + id: Principal, + method: &str, + args: In, + ) -> Result + where + In: ArgumentEncoder + Send + 'static, + Out: CandidType + DeserializeOwned + 'static, + { + PocketIcRuntime::decode_call_result( + self.env + .query_call(id, self.caller, method, PocketIcRuntime::encode_args(args)) + .await, + ) + } +} + +impl PocketIcRuntime<'_> { + fn encode_args(args: In) -> Vec + where + In: ArgumentEncoder, + { + encode_args(args).expect("Failed to encode arguments.") + } + + fn decode_call_result( + result: Result, + ) -> Result + where + Out: CandidType + DeserializeOwned + 'static, + { + match result { Ok(WasmResult::Reply(bytes)) => decode_args(&bytes).map(|(res,)| res).map_err(|e| { ( RejectionCode::CanisterError, diff --git a/integration_tests/tests/tests.rs b/integration_tests/tests/tests.rs index e092bdf1..4f62d37f 100644 --- a/integration_tests/tests/tests.rs +++ b/integration_tests/tests/tests.rs @@ -1,21 +1,26 @@ use sol_rpc_int_tests::Setup; -use sol_rpc_types::{DummyRequest, DummyResponse}; +use sol_rpc_types::{Provider, RpcAccess, RpcAuth, RpcService, SolMainnetService, SolanaCluster}; #[tokio::test] -async fn should_greet() { +async fn should_get_providers() { let setup = Setup::new().await; let client = setup.client(); + let providers = client.get_providers().await; - let response = client - .greet(DummyRequest { - input: "world".to_string(), - }) - .await; + assert_eq!(providers.len(), 5); assert_eq!( - response, - DummyResponse { - output: "Hello, world!".to_string() + providers[0], + Provider { + provider_id: "alchemy-mainnet".to_string(), + cluster: SolanaCluster::Mainnet, + access: RpcAccess::Authenticated { + auth: RpcAuth::BearerToken { + url: "https://solana-mainnet.g.alchemy.com/v2".to_string(), + }, + public_url: Some("https://solana-mainnet.g.alchemy.com/v2/demo".to_string()), + }, + alias: Some(RpcService::SolMainnet(SolMainnetService::Alchemy)), } ); diff --git a/libs/client/src/lib.rs b/libs/client/src/lib.rs index 9b7c1afb..8213192f 100644 --- a/libs/client/src/lib.rs +++ b/libs/client/src/lib.rs @@ -8,7 +8,6 @@ use candid::utils::ArgumentEncoder; use candid::{CandidType, Principal}; use ic_cdk::api::call::RejectionCode; use serde::de::DeserializeOwned; -use sol_rpc_types::{DummyRequest, DummyResponse}; /// Abstract the canister runtime so that the client code can be reused: /// * in production using `ic_cdk`, @@ -16,8 +15,8 @@ use sol_rpc_types::{DummyRequest, DummyResponse}; /// * in integration tests by implementing this trait for `PocketIc`. #[async_trait] pub trait Runtime { - /// Defines how asynchronous inter-canister calls are made. - async fn call( + /// Defines how asynchronous inter-canister update calls are made. + async fn update_call( &self, id: Principal, method: &str, @@ -27,6 +26,17 @@ pub trait Runtime { where In: ArgumentEncoder + Send + 'static, Out: CandidType + DeserializeOwned + 'static; + + /// Defines how asynchronous inter-canister query calls are made. + async fn query_call( + &self, + id: Principal, + method: &str, + args: In, + ) -> Result + where + In: ArgumentEncoder + Send + 'static, + Out: CandidType + DeserializeOwned + 'static; } /// Client to interact with the SOL RPC canister. @@ -59,10 +69,10 @@ impl SolRpcClient { } } - /// Call `greet` on the SOL RPC canister. - pub async fn greet(&self, request: DummyRequest) -> DummyResponse { + /// Call `getProviders` on the SOL RPC canister. + pub async fn get_providers(&self) -> Vec { self.runtime - .call(self.sol_rpc_canister, "greet", (request,), 10_000) + .query_call(self.sol_rpc_canister, "getProviders", ()) .await .unwrap() } @@ -73,7 +83,7 @@ struct IcRuntime {} #[async_trait] impl Runtime for IcRuntime { - async fn call( + async fn update_call( &self, id: Principal, method: &str, @@ -88,4 +98,19 @@ impl Runtime for IcRuntime { .await .map(|(res,)| res) } + + async fn query_call( + &self, + id: Principal, + method: &str, + args: In, + ) -> Result + where + In: ArgumentEncoder + Send + 'static, + Out: CandidType + DeserializeOwned + 'static, + { + ic_cdk::api::call::call(id, method, args) + .await + .map(|(res,)| res) + } } diff --git a/libs/types/Cargo.toml b/libs/types/Cargo.toml index 3a7369bd..3fa3cb8b 100644 --- a/libs/types/Cargo.toml +++ b/libs/types/Cargo.toml @@ -12,4 +12,8 @@ include = ["src", "Cargo.toml", "CHANGELOG.md", "LICENSE", "README.md"] [dependencies] candid = { workspace = true } +ic-cdk = { workspace = true } serde = { workspace = true } +strum = { workspace = true } +thiserror = { workspace = true } +url = { workspace = true } diff --git a/libs/types/src/lib.rs b/libs/types/src/lib.rs index f8234dc2..9b440a6e 100644 --- a/libs/types/src/lib.rs +++ b/libs/types/src/lib.rs @@ -3,22 +3,9 @@ #![forbid(unsafe_code)] #![forbid(missing_docs)] -#[cfg(test)] -mod tests; +mod rpc_client; -use candid::CandidType; -use serde::{Deserialize, Serialize}; - -/// A dummy request -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, CandidType)] -pub struct DummyRequest { - /// Input - pub input: String, -} - -/// A dummy response -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, CandidType)] -pub struct DummyResponse { - /// Output - pub output: String, -} +pub use rpc_client::{ + HttpHeader, Provider, ProviderId, RpcAccess, RpcAuth, RpcService, SolDevnetService, + SolMainnetService, SolanaCluster, +}; diff --git a/libs/types/src/rpc_client/mod.rs b/libs/types/src/rpc_client/mod.rs new file mode 100644 index 00000000..23df71b7 --- /dev/null +++ b/libs/types/src/rpc_client/mod.rs @@ -0,0 +1,154 @@ +pub use ic_cdk::api::management_canister::http_request::HttpHeader; +use std::fmt::Debug; + +use candid::CandidType; +use serde::{Deserialize, Serialize}; +use strum::VariantArray; + +/// [Solana clusters](https://solana.com/docs/references/clusters). +#[derive(Debug, Clone, PartialEq, Eq, CandidType, Deserialize, Serialize)] +pub enum SolanaCluster { + /// Mainnet: live production environment for deployed applications. + Mainnet, + /// Devnet: Testing with public accessibility for developers experimenting with their applications. + Devnet, + /// Testnet: Stress-testing for network upgrades and validator performance. + Testnet, +} + +/// Service providers to access the [Solana Mainnet](https://solana.com/docs/references/clusters). +#[derive( + Clone, + Copy, + Debug, + PartialEq, + Eq, + Ord, + PartialOrd, + Hash, + Serialize, + Deserialize, + CandidType, + VariantArray, +)] +pub enum SolMainnetService { + /// [Alchemy](https://www.alchemy.com/) Solana Mainnet RPC provider. + Alchemy, + /// [Ankr](https://www.ankr.com/) Solana Mainnet RPC provider. + Ankr, + /// [PublicNode](https://www.publicnode.com/) Solana Mainnet RPC provider. + PublicNode, +} + +impl SolMainnetService { + /// Returns an array containing all [`SolMainnetService`] variants. + pub const fn all() -> &'static [Self] { + SolMainnetService::VARIANTS + } +} + +/// Service providers to access the [Solana Devnet](https://solana.com/docs/references/clusters). +#[derive( + Clone, + Copy, + Debug, + PartialEq, + Eq, + Ord, + PartialOrd, + Hash, + Serialize, + Deserialize, + CandidType, + VariantArray, +)] +pub enum SolDevnetService { + /// [Alchemy](https://www.alchemy.com/) Solana Devnet RPC provider. + Alchemy, + /// [Ankr](https://www.ankr.com/) Solana Devnet RPC provider. + Ankr, +} + +impl SolDevnetService { + /// Returns an array containing all [`SolDevnetService`] variants. + pub const fn all() -> &'static [Self] { + SolDevnetService::VARIANTS + } +} + +/// Defines a type of RPC service, e.g. for the Solana Mainnet or Devnet. +#[derive(Clone, PartialEq, Eq, Ord, PartialOrd, Hash, Serialize, Deserialize, CandidType)] +pub enum RpcService { + /// The RPC service of a specific [`Provider`], identified by its [`ProviderId`]. + Provider(ProviderId), + // TODO: Custom(RpcApi), + /// RPC service for the [Solana Mainnet](https://solana.com/docs/references/clusters). + SolMainnet(SolMainnetService), + /// RPC service for the [Solana Devnet](https://solana.com/docs/references/clusters). + SolDevnet(SolDevnetService), +} + +impl Debug for RpcService { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + RpcService::Provider(provider_id) => write!(f, "Provider({})", provider_id), + // TODO: RpcService::Custom(_) => write!(f, "Custom(..)"), // Redact credentials + RpcService::SolMainnet(service) => write!(f, "{:?}", service), + RpcService::SolDevnet(service) => write!(f, "{:?}", service), + } + } +} + +/// Unique identifier for a [`Provider`] provider. +pub type ProviderId = String; + +/// Defines an RPC provider. +#[derive(Debug, Clone, PartialEq, Eq, CandidType, Deserialize, Serialize)] +pub struct Provider { + /// Unique identifier for this provider. + #[serde(rename = "providerId")] + pub provider_id: ProviderId, + /// The Solana cluster this provider gives access to. + pub cluster: SolanaCluster, + /// The access method for this provider. + pub access: RpcAccess, + /// The service this provider offers. + pub alias: Option, +} + +/// Defines the access method for a [`Provider`]. +#[derive(Debug, Clone, PartialEq, Eq, CandidType, Deserialize, Serialize)] +pub enum RpcAccess { + /// Access to the RPC provider requires authentication. + Authenticated { + /// The authentication method required for RPC access. + auth: RpcAuth, + /// Public URL to use when the API key is not available. + #[serde(rename = "publicUrl")] + public_url: Option, + }, + /// Access to the provider does not require authentication. + Unauthenticated { + /// Public URL to use. + #[serde(rename = "publicUrl")] + public_url: String, + }, +} + +/// Defines the authentication method for access to a [`Provider`]. +#[derive(Debug, Clone, PartialEq, Eq, CandidType, Deserialize, Serialize)] +pub enum RpcAuth { + /// API key will be used in an Authorization header as Bearer token, e.g., + /// `Authorization: Bearer API_KEY` + BearerToken { + /// Request URL for the provider. + url: String, + }, + /// API key will be inserted as a parameter into the request URL. + UrlParameter { + /// Request URL for the provider with the `{API_KEY}` placeholder where the + /// API key should be inserted, e.g. `https://rpc.ankr.com/eth/{API_KEY}`. + #[serde(rename = "urlPattern")] + url_pattern: String, + }, +} diff --git a/libs/types/src/tests.rs b/libs/types/src/tests.rs deleted file mode 100644 index 32f35444..00000000 --- a/libs/types/src/tests.rs +++ /dev/null @@ -1,18 +0,0 @@ -use crate::DummyRequest; - -#[test] -fn should_deser() { - let request = DummyRequest { - input: "Hello".to_string(), - }; - let encoded = candid::encode_one(&request).unwrap(); - let decoded: DummyRequest = candid::decode_one(&encoded).unwrap(); - assert_eq!(request, decoded); - - let response = DummyRequest { - input: "Hello world!".to_string(), - }; - let encoded = candid::encode_one(&response).unwrap(); - let decoded: DummyRequest = candid::decode_one(&encoded).unwrap(); - assert_eq!(response, decoded); -}