//! A nonblocking [`RpcSender`] used for unit testing [`RpcClient`](crate::rpc_client::RpcClient). use { crate::rpc_sender::*, async_trait::async_trait, serde_json::{json, Number, Value}, solana_account_decoder::{UiAccount, UiAccountEncoding}, solana_rpc_client_api::{ client_error::Result, config::RpcBlockProductionConfig, request::RpcRequest, response::{ Response, RpcAccountBalance, RpcBlockProduction, RpcBlockProductionRange, RpcBlockhash, RpcConfirmedTransactionStatusWithSignature, RpcContactInfo, RpcFees, RpcIdentity, RpcInflationGovernor, RpcInflationRate, RpcInflationReward, RpcKeyedAccount, RpcPerfSample, RpcResponseContext, RpcSimulateTransactionResult, RpcSnapshotSlotInfo, RpcStakeActivation, RpcSupply, RpcVersionInfo, RpcVoteAccountInfo, RpcVoteAccountStatus, StakeActivationState, }, }, solana_sdk::{ account::Account, clock::{Slot, UnixTimestamp}, epoch_info::EpochInfo, fee_calculator::{FeeCalculator, FeeRateGovernor}, instruction::InstructionError, message::MessageHeader, pubkey::Pubkey, signature::Signature, sysvar::epoch_schedule::EpochSchedule, transaction::{self, Transaction, TransactionError, TransactionVersion}, }, solana_transaction_status::{ EncodedConfirmedBlock, EncodedConfirmedTransactionWithStatusMeta, EncodedTransaction, EncodedTransactionWithStatusMeta, Rewards, TransactionBinaryEncoding, TransactionConfirmationStatus, TransactionStatus, UiCompiledInstruction, UiMessage, UiRawMessage, UiTransaction, UiTransactionStatusMeta, }, solana_version::Version, std::{collections::HashMap, net::SocketAddr, str::FromStr, sync::RwLock}, }; pub const PUBKEY: &str = "7RoSF9fUmdphVCpabEoefH81WwrW7orsWonXWqTXkKV8"; pub type Mocks = HashMap; pub struct MockSender { mocks: RwLock, url: String, } /// An [`RpcSender`] used for unit testing [`RpcClient`](crate::rpc_client::RpcClient). /// /// This is primarily for internal use. /// /// Unless directed otherwise, it will generally return a reasonable default /// response, at least for [`RpcRequest`] values for which responses have been /// implemented. /// /// The behavior can be customized in two ways: /// /// 1) The `url` constructor argument is not actually a URL, but a simple string /// directive that changes `MockSender`s behavior in specific scenarios. /// /// If `url` is "fails" then any call to `send` will return `Ok(Value::Null)`. /// /// It is customary to set the `url` to "succeeds" for mocks that should /// return sucessfully, though this value is not actually interpreted. /// /// Other possible values of `url` are specific to different `RpcRequest` /// values. Read the implementation for specifics. /// /// 2) Custom responses can be configured by providing [`Mocks`] to the /// [`MockSender::new_with_mocks`] constructor. This type is a [`HashMap`] /// from [`RpcRequest`] to a JSON [`Value`] response, Any entries in this map /// override the default behavior for the given request. impl MockSender { pub fn new(url: U) -> Self { Self::new_with_mocks(url, Mocks::default()) } pub fn new_with_mocks(url: U, mocks: Mocks) -> Self { Self { url: url.to_string(), mocks: RwLock::new(mocks), } } } #[async_trait] impl RpcSender for MockSender { fn get_transport_stats(&self) -> RpcTransportStats { RpcTransportStats::default() } async fn send( &self, request: RpcRequest, params: serde_json::Value, ) -> Result { if let Some(value) = self.mocks.write().unwrap().remove(&request) { return Ok(value); } if self.url == "fails" { return Ok(Value::Null); } let method = &request.build_request_json(42, params.clone())["method"]; let val = match method.as_str().unwrap() { "getAccountInfo" => serde_json::to_value(Response { context: RpcResponseContext { slot: 1, api_version: None }, value: Value::Null, })?, "getBalance" => serde_json::to_value(Response { context: RpcResponseContext { slot: 1, api_version: None }, value: Value::Number(Number::from(50)), })?, "getRecentBlockhash" => serde_json::to_value(Response { context: RpcResponseContext { slot: 1, api_version: None }, value: ( Value::String(PUBKEY.to_string()), serde_json::to_value(FeeCalculator::default()).unwrap(), ), })?, "getEpochInfo" => serde_json::to_value(EpochInfo { epoch: 1, slot_index: 2, slots_in_epoch: 32, absolute_slot: 34, block_height: 34, transaction_count: Some(123), })?, "getFeeCalculatorForBlockhash" => { let value = if self.url == "blockhash_expired" { Value::Null } else { serde_json::to_value(Some(FeeCalculator::default())).unwrap() }; serde_json::to_value(Response { context: RpcResponseContext { slot: 1, api_version: None }, value, })? } "getFeeRateGovernor" => serde_json::to_value(Response { context: RpcResponseContext { slot: 1, api_version: None }, value: serde_json::to_value(FeeRateGovernor::default()).unwrap(), })?, "getFees" => serde_json::to_value(Response { context: RpcResponseContext { slot: 1, api_version: None }, value: serde_json::to_value(RpcFees { blockhash: PUBKEY.to_string(), fee_calculator: FeeCalculator::default(), last_valid_slot: 42, last_valid_block_height: 42, }) .unwrap(), })?, "getSignatureStatuses" => { let status: transaction::Result<()> = if self.url == "account_in_use" { Err(TransactionError::AccountInUse) } else if self.url == "instruction_error" { Err(TransactionError::InstructionError( 0, InstructionError::UninitializedAccount, )) } else { Ok(()) }; let status = if self.url == "sig_not_found" { None } else { let err = status.clone().err(); Some(TransactionStatus { status, slot: 1, confirmations: None, err, confirmation_status: Some(TransactionConfirmationStatus::Finalized), }) }; let statuses: Vec> = params.as_array().unwrap()[0] .as_array() .unwrap() .iter() .map(|_| status.clone()) .collect(); serde_json::to_value(Response { context: RpcResponseContext { slot: 1, api_version: None }, value: statuses, })? } "getTransaction" => serde_json::to_value(EncodedConfirmedTransactionWithStatusMeta { slot: 2, transaction: EncodedTransactionWithStatusMeta { version: Some(TransactionVersion::LEGACY), transaction: EncodedTransaction::Json( UiTransaction { signatures: vec!["3AsdoALgZFuq2oUVWrDYhg2pNeaLJKPLf8hU2mQ6U8qJxeJ6hsrPVpMn9ma39DtfYCrDQSvngWRP8NnTpEhezJpE".to_string()], message: UiMessage::Raw( UiRawMessage { header: MessageHeader { num_required_signatures: 1, num_readonly_signed_accounts: 0, num_readonly_unsigned_accounts: 1, }, account_keys: vec![ "C6eBmAXKg6JhJWkajGa5YRGUfG4YKXwbxF5Ufv7PtExZ".to_string(), "2Gd5eoR5J4BV89uXbtunpbNhjmw3wa1NbRHxTHzDzZLX".to_string(), "11111111111111111111111111111111".to_string(), ], recent_blockhash: "D37n3BSG71oUWcWjbZ37jZP7UfsxG2QMKeuALJ1PYvM6".to_string(), instructions: vec![UiCompiledInstruction { program_id_index: 2, accounts: vec![0, 1], data: "3Bxs49DitAvXtoDR".to_string(), }], address_table_lookups: None, }) }), meta: Some(UiTransactionStatusMeta { err: None, status: Ok(()), fee: 0, pre_balances: vec![499999999999999950, 50, 1], post_balances: vec![499999999999999950, 50, 1], inner_instructions: None, log_messages: None, pre_token_balances: None, post_token_balances: None, rewards: None, loaded_addresses: None, return_data: None, compute_units_consumed: None, }), }, block_time: Some(1628633791), })?, "getTransactionCount" => json![1234], "getSlot" => json![0], "getMaxShredInsertSlot" => json![0], "requestAirdrop" => Value::String(Signature::new(&[8; 64]).to_string()), "getSnapshotSlot" => Value::Number(Number::from(0)), "getHighestSnapshotSlot" => json!(RpcSnapshotSlotInfo { full: 100, incremental: Some(110), }), "getBlockHeight" => Value::Number(Number::from(1234)), "getSlotLeaders" => json!([PUBKEY]), "getBlockProduction" => { if params.is_null() { json!(Response { context: RpcResponseContext { slot: 1, api_version: None }, value: RpcBlockProduction { by_identity: HashMap::new(), range: RpcBlockProductionRange { first_slot: 1, last_slot: 2, }, }, }) } else { let config: Vec = serde_json::from_value(params).unwrap(); let config = config[0].clone(); let mut by_identity = HashMap::new(); by_identity.insert(config.identity.unwrap(), (1, 123)); let config_range = config.range.unwrap_or_default(); json!(Response { context: RpcResponseContext { slot: 1, api_version: None }, value: RpcBlockProduction { by_identity, range: RpcBlockProductionRange { first_slot: config_range.first_slot, last_slot: { if let Some(last_slot) = config_range.last_slot { last_slot } else { 2 } }, }, }, }) } } "getStakeActivation" => json!(RpcStakeActivation { state: StakeActivationState::Activating, active: 123, inactive: 12, }), "getStakeMinimumDelegation" => json!(Response { context: RpcResponseContext { slot: 1, api_version: None }, value: 123_456_789, }), "getSupply" => json!(Response { context: RpcResponseContext { slot: 1, api_version: None }, value: RpcSupply { total: 100000000, circulating: 50000, non_circulating: 20000, non_circulating_accounts: vec![PUBKEY.to_string()], }, }), "getLargestAccounts" => { let rpc_account_balance = RpcAccountBalance { address: PUBKEY.to_string(), lamports: 10000, }; json!(Response { context: RpcResponseContext { slot: 1, api_version: None }, value: vec![rpc_account_balance], }) } "getVoteAccounts" => { json!(RpcVoteAccountStatus { current: vec![], delinquent: vec![RpcVoteAccountInfo { vote_pubkey: PUBKEY.to_string(), node_pubkey: PUBKEY.to_string(), activated_stake: 0, commission: 0, epoch_vote_account: false, epoch_credits: vec![], last_vote: 0, root_slot: Slot::default(), }], }) } "sendTransaction" => { let signature = if self.url == "malicious" { Signature::new(&[8; 64]).to_string() } else { let tx_str = params.as_array().unwrap()[0].as_str().unwrap().to_string(); let data = base64::decode(tx_str).unwrap(); let tx: Transaction = bincode::deserialize(&data).unwrap(); tx.signatures[0].to_string() }; Value::String(signature) } "simulateTransaction" => serde_json::to_value(Response { context: RpcResponseContext { slot: 1, api_version: None }, value: RpcSimulateTransactionResult { err: None, logs: None, accounts: None, units_consumed: None, return_data: None, }, })?, "getMinimumBalanceForRentExemption" => json![20], "getVersion" => { let version = Version::default(); json!(RpcVersionInfo { solana_core: version.to_string(), feature_set: Some(version.feature_set), }) } "getLatestBlockhash" => serde_json::to_value(Response { context: RpcResponseContext { slot: 1, api_version: None }, value: RpcBlockhash { blockhash: PUBKEY.to_string(), last_valid_block_height: 1234, }, })?, "getFeeForMessage" => serde_json::to_value(Response { context: RpcResponseContext { slot: 1, api_version: None }, value: json!(Some(0)), })?, "getClusterNodes" => serde_json::to_value(vec![RpcContactInfo { pubkey: PUBKEY.to_string(), gossip: Some(SocketAddr::from(([10, 239, 6, 48], 8899))), tpu: Some(SocketAddr::from(([10, 239, 6, 48], 8856))), rpc: Some(SocketAddr::from(([10, 239, 6, 48], 8899))), version: Some("1.0.0 c375ce1f".to_string()), feature_set: None, shred_version: None, }])?, "getBlock" => serde_json::to_value(EncodedConfirmedBlock { previous_blockhash: "mfcyqEXB3DnHXki6KjjmZck6YjmZLvpAByy2fj4nh6B".to_string(), blockhash: "3Eq21vXNB5s86c62bVuUfTeaMif1N2kUqRPBmGRJhyTA".to_string(), parent_slot: 429, transactions: vec![EncodedTransactionWithStatusMeta { transaction: EncodedTransaction::Binary( "ju9xZWuDBX4pRxX2oZkTjxU5jB4SSTgEGhX8bQ8PURNzyzqKMPPpNvWihx8zUe\ FfrbVNoAaEsNKZvGzAnTDy5bhNT9kt6KFCTBixpvrLCzg4M5UdFUQYrn1gdgjX\ pLHxcaShD81xBNaFDgnA2nkkdHnKtZt4hVSfKAmw3VRZbjrZ7L2fKZBx21CwsG\ hD6onjM2M3qZW5C8J6d1pj41MxKmZgPBSha3MyKkNLkAGFASK" .to_string(), TransactionBinaryEncoding::Base58, ), meta: None, version: Some(TransactionVersion::LEGACY), }], rewards: Rewards::new(), block_time: None, block_height: Some(428), })?, "getBlocks" => serde_json::to_value(vec![1, 2, 3])?, "getBlocksWithLimit" => serde_json::to_value(vec![1, 2, 3])?, "getSignaturesForAddress" => { serde_json::to_value(vec![RpcConfirmedTransactionStatusWithSignature { signature: crate::mock_sender_for_cli::SIGNATURE.to_string(), slot: 123, err: None, memo: None, block_time: None, confirmation_status: Some(TransactionConfirmationStatus::Finalized), }])? } "getBlockTime" => serde_json::to_value(UnixTimestamp::default())?, "getEpochSchedule" => serde_json::to_value(EpochSchedule::default())?, "getRecentPerformanceSamples" => serde_json::to_value(vec![RpcPerfSample { slot: 347873, num_transactions: 125, num_slots: 123, sample_period_secs: 60, }])?, "getIdentity" => serde_json::to_value(RpcIdentity { identity: PUBKEY.to_string(), })?, "getInflationGovernor" => serde_json::to_value( RpcInflationGovernor { initial: 0.08, terminal: 0.015, taper: 0.15, foundation: 0.05, foundation_term: 7.0, })?, "getInflationRate" => serde_json::to_value( RpcInflationRate { total: 0.08, validator: 0.076, foundation: 0.004, epoch: 0, })?, "getInflationReward" => serde_json::to_value(vec![ Some(RpcInflationReward { epoch: 2, effective_slot: 224, amount: 2500, post_balance: 499999442500, commission: None, })])?, "minimumLedgerSlot" => json![123], "getMaxRetransmitSlot" => json![123], "getMultipleAccounts" => serde_json::to_value(Response { context: RpcResponseContext { slot: 1, api_version: None }, value: vec![Value::Null, Value::Null] })?, "getProgramAccounts" => { let pubkey = Pubkey::from_str(PUBKEY).unwrap(); let account = Account { lamports: 1_000_000, data: vec![], owner: pubkey, executable: false, rent_epoch: 0, }; serde_json::to_value(vec![ RpcKeyedAccount { pubkey: PUBKEY.to_string(), account: UiAccount::encode( &pubkey, &account, UiAccountEncoding::Base64, None, None, ) } ])? }, _ => Value::Null, }; Ok(val) } fn url(&self) -> String { format!("MockSender: {}", self.url) } }