Files
khadhroony-solana-project/crates/ksp-onchain-transport-lib/unit_tests/rpc_accounts.rs
2026-08-18 07:31:07 +02:00

447 lines
23 KiB
Rust

// file: crates/ksp-onchain-transport-lib/unit_tests/rpc_accounts.rs
// version: 3
#[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::Value> =
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<std::string::String>) {
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::<usize>().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::<ksp_core_lib::Pubkey>().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::<std::vec::Vec<_>>()));
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");
}