diff --git a/README.md b/README.md index 3587b70..98bbd55 100644 --- a/README.md +++ b/README.md @@ -47,9 +47,9 @@ Add the following configuration to your `dfx.json` file (replace the Make sure you have the following installed: - [Rust](https://www.rust-lang.org/learn/get-started) +- [DFINITY SDK](https://sdk.dfinity.org/docs/quickstart/local-quickstart.html) - [Docker](https://www.docker.com/get-started/) (Optional for [reproducible builds](#reproducible-builds)) - [PocketIC](https://github.com/dfinity/pocketic) (Optional for testing) -- [DFINITY SDK](https://sdk.dfinity.org/docs/quickstart/local-quickstart.html) ### Building the code diff --git a/ic-solana-rpc.wasm.gz b/ic-solana-rpc.wasm.gz index d127101..30acf2f 100644 Binary files a/ic-solana-rpc.wasm.gz and b/ic-solana-rpc.wasm.gz differ diff --git a/ic-solana-wallet.wasm.gz b/ic-solana-wallet.wasm.gz index a69571c..a4a610e 100644 Binary files a/ic-solana-wallet.wasm.gz and b/ic-solana-wallet.wasm.gz differ diff --git a/src/ic-solana-rpc/ic-solana-rpc.did b/src/ic-solana-rpc/ic-solana-rpc.did index ded8e4a..a4658b8 100644 --- a/src/ic-solana-rpc/ic-solana-rpc.did +++ b/src/ic-solana-rpc/ic-solana-rpc.did @@ -355,7 +355,7 @@ type RpcProgramAccountsConfig = record { commitment : opt CommitmentLevel; }; type RpcSendTransactionConfig = record { - encoding : opt UiTransactionEncoding; + encoding : opt TransactionBinaryEncoding; preflightCommitment : opt CommitmentLevel; maxRetries : opt nat64; minContextSlot : opt nat64; @@ -383,7 +383,7 @@ type RpcSimulateTransactionAccountsConfig = record { }; type RpcSimulateTransactionConfig = record { replaceRecentBlockhash : bool; - encoding : opt UiTransactionEncoding; + encoding : opt TransactionBinaryEncoding; innerInstructions : bool; accounts : opt RpcSimulateTransactionAccountsConfig; sigVerify : bool; @@ -832,7 +832,13 @@ service : (InitArgs) -> { opt RpcContextConfig, ) -> (Result_34); sol_minimumLedgerSlot : (RpcServices, opt RpcConfig) -> (Result_2); - sol_requestAirdrop : (RpcServices, opt RpcConfig, text, nat64) -> (Result); + sol_requestAirdrop : ( + RpcServices, + opt RpcConfig, + text, + nat64, + opt CommitmentConfig, + ) -> (Result); sol_sendTransaction : ( RpcServices, opt RpcConfig, diff --git a/src/ic-solana-rpc/src/constants.rs b/src/ic-solana-rpc/src/constants.rs index 1e922d8..086f7e8 100644 --- a/src/ic-solana-rpc/src/constants.rs +++ b/src/ic-solana-rpc/src/constants.rs @@ -1,28 +1,30 @@ -// HTTP outcall cost calculation -// See https://internetcomputer.org/docs/current/developer-docs/gas-cost#special-features +/// HTTP outcall cost calculation +/// See https://internetcomputer.org/docs/current/developer-docs/gas-cost#cycles-price-breakdown +/// and https://github.com/dfinity/ic/blob/b9c732eace54b47292969e77801e22317ae182a2/rs/config/src/subnet_config.rs#L442 pub const INGRESS_OVERHEAD_BYTES: u128 = 100; -pub const INGRESS_MESSAGE_RECEIVED_COST: u128 = 1_200_000; -pub const INGRESS_MESSAGE_BYTE_RECEIVED_COST: u128 = 2_000; -pub const HTTP_OUTCALL_REQUEST_BASE_COST: u128 = 3_000_000; -pub const HTTP_OUTCALL_REQUEST_PER_NODE_COST: u128 = 60_000; -pub const HTTP_OUTCALL_REQUEST_COST_PER_BYTE: u128 = 400; -pub const HTTP_OUTCALL_RESPONSE_COST_PER_BYTE: u128 = 800; +pub const INGRESS_MESSAGE_RECEPTION_FEE: u128 = 1_200_000; +pub const INGRESS_BYTE_RECEPTION_FEE: u128 = 2_000; +pub const HTTP_REQUEST_LINEAR_BASELINE_FEE: u128 = 3_000_000; +pub const HTTP_REQUEST_QUADRATIC_BASELINE_FEE: u128 = 60_000; +pub const HTTP_REQUEST_PER_BYTE_FEE: u128 = 400; +pub const HTTP_RESPONSE_PER_BYTE_FEE: u128 = 800; -// Additional cost of operating the canister per subnet node +/// Additional cost of operating the canister per subnet node pub const CANISTER_OVERHEAD: u128 = 1_000_000; -// Cycles which must be passed with each RPC request in case the -// third-party JSON-RPC prices increase in the future (currently always refunded) +/// Cycles which must be passed with each RPC request in case the +/// third-party JSON-RPC prices increase in the future (currently always refunded) pub const COLLATERAL_CYCLES_PER_NODE: u128 = 10_000_000; -// Minimum number of bytes charged for a URL; improves consistency of costs between providers +/// Minimum number of bytes charged for a URL; improves consistency of costs between providers pub const RPC_URL_COST_BYTES: u32 = 256; -// pub const MINIMUM_WITHDRAWAL_CYCLES: u128 = 1_000_000_000; +/// Default subnet size which is used to scale cycles cost according to a subnet replication factor. +pub const DEFAULT_SUBNET_SIZE: u32 = 13; pub const NODES_IN_SUBNET: u32 = 34; pub const PROVIDER_ID_MAX_SIZE: u32 = 128; -// List of hosts which are not allowed to be used as RPC providers +/// List of hosts which are not allowed to be used as RPC providers pub const RPC_HOSTS_BLOCKLIST: &[&str] = &[]; diff --git a/src/ic-solana-rpc/src/http.rs b/src/ic-solana-rpc/src/http.rs index 8c76e9b..67482bd 100644 --- a/src/ic-solana-rpc/src/http.rs +++ b/src/ic-solana-rpc/src/http.rs @@ -1,5 +1,4 @@ use ic_canisters_http_types::{HttpRequest, HttpResponse, HttpResponseBuilder}; -use ic_cdk::api::management_canister::http_request::TransformContext; use ic_solana::{ constants::HTTP_MAX_SIZE, logs::{Log, Priority, Sort}, @@ -9,10 +8,9 @@ use ic_solana::{ use crate::{ constants::{ - CANISTER_OVERHEAD, COLLATERAL_CYCLES_PER_NODE, HTTP_OUTCALL_REQUEST_BASE_COST, - HTTP_OUTCALL_REQUEST_COST_PER_BYTE, HTTP_OUTCALL_REQUEST_PER_NODE_COST, HTTP_OUTCALL_RESPONSE_COST_PER_BYTE, - INGRESS_MESSAGE_BYTE_RECEIVED_COST, INGRESS_MESSAGE_RECEIVED_COST, INGRESS_OVERHEAD_BYTES, NODES_IN_SUBNET, - RPC_URL_COST_BYTES, + COLLATERAL_CYCLES_PER_NODE, DEFAULT_SUBNET_SIZE, HTTP_REQUEST_LINEAR_BASELINE_FEE, HTTP_REQUEST_PER_BYTE_FEE, + HTTP_REQUEST_QUADRATIC_BASELINE_FEE, HTTP_RESPONSE_PER_BYTE_FEE, INGRESS_BYTE_RECEPTION_FEE, + INGRESS_MESSAGE_RECEPTION_FEE, INGRESS_OVERHEAD_BYTES, NODES_IN_SUBNET, RPC_URL_COST_BYTES, }, providers::find_provider, state::read_state, @@ -30,7 +28,7 @@ pub fn rpc_client(source: RpcServices, config: Option) -> RpcClient { RpcServices::Localnet => Cluster::Localnet, _ => unreachable!(), }; - vec![get_provider_rpc_api(&cluster.to_string())] + vec![get_provider_rpc_api(cluster.as_ref())] } RpcServices::Provider(ids) => ids.iter().map(|id| get_provider_rpc_api(id)).collect(), RpcServices::Custom(apis) => apis, // Use the custom APIs directly @@ -50,7 +48,7 @@ pub fn rpc_client(source: RpcServices, config: Option) -> RpcClient { (cycles_cost, get_cost_with_collateral(cycles_cost)) }), host_validator: Some(|host| validate_hostname(host).is_ok()), - transform_context: Some(TransformContext::from_name("__transform_json_rpc".to_owned(), vec![])), + transform_function_name: Some("__transform_json_rpc".to_owned()), is_demo_active: s.is_demo_active, use_compression: false, }; @@ -65,18 +63,22 @@ fn get_provider_rpc_api(provider_id: &str) -> RpcApi { .api() } -/// Calculates the cost of sending a JSON-RPC request using HTTP outcalls. +/// Calculates the baseline cost of sending a request using HTTP outcalls. +/// The corresponding code in replica: +/// https://github.com/dfinity/ic/blob/master/rs/cycles_account_manager/src/lib.rs#L1153 pub fn get_http_request_cost(payload_size_bytes: u64, max_response_bytes: u64) -> u128 { - let nodes_in_subnet = NODES_IN_SUBNET as u128; - let ingress_bytes = payload_size_bytes as u128 + RPC_URL_COST_BYTES as u128 + INGRESS_OVERHEAD_BYTES; - let cost_per_node = INGRESS_MESSAGE_RECEIVED_COST - + INGRESS_MESSAGE_BYTE_RECEIVED_COST * ingress_bytes - + HTTP_OUTCALL_REQUEST_BASE_COST - + HTTP_OUTCALL_REQUEST_PER_NODE_COST * nodes_in_subnet - + HTTP_OUTCALL_REQUEST_COST_PER_BYTE * payload_size_bytes as u128 - + HTTP_OUTCALL_RESPONSE_COST_PER_BYTE * max_response_bytes as u128 - + CANISTER_OVERHEAD; - cost_per_node * nodes_in_subnet + let subnet_size = NODES_IN_SUBNET as u128; + let request_size = payload_size_bytes as u128; + let response_size = max_response_bytes as u128; + let ingress_size = request_size + RPC_URL_COST_BYTES as u128 + INGRESS_OVERHEAD_BYTES; + + (INGRESS_MESSAGE_RECEPTION_FEE / DEFAULT_SUBNET_SIZE as u128 + + INGRESS_BYTE_RECEPTION_FEE / DEFAULT_SUBNET_SIZE as u128 * ingress_size + + HTTP_REQUEST_LINEAR_BASELINE_FEE + + HTTP_REQUEST_QUADRATIC_BASELINE_FEE * subnet_size + + HTTP_REQUEST_PER_BYTE_FEE * request_size + + HTTP_RESPONSE_PER_BYTE_FEE * response_size) + * subnet_size } /// Calculate the cost + collateral cycles for an HTTP request. @@ -162,3 +164,32 @@ pub fn serve_logs(request: HttpRequest) -> HttpResponse { .with_body_and_content_length(log.serialize_logs(MAX_BODY_SIZE)) .build() } + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_request_cost() { + let payload = r#"{"jsonrpc":"2.0","method":"sol_getHealth","params":[],"id":1}"#; + let base_cost = get_http_request_cost(payload.len() as u64, 1000); + let base_cost_10_extra_bytes = get_http_request_cost(payload.len() as u64 + 10, 1000); + let estimated_cost_10_extra_bytes = base_cost + + 10 * (HTTP_REQUEST_PER_BYTE_FEE + INGRESS_BYTE_RECEPTION_FEE / DEFAULT_SUBNET_SIZE as u128) + * NODES_IN_SUBNET as u128; + assert_eq!(base_cost_10_extra_bytes, estimated_cost_10_extra_bytes); + } + + #[test] + fn test_candid_rpc_cost() { + assert_eq!( + [ + get_http_request_cost(0, 0), + get_http_request_cost(123, 123), + get_http_request_cost(123, 4567890), + get_http_request_cost(890, 4567890), + ], + [176350350, 182008596, 124425270996, 124439692130] + ); + } +} diff --git a/src/ic-solana-rpc/src/main.rs b/src/ic-solana-rpc/src/main.rs index 4ba5a6c..3f22ca9 100644 --- a/src/ic-solana-rpc/src/main.rs +++ b/src/ic-solana-rpc/src/main.rs @@ -37,7 +37,7 @@ use ic_solana_rpc::{ providers::{do_register_provider, do_unregister_provider, do_update_provider}, state::{read_state, replace_state, InitArgs}, types::{RegisterProviderArgs, UpdateProviderArgs}, - utils::{parse_pubkey, parse_pubkeys, parse_signature, parse_signatures}, + utils::{parse_pubkey, parse_pubkeys, parse_signature, parse_signatures, transform_http_request}, }; /// Returns all information associated with the account of the provided Pubkey. @@ -657,10 +657,11 @@ pub async fn sol_request_airdrop( config: Option, pubkey: String, lamports: u64, + params: Option, ) -> RpcResult { let client = rpc_client(source, config); let pubkey = parse_pubkey(&pubkey)?; - client.request_airdrop(&pubkey, lamports).await + client.request_airdrop(&pubkey, lamports, params).await } /// Submits a signed transaction to the cluster for processing. @@ -832,12 +833,8 @@ fn get_metrics() -> Metrics { /// /// * `args` - Transformation arguments containing the HTTP response. #[query(hidden = true)] -fn __transform_json_rpc(mut args: TransformArgs) -> HttpResponse { - // The response header contains non-deterministic fields that make it impossible to reach - // consensus! Errors seem deterministic and do not contain data that can break consensus. - // Clear non-deterministic fields from the response headers. - args.response.headers.clear(); - args.response +fn __transform_json_rpc(args: TransformArgs) -> HttpResponse { + transform_http_request(args) } #[ic_cdk::init] diff --git a/src/ic-solana-rpc/src/memory.rs b/src/ic-solana-rpc/src/memory.rs index 075a749..36a36a8 100644 --- a/src/ic-solana-rpc/src/memory.rs +++ b/src/ic-solana-rpc/src/memory.rs @@ -11,8 +11,8 @@ use crate::{ types::PrincipalStorable, }; -const AUTH_MEMORY_ID: MemoryId = MemoryId::new(2); -const PROVIDERS_MEMORY_ID: MemoryId = MemoryId::new(3); +pub const AUTH_MEMORY_ID: MemoryId = MemoryId::new(2); +pub const PROVIDERS_MEMORY_ID: MemoryId = MemoryId::new(3); pub type StableMemory = VirtualMemory; pub type AuthMemory = StableBTreeMap; diff --git a/src/ic-solana-rpc/src/providers.rs b/src/ic-solana-rpc/src/providers.rs index c0af452..5ec018b 100644 --- a/src/ic-solana-rpc/src/providers.rs +++ b/src/ic-solana-rpc/src/providers.rs @@ -133,49 +133,48 @@ pub fn do_register_provider(caller: Principal, args: RegisterProviderArgs) { }); } -/// Unregister provider. The caller must be the owner or administrator. +/// Unregister provider. +/// The caller must be the owner or administrator. pub fn do_unregister_provider(caller: Principal, provider_id: &str) -> bool { + let is_admin = is_controller(&caller); let is_manager = is_authorized(&caller, Auth::Manage); mutate_state(|s| { let id = ProviderId::new(provider_id); if let Some(provider) = s.rpc_providers.get(&id) { - if provider.owner == caller || is_controller(&caller) || is_manager { - log!(INFO, "[{}] Unregistering provider: {:?}", caller, provider_id); - s.rpc_providers.remove(&id).is_some() - } else { + if !(provider.owner == caller || is_admin || is_manager) { ic_cdk::trap("Unauthorized"); } + log!(INFO, "[{}] Unregistering provider: {:?}", caller, provider_id); + s.rpc_providers.remove(&id).is_some() } else { false } }) } -/// Change provider details. The caller must be the owner or administrator. +/// Change provider details. +/// The caller must be the owner or administrator. pub fn do_update_provider(caller: Principal, args: UpdateProviderArgs) { - let provider_id = ProviderId::new(args.id); + let is_admin = is_controller(&caller); let is_manager = is_authorized(&caller, Auth::Manage); + let provider_id = ProviderId::new(&args.id); mutate_state(|s| match s.rpc_providers.get(&provider_id) { Some(mut provider) => { - if provider.owner == caller { - if args.url.is_some() { - ic_cdk::trap("You are not authorized to update the `url` field"); - } - if let Some(auth) = args.auth { - provider.auth = Some(auth); - } - s.rpc_providers.insert(provider_id, provider); - } else if is_controller(&caller) || is_manager { - if let Some(url) = args.url { + if !(provider.owner == caller || is_admin || is_manager) { + ic_cdk::trap("Unauthorized"); + } + log!(INFO, "[{}] Updating provider: {:?}", caller, args); + if let Some(url) = args.url { + if is_admin { provider.url = url; + } else { + ic_cdk::trap("You are not authorized to update the `url` field"); } - if let Some(auth) = args.auth { - provider.auth = Some(auth); - } - s.rpc_providers.insert(provider_id, provider); - } else { - ic_cdk::trap("Unauthorized"); } + if let Some(auth) = args.auth { + provider.auth = Some(auth); + } + s.rpc_providers.insert(provider_id, provider) } None => ic_cdk::trap("Provider not found"), }); diff --git a/src/ic-solana-rpc/src/types.rs b/src/ic-solana-rpc/src/types.rs index dfddfe6..bce9cf9 100644 --- a/src/ic-solana-rpc/src/types.rs +++ b/src/ic-solana-rpc/src/types.rs @@ -46,7 +46,7 @@ pub struct RegisterProviderArgs { pub auth: Option, } -#[derive(Clone, CandidType, Deserialize)] +#[derive(Clone, Debug, CandidType, Deserialize)] pub struct UpdateProviderArgs { /// The id of the provider to update pub id: String, diff --git a/src/ic-solana-rpc/src/utils.rs b/src/ic-solana-rpc/src/utils.rs index b3fa554..a7eaee7 100644 --- a/src/ic-solana-rpc/src/utils.rs +++ b/src/ic-solana-rpc/src/utils.rs @@ -1,6 +1,8 @@ use std::str::FromStr; +use ic_cdk::api::management_canister::http_request::{HttpResponse, TransformArgs}; use ic_solana::{ + request::RpcRequest, rpc_client::{RpcError, RpcResult}, types::{Pubkey, Signature}, }; @@ -45,6 +47,34 @@ pub fn parse_signatures(signatures: Vec) -> RpcResult> { signatures.iter().map(|s| parse_signature(s)).collect() } +pub fn transform_http_request(mut args: TransformArgs) -> HttpResponse { + // Remove headers (which may contain a timestamp) for consensus + args.response.headers.clear(); + + let response = serde_json::from_slice::(&args.response.body).ok(); + if response.is_none() { + return args.response; + } + + let mut response = response.unwrap(); + + let request = serde_json::from_slice::(&args.context).unwrap_or_default(); + + if request["method"] == RpcRequest::GetEpochInfo.to_string() { + response["result"]["absoluteSlot"] = serde_json::Value::Number(0.into()); + } + + args.response.body = serde_json::to_vec(&response).unwrap_or(args.response.body); + args.response + + // HttpResponse { + // status: args.response.status, + // body: canonicalize_json(&args.response.body).unwrap_or(args.response.body), + // // Remove headers (which may contain a timestamp) for consensus + // headers: vec![], + // } +} + #[cfg(test)] mod test { use super::*; diff --git a/src/ic-solana-rpc/tests/setup.rs b/src/ic-solana-rpc/tests/setup.rs index 5608828..19fb885 100644 --- a/src/ic-solana-rpc/tests/setup.rs +++ b/src/ic-solana-rpc/tests/setup.rs @@ -1,3 +1,5 @@ +use std::cell::RefCell; + use candid::{utils::ArgumentEncoder, CandidType, Decode, Encode, Principal}; use ic_canisters_http_types::{HttpRequest, HttpResponse}; use ic_solana::{ @@ -7,9 +9,14 @@ use ic_solana::{ }; use ic_solana_rpc::{ auth::Auth, + memory::{ProvidersMemory, StableMemory, PROVIDERS_MEMORY_ID}, state::InitArgs, types::{RegisterProviderArgs, UpdateProviderArgs}, }; +use ic_stable_structures::{ + memory_manager::{MemoryId, MemoryManager}, + DefaultMemoryImpl, +}; use ic_test_utilities_load_wasm::load_wasm; use serde::de::DeserializeOwned; use test_utils::{utils::assert_reply, CallFlow, TestSetup}; @@ -85,6 +92,12 @@ impl SolanaRpcSetup { self.call_query("getMetrics", ()) } + pub fn get_stable_memory(&self, id: MemoryId) -> StableMemory { + let bytes = self.setup.env.get_stable_memory(self.setup.canister_id); + let memory_manager = MemoryManager::init(DefaultMemoryImpl::from(RefCell::new(bytes))); + memory_manager.get(id) + } + pub fn http_get_logs(&self, priority: &str) -> Vec { let request = HttpRequest { method: "".to_string(), @@ -127,6 +140,10 @@ impl SolanaRpcSetup { .call_update("request", (source, method, params, max_response_bytes)) } + pub fn get_providers_memory(&self) -> ProvidersMemory { + ProvidersMemory::init(self.get_stable_memory(PROVIDERS_MEMORY_ID)) + } + pub fn get_providers(&self) -> Vec { self.setup.call_query("getProviders", ()) } diff --git a/src/ic-solana-rpc/tests/tests.rs b/src/ic-solana-rpc/tests/tests.rs index dd120cb..253486a 100644 --- a/src/ic-solana-rpc/tests/tests.rs +++ b/src/ic-solana-rpc/tests/tests.rs @@ -16,10 +16,16 @@ use ic_solana::{ RpcInflationGovernor, RpcInflationRate, RpcInflationReward, RpcLargestAccountsConfig, RpcLargestAccountsFilter, RpcLeaderSchedule, RpcPerfSample, RpcPrioritizationFee, RpcSignatureStatusConfig, RpcSimulateTransactionConfig, RpcSnapshotSlotInfo, RpcSupply, RpcTokenAccountsFilter, RpcVersionInfo, RpcVoteAccountStatus, - TransactionDetails, TransactionStatus, UiDataSliceConfig, UiTokenAmount, UiTransactionEncoding, + TransactionBinaryEncoding, TransactionDetails, TransactionStatus, UiDataSliceConfig, UiTokenAmount, }, }; -use ic_solana_rpc::{auth::Auth, state::InitArgs, types::RegisterProviderArgs}; +use ic_solana_rpc::{ + auth::Auth, + http::get_http_request_cost, + providers::ProviderId, + state::InitArgs, + types::{RegisterProviderArgs, UpdateProviderArgs}, +}; use test_utils::{MockOutcallBuilder, TestSetup}; use crate::setup::{mock_update, SolanaRpcSetup, MOCK_RAW_TX}; @@ -641,7 +647,7 @@ fn test_request_airdrop() { RpcServices::Mainnet, (), "83astBRguLMdt2h5U1Tpdq5tjFoJ6noeGwaY3mDLVcri", - 1000000000u64, + 1000000000u64 ), r#"{"jsonrpc":"2.0","result":"5VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUW","id":1}"#, ) @@ -675,7 +681,7 @@ fn test_simulate_transaction() { (), MOCK_RAW_TX, RpcSimulateTransactionConfig { - encoding: Some(UiTransactionEncoding::Base64), + encoding: Some(TransactionBinaryEncoding::Base64), ..Default::default() } ), @@ -712,13 +718,23 @@ fn test_get_logs() { #[test] fn should_get_valid_request_cost() { + let payload = r#"{"jsonrpc":"2.0","method":"sol_getHealth","params":[],"id":1}"#; + let max_response_size = 128u64; + assert_eq!( + SolanaRpcSetup::new(InitArgs { + demo: Some(true), + ..Default::default() + }) + .call_query::<_, u128>("requestCost", (payload, max_response_size)), + 0 + ); assert_eq!( SolanaRpcSetup::new(InitArgs { demo: None, ..Default::default() }) - .call_query::<_, u128>("requestCost", (MOCK_RAW_TX, 1000u64)), - 321476800 + .call_query::<_, u128>("requestCost", (payload, max_response_size)), + get_http_request_cost(payload.len() as u64, max_response_size) ); } @@ -784,6 +800,69 @@ fn should_allow_manager_to_register_and_unregister_providers() { assert!(!providers.contains(&provider_id)); } +#[test] +fn should_allow_controller_to_update_provider_url() { + let setup = SolanaRpcSetup::default(); + let provider_id = "test_mainnet1".to_string(); + + setup + .clone() + .as_controller() + .register_provider(RegisterProviderArgs { + id: provider_id.clone(), + url: Cluster::Mainnet.url().to_string(), + auth: None, + }) + .wait(); + let providers = setup.get_providers(); + assert!(providers.contains(&provider_id)); + + setup + .clone() + .as_controller() + .update_provider(UpdateProviderArgs { + id: provider_id.clone(), + url: Some(Cluster::Devnet.url().to_string()), + auth: None, + }) + .wait(); + + let provider = setup.get_providers_memory().get(&ProviderId::new(provider_id)).unwrap(); + assert_eq!(provider.url, Cluster::Devnet.url().to_string()); +} + +#[test] +#[should_panic(expected = "You are not authorized to update the `url` field")] +fn should_not_allow_manager_to_update_provider_url() { + let setup = SolanaRpcSetup::default(); + let provider_id = "test_mainnet1".to_string(); + let manager = TestSetup::principal(3); + + setup.clone().as_controller().authorize(manager, Auth::Manage).wait(); + + setup + .clone() + .as_caller(manager) + .register_provider(RegisterProviderArgs { + id: provider_id.clone(), + url: Cluster::Mainnet.url().into(), + auth: None, + }) + .wait(); + let providers = setup.get_providers(); + assert!(providers.contains(&provider_id)); + + setup + .clone() + .as_caller(manager) + .update_provider(UpdateProviderArgs { + id: provider_id.clone(), + url: Some(Cluster::Devnet.url().into()), + auth: None, + }) + .wait(); +} + #[test] fn should_allow_caller_with_access_register_provider() { let setup = SolanaRpcSetup::default(); diff --git a/src/ic-solana-wallet/src/main.rs b/src/ic-solana-wallet/src/main.rs index c0027e7..aeaeb8d 100644 --- a/src/ic-solana-wallet/src/main.rs +++ b/src/ic-solana-wallet/src/main.rs @@ -4,12 +4,12 @@ use candid::candid_method; use ic_cdk::update; use ic_solana::{ rpc_client::{RpcConfig, RpcResult, RpcServices}, - types::{BlockHash, Pubkey, RpcSendTransactionConfig, Transaction}, + types::{BlockHash, RpcSendTransactionConfig, Transaction}, }; use ic_solana_wallet::{ - eddsa::{eddsa_public_key, sign_with_eddsa}, + eddsa::sign_with_eddsa, state::{read_state, InitArgs, State}, - utils::validate_caller_not_anonymous, + utils::{caller_pubkey, caller_sign, validate_caller_not_anonymous}, }; use serde_bytes::ByteBuf; @@ -22,10 +22,7 @@ use serde_bytes::ByteBuf; #[candid_method] pub async fn address() -> String { let caller = validate_caller_not_anonymous(); - let key_name = read_state(|s| s.schnorr_key.to_owned()); - let derived_path = vec![ByteBuf::from(caller.as_slice())]; - let pk = eddsa_public_key(key_name, derived_path).await; - Pubkey::try_from(pk.as_slice()).expect("Invalid public key").to_string() + caller_pubkey(caller).await.to_string() } /// Signs a provided message using the caller's Eddsa key. @@ -42,9 +39,22 @@ pub async fn address() -> String { #[candid_method(query, rename = "signMessage")] pub async fn sign_message(message: String) -> Vec { let caller = validate_caller_not_anonymous(); - let key_name = read_state(|s| s.schnorr_key.to_owned()); - let derived_path = vec![ByteBuf::from(caller.as_slice())]; - sign_with_eddsa(key_name, derived_path, message.as_bytes().into()).await + caller_sign(caller, message.as_bytes()).await +} + +/// Returns the current balance of the Solana wallet associated with the caller. +#[update] +#[candid_method] +pub async fn balance(source: RpcServices, config: Option) -> RpcResult { + let caller = validate_caller_not_anonymous(); + let sol_canister = read_state(|s| s.sol_canister); + let pubkey = caller_pubkey(caller).await; + + let (response,) = + ic_cdk::call::<_, (RpcResult,)>(sol_canister, "sol_getBalance", (&source, config, pubkey.to_string())) + .await?; + + response } /// Signs and sends a transaction to the Solana network. @@ -52,8 +62,9 @@ pub async fn sign_message(message: String) -> Vec { /// # Parameters /// /// - `provider` (`String`): The Solana RPC provider ID. +/// - `config` (`Option`): The serialized unsigned transaction. /// - `raw_transaction` (`String`): The serialized unsigned transaction. -/// - `config` (`Option`): Optional configuration for sending the +/// - `params` (`Option`): Optional configuration for sending the /// transaction. /// /// # Returns diff --git a/src/ic-solana-wallet/src/utils.rs b/src/ic-solana-wallet/src/utils.rs index 9d50caa..70bb4de 100644 --- a/src/ic-solana-wallet/src/utils.rs +++ b/src/ic-solana-wallet/src/utils.rs @@ -1,4 +1,11 @@ use candid::Principal; +use ic_solana::types::Pubkey; +use serde_bytes::ByteBuf; + +use crate::{ + eddsa::{eddsa_public_key, sign_with_eddsa}, + state::read_state, +}; pub fn validate_caller_not_anonymous() -> Principal { let caller = ic_cdk::caller(); @@ -7,3 +14,16 @@ pub fn validate_caller_not_anonymous() -> Principal { } caller } + +pub async fn caller_pubkey(caller: Principal) -> Pubkey { + let key_name = read_state(|s| s.schnorr_key.to_owned()); + let derived_path = vec![ByteBuf::from(caller.as_slice())]; + let pk = eddsa_public_key(key_name, derived_path).await; + Pubkey::try_from(pk.as_slice()).expect("Invalid public key") +} + +pub async fn caller_sign(caller: Principal, message: &[u8]) -> Vec { + let key_name = read_state(|s| s.schnorr_key.to_owned()); + let derived_path = vec![ByteBuf::from(caller.as_slice())]; + sign_with_eddsa(key_name, derived_path, message.into()).await +} diff --git a/src/ic-solana/src/rpc_client.rs b/src/ic-solana/src/rpc_client.rs index 4cfd9c8..021a3ea 100644 --- a/src/ic-solana/src/rpc_client.rs +++ b/src/ic-solana/src/rpc_client.rs @@ -24,8 +24,7 @@ use crate::{ RpcGetVoteAccountsConfig, RpcLargestAccountsConfig, RpcLeaderScheduleConfig, RpcProgramAccountsConfig, RpcSendTransactionConfig, RpcSignatureStatusConfig, RpcSignaturesForAddressConfig, RpcSimulateTransactionConfig, RpcSupplyConfig, RpcTokenAccountsFilter, RpcTransactionConfig, Signature, Slot, - Transaction, TransactionStatus, UiAccount, UiConfirmedBlock, UiTokenAmount, UiTransactionEncoding, - UnixTimestamp, + Transaction, TransactionStatus, UiAccount, UiConfirmedBlock, UiTokenAmount, UnixTimestamp, }, }; @@ -48,6 +47,7 @@ use crate::{ RpcVersionInfo, RpcVoteAccountStatus, }, tagged::RpcTokenAccountBalance, + TransactionBinaryEncoding, }, }; @@ -61,7 +61,7 @@ pub struct RpcClientConfig { pub response_size_estimate: Option, pub request_cost_calculator: Option, pub host_validator: Option, - pub transform_context: Option, + pub transform_function_name: Option, pub use_compression: bool, pub is_demo_active: bool, } @@ -145,14 +145,19 @@ impl RpcClient { } let body = serde_json::to_vec(payload).map_err(|e| RpcError::ParseError(e.to_string()))?; + let transform = self + .config + .transform_function_name + .as_ref() + .map(|name| TransformContext::from_name(name.into(), body.clone())); let request = CanisterHttpRequestArgument { - url: url.to_string(), max_response_bytes, - method: HttpMethod::POST, headers, + transform, + url: url.to_string(), + method: HttpMethod::POST, body: Some(body), - transform: self.config.transform_context.clone(), }; // Calculate cycles if a calculator is provided @@ -334,6 +339,10 @@ impl RpcClient { /// Method relies on the `getBlock` RPC call to get the block: /// https://solana.com/docs/rpc/http/getBlock pub async fn get_block(&self, slot: Slot, config: Option) -> RpcResult { + if let Some(commitment) = config.as_ref().and_then(|c| c.commitment) { + ensure_at_least_confirmed(commitment.into())?; + } + self.call( RpcRequest::GetBlock, (slot, config.unwrap_or_default()), @@ -410,6 +419,10 @@ impl RpcClient { json!([start_slot, commitment_config]) }; + if let Some(commitment_config) = commitment_config { + ensure_at_least_confirmed(commitment_config)?; + } + let end_slot = end_slot.unwrap_or(start_slot + MAX_GET_BLOCKS_RANGE); let limit = end_slot.saturating_sub(start_slot); @@ -446,6 +459,10 @@ impl RpcClient { ))); } + if let Some(commitment_config) = commitment_config { + ensure_at_least_confirmed(commitment_config)?; + } + self.call( RpcRequest::GetBlocksWithLimit, (start_slot, limit, commitment_config), @@ -918,10 +935,19 @@ impl RpcClient { /// /// Method relies on the `requestAirdrop` RPC call to request the airdrop: /// https://solana.com/docs/rpc/http/requestAirdrop - pub async fn request_airdrop(&self, pubkey: &Pubkey, lamports: u64) -> RpcResult { - self.call(RpcRequest::RequestAirdrop, (pubkey.to_string(), lamports), Some(156)) - .await? - .into() + pub async fn request_airdrop( + &self, + pubkey: &Pubkey, + lamports: u64, + commitment_config: Option, + ) -> RpcResult { + self.call( + RpcRequest::RequestAirdrop, + (pubkey.to_string(), lamports, commitment_config), + Some(156), + ) + .await? + .into() } /// Returns signatures for confirmed transactions that include the given address in their @@ -983,6 +1009,10 @@ impl RpcClient { signature: &Signature, config: Option, ) -> RpcResult> { + if let Some(commitment) = config.as_ref().and_then(|c| c.commitment) { + ensure_at_least_confirmed(commitment.into())?; + } + self.call( RpcRequest::GetTransaction, (signature.to_string(), config.unwrap_or_default()), @@ -1060,13 +1090,8 @@ impl RpcClient { let serialized = tx.serialize(); let raw_tx = match config.encoding { - None | Some(UiTransactionEncoding::Base58) => bs58::encode(serialized).into_string(), - Some(UiTransactionEncoding::Base64) => BASE64_STANDARD.encode(serialized), - Some(e) => { - return Err(RpcError::Text(format!( - "Unsupported encoding: {e}. Supported encodings: base58, base64" - ))); - } + Some(TransactionBinaryEncoding::Base58) => bs58::encode(serialized).into_string(), + Some(TransactionBinaryEncoding::Base64) | None => BASE64_STANDARD.encode(serialized), }; let response: RpcResult = self @@ -1090,13 +1115,8 @@ impl RpcClient { let serialized = tx.serialize(); let raw_tx = match config.encoding { - None | Some(UiTransactionEncoding::Base58) => bs58::encode(serialized).into_string(), - Some(UiTransactionEncoding::Base64) => BASE64_STANDARD.encode(serialized), - Some(e) => { - return Err(RpcError::Text(format!( - "Unsupported encoding: {e}. Supported encodings: base58, base64" - ))); - } + Some(TransactionBinaryEncoding::Base58) => bs58::encode(serialized).into_string(), + Some(TransactionBinaryEncoding::Base64) | None => BASE64_STANDARD.encode(serialized), }; self.call(RpcRequest::SimulateTransaction, (raw_tx, config), None) @@ -1159,6 +1179,17 @@ impl RpcClient { } } +/// Ensures that the provided commitment configuration meets the minimum required level of +/// 'confirmed'. +fn ensure_at_least_confirmed(commitment_config: CommitmentConfig) -> RpcResult<()> { + if !commitment_config.is_at_least_confirmed() { + return Err(RpcError::ValidationError( + "This method requires a commitment level of 'confirmed' or higher.".to_string(), + )); + } + Ok(()) +} + // TODO: // pub fn is_response_too_large(code: &RejectionCode, message: &str) -> bool { // code == &RejectionCode::SysFatal && (message.contains("size limit") || diff --git a/src/ic-solana/src/types/cluster.rs b/src/ic-solana/src/types/cluster.rs index eb26fe3..a3ec297 100644 --- a/src/ic-solana/src/types/cluster.rs +++ b/src/ic-solana/src/types/cluster.rs @@ -79,15 +79,21 @@ impl FromStr for Cluster { impl fmt::Display for Cluster { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - let cluster_str = match self { + let cluster_str = self.as_ref(); + write!(f, "{cluster_str}") + } +} + +impl AsRef for Cluster { + fn as_ref(&self) -> &str { + match self { Cluster::Testnet => "testnet", Cluster::Mainnet => "mainnet", Cluster::Devnet => "devnet", Cluster::Localnet => "localnet", Cluster::Debug => "debug", Cluster::Custom(url, _ws_url) => url, - }; - write!(f, "{cluster_str}") + } } } diff --git a/src/ic-solana/src/types/config.rs b/src/ic-solana/src/types/config.rs index 2998603..c018895 100644 --- a/src/ic-solana/src/types/config.rs +++ b/src/ic-solana/src/types/config.rs @@ -7,7 +7,7 @@ use crate::types::{ filter::RpcFilterType, response::RpcBlockProductionRange, transaction::{TransactionDetails, UiTransactionEncoding}, - Epoch, Slot, + Epoch, Slot, TransactionBinaryEncoding, }; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, CandidType)] @@ -38,7 +38,7 @@ pub struct RpcSendTransactionConfig { pub preflight_commitment: Option, /// Encoding used for the transaction data. /// Default: `Base64` - pub encoding: Option, + pub encoding: Option, /// Maximum number of times for the RPC node to retry sending the transaction to the leader. /// If this parameter is not provided, the RPC node will retry the transaction until it is /// finalized or until the blockhash expires. @@ -64,7 +64,7 @@ pub struct RpcSimulateTransactionConfig { #[serde(default, rename = "replaceRecentBlockhash")] pub replace_recent_blockhash: bool, pub commitment: Option, - pub encoding: Option, + pub encoding: Option, pub accounts: Option, #[serde(rename = "minContextSlot")] pub min_context_slot: Option, diff --git a/src/ic-solana/src/types/tagged.rs b/src/ic-solana/src/types/tagged.rs index 1f28408..2b24feb 100644 --- a/src/ic-solana/src/types/tagged.rs +++ b/src/ic-solana/src/types/tagged.rs @@ -498,8 +498,7 @@ impl From for super::UiTransactionStatusMeta { #[serde(rename_all = "camelCase")] pub enum EncodedTransaction { #[serde(rename = "legacyBinary")] - LegacyBinary(String), /* Old way of expressing base-58, retained for RPC backwards - * compatibility */ + LegacyBinary(String), // Old way of expressing base-58, retained for RPC backwards compatibility #[serde(rename = "binary")] Binary(String, TransactionBinaryEncoding), #[serde(rename = "json")] diff --git a/src/ic-solana/src/types/transaction.rs b/src/ic-solana/src/types/transaction.rs index 35e1ab8..9d48c0f 100644 --- a/src/ic-solana/src/types/transaction.rs +++ b/src/ic-solana/src/types/transaction.rs @@ -200,8 +200,7 @@ pub struct UiAccountsList { #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", untagged)] pub enum EncodedTransaction { - LegacyBinary(String), /* Old way of expressing base-58, retained for RPC backwards - * compatibility */ + LegacyBinary(String), // Old way of expressing base-58, retained for RPC backwards compatibility Binary(String, TransactionBinaryEncoding), Json(UiTransaction), Accounts(UiAccountsList),