// file: crates/ksp-onchain-transport-lib/unit_tests/rpc_accounts.rs // version: 4 #[test] fn account_config_serializes_all_common_fields() { let config = crate::SolanaAccountInfoConfig::new( std::option::Option::Some(crate::SolanaAccountEncoding::Base64), std::option::Option::Some(crate::SolanaDataSliceConfig::new(8, 32)), std::option::Option::Some(crate::SolanaCommitment::Finalized), std::option::Option::Some(99), ); assert_eq!( config.to_json_value(), serde_json::json!({"encoding":"base64","dataSlice":{"offset":8,"length":32},"commitment":"finalized","minContextSlot":99}) ); } #[test] fn program_accounts_config_preserves_filter_variants_and_flags() { let filters = std::vec![ crate::SolanaProgramAccountFilter::DataSize(165), crate::SolanaProgramAccountFilter::Memcmp(crate::SolanaMemcmpFilter::new(4, crate::SolanaMemcmpBytes::Base64("AQID".to_owned()))), crate::SolanaProgramAccountFilter::TokenAccountState, ]; let config = crate::SolanaProgramAccountsConfig::new( crate::SolanaAccountInfoConfig::default(), filters, std::option::Option::Some(true), std::option::Option::Some(true), ); assert_eq!( config.to_json_value(), serde_json::json!({ "filters":[{"dataSize":165},{"memcmp":{"offset":4,"bytes":"AQID","encoding":"base64"}},"tokenAccountState"], "withContext":true, "sortResults":true }) ); } #[test] fn account_wire_fixture_preserves_legacy_encoded_and_json_parsed_data() { let values: std::vec::Vec = serde_json::from_str(include_str!("../fixtures/http/account_data.variants.json")).expect("fixture must decode"); let legacy = crate::SolanaAccount::decode_wire("fixture", values[0].clone()).expect("legacy account must decode"); assert!(matches!(legacy.data(), crate::SolanaAccountData::LegacyBinary(_))); assert_eq!(legacy.space(), std::option::Option::None); let encoded = crate::SolanaAccount::decode_wire("fixture", values[1].clone()).expect("encoded account must decode"); assert!(matches!(encoded.data(), crate::SolanaAccountData::Encoded { encoding: crate::SolanaAccountEncoding::Base64Zstd, .. })); let parsed = crate::SolanaAccount::decode_wire("fixture", values[2].clone()).expect("parsed account must decode"); assert!(matches!(parsed.data(), crate::SolanaAccountData::JsonParsed(_)), "jsonParsed fixture must retain parsed data"); if let crate::SolanaAccountData::JsonParsed(value) = parsed.data() { assert_eq!(value.program(), "spl-token"); assert_eq!(value.space(), 165); assert_eq!(value.parsed()["type"], serde_json::json!("account")); } } #[test] fn staged_largest_and_keyed_account_helpers_match_wire_shapes() { let config = crate::SolanaLargestAccountsConfig::new( std::option::Option::Some(crate::SolanaCommitment::Finalized), std::option::Option::Some(crate::SolanaLargestAccountsFilter::NonCirculating), std::option::Option::Some(true), ); assert_eq!(config.to_json_value(), serde_json::json!({"commitment":"finalized","filter":"nonCirculating","sortResults":true})); let keyed = crate::SolanaKeyedAccount::decode_wire( "fixture", serde_json::json!({ "pubkey":"11111111111111111111111111111111", "account":{ "lamports":42, "data":["", "base64"], "owner":"11111111111111111111111111111111", "executable":false, "rentEpoch":0, "space":0 } }), ) .expect("keyed account must decode"); assert_eq!(keyed.pubkey().to_string(), "11111111111111111111111111111111"); assert_eq!(keyed.account().lamports(), 42); let balance = crate::SolanaAccountBalance::decode_wire("fixture", serde_json::json!({"address":"11111111111111111111111111111111","lamports":99})) .expect("account balance must decode"); assert_eq!(balance.address().to_string(), "11111111111111111111111111111111"); assert_eq!(balance.lamports(), 99); } fn pool_for_url(url: &str) -> crate::HttpTransportPool { let role = crate::HttpEndpointRoleSettings::new( crate::HttpRoleName::new("default"), true, std::vec![crate::HttpRequestKind::wildcard()], 10, crate::HttpRoleLimits::new(std::option::Option::None, std::option::Option::None, std::option::Option::None, std::option::Option::None), ); let endpoint = crate::HttpEndpointSettings::new( "fixture", true, crate::HttpProviderName::new("fixture"), crate::HttpClusterName::new("local"), crate::HttpEndpointUrl::parse(url).expect("fixture URL must parse"), std::time::Duration::from_secs(1), std::time::Duration::from_secs(1), std::option::Option::Some(1), std::vec![role], ); let settings = crate::HttpTransportSettings::new( std::vec![endpoint], crate::HttpRetrySettings::new(0, std::time::Duration::from_millis(1), std::time::Duration::from_millis(1)), ); return crate::HttpTransportPool::new(settings).expect("fixture pool must build"); } fn serve_once(body: &'static str) -> (std::string::String, std::thread::JoinHandle) { let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("fixture listener must bind"); let address = listener.local_addr().expect("fixture listener address must resolve"); let handle = std::thread::spawn(move || { let (mut stream, _) = listener.accept().expect("fixture server must accept one request"); let request = read_request(&mut stream); let response = format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body); std::io::Write::write_all(&mut stream, response.as_bytes()).expect("fixture response must write"); return request; }); return (format!("http://{address}"), handle); } fn read_request(stream: &mut std::net::TcpStream) -> std::string::String { let mut bytes = std::vec::Vec::new(); let mut buffer = [0_u8; 1024]; loop { let count = std::io::Read::read(stream, &mut buffer).expect("fixture request must read"); if count == 0 { break; } bytes.extend_from_slice(&buffer[..count]); if request_complete(bytes.as_slice()) { break; } } return std::string::String::from_utf8(bytes).expect("fixture request must be UTF-8"); } fn request_complete(bytes: &[u8]) -> bool { let text = match std::str::from_utf8(bytes) { std::result::Result::Ok(text) => text, std::result::Result::Err(_) => return false, }; let header_end = match text.find("\r\n\r\n") { std::option::Option::Some(value) => value, std::option::Option::None => return false, }; let mut content_length = 0_usize; for line in text[..header_end].lines() { let (name, value) = match line.split_once(':') { std::option::Option::Some(parts) => parts, std::option::Option::None => continue, }; if name.eq_ignore_ascii_case("content-length") { content_length = value.trim().parse::().expect("content length must parse"); } } return bytes.len() >= header_end.saturating_add(4).saturating_add(content_length); } fn request_body(request: &str) -> serde_json::Value { let body = request.split("\r\n\r\n").nth(1).expect("fixture request body must exist"); return serde_json::from_str(body).expect("fixture request body must be JSON"); } fn fixture_pubkey(value: &str) -> ksp_core_lib::Pubkey { return value.parse::().expect("fixture pubkey must parse"); } #[tokio::test(flavor = "current_thread")] async fn typed_get_account_info_serializes_config_and_preserves_account_wire() { let (url, handle) = serve_once(include_str!("../fixtures/http/get_account_info.success.json")); let pool = pool_for_url(url.as_str()); let account = fixture_pubkey("11111111111111111111111111111111"); let config = crate::SolanaAccountInfoConfig::new( std::option::Option::Some(crate::SolanaAccountEncoding::Base64), std::option::Option::Some(crate::SolanaDataSliceConfig::new(2, 4)), std::option::Option::Some(crate::SolanaCommitment::Finalized), std::option::Option::Some(400_000_000), ); let response = pool .get_account_info(&crate::HttpRoleName::new("default"), &account, std::option::Option::Some(&config)) .await .expect("getAccountInfo fixture must succeed"); assert_eq!(response.context().slot(), 410_000_001); let returned = response.value().as_ref().expect("fixture account must be present"); assert_eq!(returned.lamports(), 2_039_280); assert_eq!(returned.owner(), &account); assert_eq!(returned.space(), std::option::Option::Some(4)); assert!(matches!(returned.data(), crate::SolanaAccountData::Encoded { encoding: crate::SolanaAccountEncoding::Base64, .. })); let request = handle.join().expect("fixture server must join"); let body = request_body(request.as_str()); assert_eq!(body["method"], serde_json::json!("getAccountInfo")); assert_eq!(body["params"][0], serde_json::json!(account.to_string())); assert_eq!(body["params"][1]["encoding"], serde_json::json!("base64")); assert_eq!(body["params"][1]["dataSlice"], serde_json::json!({"offset":2,"length":4})); assert_eq!(body["params"][1]["commitment"], serde_json::json!("finalized")); assert_eq!(body["params"][1]["minContextSlot"], serde_json::json!(400_000_000_u64)); } #[tokio::test(flavor = "current_thread")] async fn typed_get_account_info_preserves_missing_account_as_none() { let (url, handle) = serve_once(include_str!("../fixtures/http/get_account_info.null.json")); let pool = pool_for_url(url.as_str()); let account = fixture_pubkey("11111111111111111111111111111111"); let response = pool .get_account_info(&crate::HttpRoleName::new("default"), &account, std::option::Option::None) .await .expect("missing getAccountInfo fixture must succeed"); assert_eq!(response.value(), &std::option::Option::None); let request = handle.join().expect("fixture server must join"); assert_eq!(request_body(request.as_str())["params"].as_array().map(std::vec::Vec::len), std::option::Option::Some(1)); } #[tokio::test(flavor = "current_thread")] async fn typed_get_account_info_rejects_invalid_owner_without_exposing_value() { let (url, handle) = serve_once(include_str!("../fixtures/http/get_account_info.invalid_owner.json")); let pool = pool_for_url(url.as_str()); let account = fixture_pubkey("11111111111111111111111111111111"); let result = pool.get_account_info(&crate::HttpRoleName::new("default"), &account, std::option::Option::None).await; let error = result.expect_err("invalid account owner must fail typed decoding"); assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RESPONSE); assert!(!format!("{error:?}").contains("not-a-pubkey")); handle.join().expect("fixture server must join"); } #[tokio::test(flavor = "current_thread")] async fn typed_get_largest_accounts_serializes_extended_config_and_decodes_order() { let (url, handle) = serve_once(include_str!("../fixtures/http/get_largest_accounts.success.json")); let pool = pool_for_url(url.as_str()); let config = crate::SolanaLargestAccountsConfig::new( std::option::Option::Some(crate::SolanaCommitment::Finalized), std::option::Option::Some(crate::SolanaLargestAccountsFilter::Circulating), std::option::Option::Some(true), ); let response = pool .get_largest_accounts(&crate::HttpRoleName::new("default"), std::option::Option::Some(&config)) .await .expect("getLargestAccounts fixture must succeed"); assert_eq!(response.value().len(), 2); assert_eq!(response.value()[0].lamports(), 999_999_999); assert_eq!(response.value()[1].lamports(), 888_888_888); let request = handle.join().expect("fixture server must join"); let body = request_body(request.as_str()); assert_eq!(body["method"], serde_json::json!("getLargestAccounts")); assert_eq!(body["params"][0], serde_json::json!({"commitment":"finalized","filter":"circulating","sortResults":true})); } #[tokio::test(flavor = "current_thread")] async fn typed_get_largest_accounts_rejects_invalid_address() { let (url, handle) = serve_once(include_str!("../fixtures/http/get_largest_accounts.invalid_address.json")); let pool = pool_for_url(url.as_str()); let result = pool.get_largest_accounts(&crate::HttpRoleName::new("default"), std::option::Option::None).await; let error = result.expect_err("invalid largest-account address must fail typed decoding"); assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RESPONSE); assert!(!format!("{error:?}").contains("invalid-address")); handle.join().expect("fixture server must join"); } #[tokio::test(flavor = "current_thread")] async fn typed_get_minimum_balance_for_rent_exemption_serializes_length_and_commitment() { let (url, handle) = serve_once(include_str!("../fixtures/http/get_minimum_balance_for_rent_exemption.success.json")); let pool = pool_for_url(url.as_str()); let config = crate::SolanaCommitmentConfig::new(std::option::Option::Some(crate::SolanaCommitment::Processed)); let result = pool .get_minimum_balance_for_rent_exemption(&crate::HttpRoleName::new("default"), 50, std::option::Option::Some(&config)) .await .expect("rent-exemption fixture must succeed"); assert_eq!(result, 890_880); let request = handle.join().expect("fixture server must join"); let body = request_body(request.as_str()); assert_eq!(body["method"], serde_json::json!("getMinimumBalanceForRentExemption")); assert_eq!(body["params"], serde_json::json!([50,{"commitment":"processed"}])); } #[tokio::test(flavor = "current_thread")] async fn typed_get_minimum_balance_for_rent_exemption_preserves_rpc_application_error() { let (url, handle) = serve_once(include_str!("../fixtures/http/get_minimum_balance_for_rent_exemption.error.json")); let pool = pool_for_url(url.as_str()); let result = pool.get_minimum_balance_for_rent_exemption(&crate::HttpRoleName::new("default"), usize::MAX, std::option::Option::None).await; let error = result.expect_err("remote invalid parameter must remain an RPC application error"); assert_eq!(error.code(), crate::ERROR_CODE_RPC_APPLICATION_ERROR); handle.join().expect("fixture server must join"); } #[tokio::test(flavor = "current_thread")] async fn typed_get_multiple_accounts_preserves_order_nulls_and_config() { let (url, handle) = serve_once(include_str!("../fixtures/http/get_multiple_accounts.success.json")); let pool = pool_for_url(url.as_str()); let accounts = std::vec![ fixture_pubkey("11111111111111111111111111111111"), fixture_pubkey("ComputeBudget111111111111111111111111111111"), fixture_pubkey("Stake11111111111111111111111111111111111111"), ]; let config = crate::SolanaAccountInfoConfig::new( std::option::Option::Some(crate::SolanaAccountEncoding::JsonParsed), std::option::Option::None, std::option::Option::Some(crate::SolanaCommitment::Confirmed), std::option::Option::Some(400_000_001), ); let response = pool .get_multiple_accounts(&crate::HttpRoleName::new("default"), accounts.as_slice(), std::option::Option::Some(&config)) .await .expect("getMultipleAccounts fixture must succeed"); assert_eq!(response.value().len(), 3); assert_eq!(response.value()[0].as_ref().map(crate::SolanaAccount::lamports), std::option::Option::Some(10)); assert!(response.value()[1].is_none()); assert_eq!(response.value()[2].as_ref().map(crate::SolanaAccount::lamports), std::option::Option::Some(20)); assert!(matches!(response.value()[2].as_ref().map(crate::SolanaAccount::data), std::option::Option::Some(crate::SolanaAccountData::JsonParsed(_)))); let request = handle.join().expect("fixture server must join"); let body = request_body(request.as_str()); assert_eq!(body["method"], serde_json::json!("getMultipleAccounts")); assert_eq!(body["params"][0], serde_json::json!(accounts.iter().map(std::string::ToString::to_string).collect::>())); assert_eq!(body["params"][1]["encoding"], serde_json::json!("jsonParsed")); assert_eq!(body["params"][1]["commitment"], serde_json::json!("confirmed")); } #[tokio::test(flavor = "current_thread")] async fn typed_get_multiple_accounts_rejects_more_than_100_before_io() { let pool = pool_for_url("http://127.0.0.1:9"); let account = fixture_pubkey("11111111111111111111111111111111"); let accounts = std::vec![account; 101]; let result = pool.get_multiple_accounts(&crate::HttpRoleName::new("default"), accounts.as_slice(), std::option::Option::None).await; let error = result.expect_err("more than 100 accounts must be rejected locally"); assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS); assert_eq!(error.context()[0].key(), "rpc_method"); assert_eq!(error.context()[1].key(), "account_count"); assert_eq!(error.context()[1].value(), "101"); } #[tokio::test(flavor = "current_thread")] async fn typed_get_program_accounts_serializes_filters_and_decodes_bare_result() { let (url, handle) = serve_once(include_str!("../fixtures/http/get_program_accounts.bare.success.json")); let pool = pool_for_url(url.as_str()); let program_id = fixture_pubkey("11111111111111111111111111111111"); let filters = std::vec![ crate::SolanaProgramAccountFilter::DataSize(3), crate::SolanaProgramAccountFilter::Memcmp(crate::SolanaMemcmpFilter::new(0, crate::SolanaMemcmpBytes::Base64("AQID".to_owned()))), crate::SolanaProgramAccountFilter::TokenAccountState, ]; let account_config = crate::SolanaAccountInfoConfig::new( std::option::Option::Some(crate::SolanaAccountEncoding::Base64), std::option::Option::Some(crate::SolanaDataSliceConfig::new(0, 3)), std::option::Option::Some(crate::SolanaCommitment::Finalized), std::option::Option::Some(400_000_002), ); let config = crate::SolanaProgramAccountsConfig::new(account_config, filters, std::option::Option::Some(false), std::option::Option::Some(true)); let result = pool .get_program_accounts(&crate::HttpRoleName::new("default"), &program_id, std::option::Option::Some(&config)) .await .expect("bare getProgramAccounts fixture must succeed"); assert!(matches!(&result, crate::SolanaProgramAccountsResult::Accounts(_)), "bare fixture must preserve the bare account-list result"); if let crate::SolanaProgramAccountsResult::Accounts(accounts) = result { assert_eq!(accounts.len(), 1); assert_eq!(accounts[0].account().lamports(), 42); } let request = handle.join().expect("fixture server must join"); let body = request_body(request.as_str()); assert_eq!(body["method"], serde_json::json!("getProgramAccounts")); assert_eq!( body["params"][1]["filters"], serde_json::json!([{"dataSize":3},{"memcmp":{"offset":0,"bytes":"AQID","encoding":"base64"}},"tokenAccountState"]), ); assert_eq!(body["params"][1]["withContext"], serde_json::json!(false)); assert_eq!(body["params"][1]["sortResults"], serde_json::json!(true)); } #[tokio::test(flavor = "current_thread")] async fn typed_get_program_accounts_preserves_contextual_result() { let (url, handle) = serve_once(include_str!("../fixtures/http/get_program_accounts.context.success.json")); let pool = pool_for_url(url.as_str()); let program_id = fixture_pubkey("11111111111111111111111111111111"); let config = crate::SolanaProgramAccountsConfig::new( crate::SolanaAccountInfoConfig::default(), std::vec::Vec::new(), std::option::Option::Some(true), std::option::Option::None, ); let result = pool .get_program_accounts(&crate::HttpRoleName::new("default"), &program_id, std::option::Option::Some(&config)) .await .expect("contextual getProgramAccounts fixture must succeed"); assert!(matches!(&result, crate::SolanaProgramAccountsResult::Context(_)), "contextual fixture must preserve RpcResponse wrapper"); if let crate::SolanaProgramAccountsResult::Context(response) = result { assert_eq!(response.context().slot(), 410_000_007); assert_eq!(response.value().len(), 1); } let request = handle.join().expect("fixture server must join"); assert_eq!(request_body(request.as_str())["params"][1]["withContext"], serde_json::json!(true)); } #[tokio::test(flavor = "current_thread")] async fn typed_get_program_accounts_rejects_filter_cardinality_and_oversized_raw_memcmp_before_io() { let pool = pool_for_url("http://127.0.0.1:9"); let program_id = fixture_pubkey("11111111111111111111111111111111"); let too_many = crate::SolanaProgramAccountsConfig::new( crate::SolanaAccountInfoConfig::default(), std::vec![ crate::SolanaProgramAccountFilter::DataSize(1), crate::SolanaProgramAccountFilter::DataSize(2), crate::SolanaProgramAccountFilter::DataSize(3), crate::SolanaProgramAccountFilter::DataSize(4), crate::SolanaProgramAccountFilter::DataSize(5), ], std::option::Option::None, std::option::Option::None, ); let result = pool.get_program_accounts(&crate::HttpRoleName::new("default"), &program_id, std::option::Option::Some(&too_many)).await; let error = result.expect_err("more than four program-account filters must be rejected locally"); assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS); assert_eq!(error.context()[1].value(), "5"); let oversized = crate::SolanaProgramAccountsConfig::new( crate::SolanaAccountInfoConfig::default(), std::vec![crate::SolanaProgramAccountFilter::Memcmp(crate::SolanaMemcmpFilter::new(0, crate::SolanaMemcmpBytes::Bytes(std::vec![0_u8; 129]),))], std::option::Option::None, std::option::Option::None, ); let result = pool.get_program_accounts(&crate::HttpRoleName::new("default"), &program_id, std::option::Option::Some(&oversized)).await; let error = result.expect_err("raw memcmp data above 128 bytes must be rejected locally"); assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS); assert_eq!(error.context()[1].value(), "129"); } #[test] fn typed_get_multiple_accounts_rejects_response_count_mismatch() { let value = serde_json::json!({ "context":{"apiVersion":"4.2.1","slot":410000008}, "value":[] }); let result = super::decode_multiple_accounts_response("getMultipleAccounts", value, 1); let error = result.expect_err("response count mismatch must fail typed decoding"); assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RESPONSE); assert_eq!(error.context()[1].key(), "expected_count"); assert_eq!(error.context()[1].value(), "1"); assert_eq!(error.context()[2].key(), "actual_count"); assert_eq!(error.context()[2].value(), "0"); }