1446 lines
84 KiB
Rust
1446 lines
84 KiB
Rust
// file: crates/ksp-onchain-transport-lib/unit_tests/rpc_transactions.rs
|
|
// version: 11
|
|
|
|
#[test]
|
|
fn transaction_encoding_strings_match_current_and_legacy_wire_labels() {
|
|
assert_eq!(crate::SolanaTransactionBinaryEncoding::Base58.as_str(), "base58");
|
|
assert_eq!(crate::SolanaTransactionBinaryEncoding::Base64.as_str(), "base64");
|
|
assert_eq!(crate::SolanaTransactionEncoding::Binary.as_str(), "binary");
|
|
assert_eq!(crate::SolanaTransactionEncoding::Base58.as_str(), "base58");
|
|
assert_eq!(crate::SolanaTransactionEncoding::Base64.as_str(), "base64");
|
|
assert_eq!(crate::SolanaTransactionEncoding::Json.as_str(), "json");
|
|
assert_eq!(crate::SolanaTransactionEncoding::JsonParsed.as_str(), "jsonParsed");
|
|
}
|
|
|
|
#[test]
|
|
fn wire_field_preserves_omitted_null_and_value_states() {
|
|
#[derive(serde::Deserialize)]
|
|
struct Holder {
|
|
#[serde(default)]
|
|
field: crate::SolanaWireField<u64>,
|
|
}
|
|
let omitted = serde_json::from_value::<Holder>(serde_json::json!({})).expect("omitted fixture must decode");
|
|
let null = serde_json::from_value::<Holder>(serde_json::json!({"field": null})).expect("null fixture must decode");
|
|
let value = serde_json::from_value::<Holder>(serde_json::json!({"field": 7})).expect("value fixture must decode");
|
|
assert!(omitted.field.is_omitted());
|
|
assert!(!omitted.field.is_null());
|
|
assert_eq!(omitted.field.value(), std::option::Option::None);
|
|
assert!(null.field.is_null());
|
|
assert!(!null.field.is_omitted());
|
|
assert_eq!(null.field.value(), std::option::Option::None);
|
|
assert_eq!(value.field.value(), std::option::Option::Some(&7));
|
|
}
|
|
|
|
#[test]
|
|
fn get_transaction_config_serializes_modern_object_without_legacy_overload() {
|
|
let config = crate::SolanaGetTransactionConfig::new(
|
|
std::option::Option::Some(crate::SolanaCommitment::Confirmed),
|
|
std::option::Option::Some(crate::SolanaTransactionEncoding::JsonParsed),
|
|
std::option::Option::Some(0),
|
|
);
|
|
assert!(!config.is_empty());
|
|
assert_eq!(config.to_json_value(), serde_json::json!({"commitment":"confirmed","encoding":"jsonParsed","maxSupportedTransactionVersion":0}));
|
|
assert!(crate::SolanaGetTransactionConfig::default().is_empty());
|
|
assert_eq!(crate::SolanaGetTransactionConfig::default().to_json_value(), serde_json::json!({}));
|
|
}
|
|
|
|
#[test]
|
|
fn signatures_for_address_config_preserves_pagination_and_context_order_independently() {
|
|
let config = crate::SolanaSignaturesForAddressConfig::new(
|
|
std::option::Option::Some("before-signature".to_owned()),
|
|
std::option::Option::Some("until-signature".to_owned()),
|
|
std::option::Option::Some(25),
|
|
std::option::Option::Some(crate::SolanaCommitment::Finalized),
|
|
std::option::Option::Some(430_000_000),
|
|
);
|
|
assert_eq!(config.before(), std::option::Option::Some("before-signature"));
|
|
assert_eq!(config.until(), std::option::Option::Some("until-signature"));
|
|
assert_eq!(config.limit(), std::option::Option::Some(25));
|
|
assert_eq!(config.commitment(), std::option::Option::Some(crate::SolanaCommitment::Finalized));
|
|
assert_eq!(config.min_context_slot(), std::option::Option::Some(430_000_000));
|
|
assert_eq!(
|
|
config.to_json_value(),
|
|
serde_json::json!({
|
|
"before":"before-signature",
|
|
"until":"until-signature",
|
|
"limit":25,
|
|
"commitment":"finalized",
|
|
"minContextSlot":430000000
|
|
})
|
|
);
|
|
assert!(crate::SolanaSignaturesForAddressConfig::default().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn signature_status_and_airdrop_configs_preserve_explicit_options() {
|
|
let status = crate::SolanaSignatureStatusesConfig::new(std::option::Option::Some(false));
|
|
assert!(!status.is_empty());
|
|
assert_eq!(status.search_transaction_history(), std::option::Option::Some(false));
|
|
assert_eq!(status.to_json_value(), serde_json::json!({"searchTransactionHistory":false}));
|
|
assert!(crate::SolanaSignatureStatusesConfig::default().is_empty());
|
|
let airdrop = crate::SolanaRequestAirdropConfig::new(
|
|
std::option::Option::Some("recent-blockhash".to_owned()),
|
|
std::option::Option::Some(crate::SolanaCommitment::Confirmed),
|
|
);
|
|
assert!(!airdrop.is_empty());
|
|
assert_eq!(airdrop.recent_blockhash(), std::option::Option::Some("recent-blockhash"));
|
|
assert_eq!(airdrop.commitment(), std::option::Option::Some(crate::SolanaCommitment::Confirmed));
|
|
assert_eq!(airdrop.to_json_value(), serde_json::json!({"recentBlockhash":"recent-blockhash","commitment":"confirmed"}));
|
|
assert!(crate::SolanaRequestAirdropConfig::default().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn send_transaction_config_keeps_node_retries_distinct_from_transport_policy() {
|
|
let config = crate::SolanaSendTransactionConfig::new(
|
|
std::option::Option::Some(true),
|
|
std::option::Option::Some(crate::SolanaCommitment::Processed),
|
|
std::option::Option::Some(crate::SolanaTransactionBinaryEncoding::Base64),
|
|
std::option::Option::Some(4),
|
|
std::option::Option::Some(430_000_000),
|
|
);
|
|
assert_eq!(config.skip_preflight(), std::option::Option::Some(true));
|
|
assert_eq!(config.max_retries(), std::option::Option::Some(4));
|
|
assert_eq!(
|
|
config.to_json_value(),
|
|
serde_json::json!({
|
|
"skipPreflight":true,
|
|
"preflightCommitment":"processed",
|
|
"encoding":"base64",
|
|
"maxRetries":4,
|
|
"minContextSlot":430000000
|
|
})
|
|
);
|
|
assert!(crate::SolanaSendTransactionConfig::default().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn simulate_transaction_config_serializes_accounts_and_flags_without_decoding_transaction() {
|
|
let address = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
|
|
let accounts = crate::SolanaSimulationAccountsConfig::new(std::option::Option::Some(crate::SolanaAccountEncoding::Base64), std::vec![address]);
|
|
let config = crate::SolanaSimulateTransactionConfig::new(
|
|
std::option::Option::Some(crate::SolanaCommitment::Confirmed),
|
|
std::option::Option::Some(crate::SolanaTransactionBinaryEncoding::Base64),
|
|
std::option::Option::Some(true),
|
|
std::option::Option::Some(false),
|
|
std::option::Option::Some(430_000_000),
|
|
std::option::Option::Some(true),
|
|
std::option::Option::Some(accounts),
|
|
);
|
|
assert_eq!(config.encoding(), std::option::Option::Some(crate::SolanaTransactionBinaryEncoding::Base64));
|
|
assert_eq!(config.replace_recent_blockhash(), std::option::Option::Some(true));
|
|
assert_eq!(config.sig_verify(), std::option::Option::Some(false));
|
|
assert_eq!(config.accounts().expect("accounts config must be present").addresses(), &[address]);
|
|
assert_eq!(
|
|
config.to_json_value(),
|
|
serde_json::json!({
|
|
"commitment":"confirmed",
|
|
"encoding":"base64",
|
|
"replaceRecentBlockhash":true,
|
|
"sigVerify":false,
|
|
"minContextSlot":430000000,
|
|
"innerInstructions":true,
|
|
"accounts":{"encoding":"base64","addresses":["11111111111111111111111111111111"]}
|
|
})
|
|
);
|
|
assert!(crate::SolanaSimulateTransactionConfig::default().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn latest_blockhash_fixture_decodes_shared_result() {
|
|
let value =
|
|
serde_json::from_str::<serde_json::Value>(include_str!("../fixtures/http/latest_blockhash.v4_2_1.json")).expect("latest blockhash fixture must decode");
|
|
let blockhash = crate::SolanaLatestBlockhash::decode_wire("getLatestBlockhash", value).expect("latest blockhash must decode");
|
|
assert_eq!(blockhash.blockhash(), "11111111111111111111111111111111");
|
|
assert_eq!(blockhash.last_valid_block_height(), 430_123_456);
|
|
}
|
|
|
|
#[test]
|
|
fn latest_blockhash_rejects_empty_wire_value() {
|
|
let result = crate::SolanaLatestBlockhash::decode_wire("getLatestBlockhash", serde_json::json!({"blockhash":"","lastValidBlockHeight":430123456}));
|
|
assert_eq!(result.expect_err("empty blockhash must fail closed").code(), crate::ERROR_CODE_INVALID_RESPONSE);
|
|
}
|
|
|
|
#[test]
|
|
fn prioritization_fee_fixture_preserves_server_order() {
|
|
let values = serde_json::from_str::<std::vec::Vec<serde_json::Value>>(include_str!("../fixtures/http/prioritization_fee.v4_2_1.json"))
|
|
.expect("prioritization fee fixture must decode");
|
|
let first = crate::SolanaPrioritizationFee::decode_wire("getRecentPrioritizationFees", values[0].clone()).expect("first fee must decode");
|
|
let second = crate::SolanaPrioritizationFee::decode_wire("getRecentPrioritizationFees", values[1].clone()).expect("second fee must decode");
|
|
assert_eq!(first.slot(), 430_000_123);
|
|
assert_eq!(first.prioritization_fee(), 1_000);
|
|
assert_eq!(second.slot(), 430_000_122);
|
|
assert_eq!(second.prioritization_fee(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn signature_info_fixture_accepts_current_transaction_index_and_older_omission() {
|
|
let values = serde_json::from_str::<std::vec::Vec<serde_json::Value>>(include_str!("../fixtures/http/signature_info.v4_2_1.json"))
|
|
.expect("signature info fixture must decode");
|
|
let current = crate::SolanaSignatureInfo::decode_wire("getSignaturesForAddress", values[0].clone()).expect("current signature info must decode");
|
|
let older = crate::SolanaSignatureInfo::decode_wire("getSignaturesForAddress", values[1].clone()).expect("older signature info must decode");
|
|
assert_eq!(current.slot(), 430_000_123);
|
|
assert_eq!(current.memo(), std::option::Option::Some("fixture memo"));
|
|
assert_eq!(current.transaction_index(), std::option::Option::Some(7));
|
|
assert_eq!(current.confirmation_status(), std::option::Option::Some(crate::SolanaTransactionConfirmationStatus::Finalized));
|
|
assert_eq!(older.transaction_index(), std::option::Option::None);
|
|
assert!(older.err().is_some());
|
|
assert_eq!(older.confirmation_status(), std::option::Option::Some(crate::SolanaTransactionConfirmationStatus::Confirmed));
|
|
}
|
|
|
|
#[test]
|
|
fn signature_status_fixture_preserves_position_null_and_legacy_status_value() {
|
|
let values = serde_json::from_str::<std::vec::Vec<serde_json::Value>>(include_str!("../fixtures/http/signature_status.variants.json"))
|
|
.expect("signature status fixture must decode");
|
|
assert!(values[0].is_null());
|
|
let rooted = crate::SolanaSignatureStatus::decode_wire("getSignatureStatuses", values[1].clone()).expect("rooted signature status must decode");
|
|
let errored = crate::SolanaSignatureStatus::decode_wire("getSignatureStatuses", values[2].clone()).expect("errored signature status must decode");
|
|
assert_eq!(rooted.confirmations(), std::option::Option::None);
|
|
assert_eq!(rooted.status(), &serde_json::json!({"Ok":null}));
|
|
assert_eq!(rooted.confirmation_status(), std::option::Option::Some(crate::SolanaTransactionConfirmationStatus::Finalized));
|
|
assert_eq!(errored.confirmations(), std::option::Option::Some(3));
|
|
assert!(errored.err().is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_confirmation_status_is_rejected_as_invalid_response() {
|
|
let result = crate::SolanaSignatureStatus::decode_wire(
|
|
"getSignatureStatuses",
|
|
serde_json::json!({"slot":1,"confirmations":0,"status":{"Ok":null},"err":null,"confirmationStatus":"future-status"}),
|
|
);
|
|
let error = result.expect_err("unknown confirmation status must fail closed");
|
|
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RESPONSE);
|
|
}
|
|
|
|
#[test]
|
|
fn encoded_transaction_fixture_preserves_legacy_binary_tuple_and_json_shapes() {
|
|
let values = serde_json::from_str::<std::vec::Vec<serde_json::Value>>(include_str!("../fixtures/http/encoded_transaction.variants.json"))
|
|
.expect("encoded transaction fixture must decode");
|
|
let legacy = crate::SolanaEncodedTransaction::decode_wire("getTransaction", values[0].clone()).expect("legacy transaction must decode");
|
|
let binary = crate::SolanaEncodedTransaction::decode_wire("getTransaction", values[1].clone()).expect("binary transaction must decode");
|
|
let json = crate::SolanaEncodedTransaction::decode_wire("getTransaction", values[2].clone()).expect("JSON transaction must decode");
|
|
assert!(matches!(legacy, crate::SolanaEncodedTransaction::LegacyBinary(_)));
|
|
assert!(matches!(binary, crate::SolanaEncodedTransaction::Binary { encoding: crate::SolanaTransactionBinaryEncoding::Base64, .. }));
|
|
assert!(matches!(json, crate::SolanaEncodedTransaction::Json(_)));
|
|
let invalid = crate::SolanaEncodedTransaction::decode_wire("getTransaction", serde_json::json!(["payload", "json"]));
|
|
assert_eq!(invalid.expect_err("non-binary tuple encoding must fail").code(), crate::ERROR_CODE_INVALID_RESPONSE);
|
|
}
|
|
|
|
#[test]
|
|
fn confirmed_transaction_fixture_preserves_meta_version_and_transaction_index_states() {
|
|
let values = serde_json::from_str::<std::vec::Vec<serde_json::Value>>(include_str!("../fixtures/http/confirmed_transaction.variants.json"))
|
|
.expect("confirmed transaction fixture must decode");
|
|
let current = crate::SolanaConfirmedTransaction::decode_wire("getTransaction", values[0].clone()).expect("current transaction must decode");
|
|
let legacy = crate::SolanaConfirmedTransaction::decode_wire("getTransaction", values[1].clone()).expect("legacy transaction must decode");
|
|
let omitted = crate::SolanaConfirmedTransaction::decode_wire("getTransaction", values[2].clone()).expect("omitted-field transaction must decode");
|
|
assert_eq!(current.slot(), 430_000_123);
|
|
assert_eq!(current.version().value(), std::option::Option::Some(&crate::SolanaTransactionVersion::Number(0)));
|
|
assert_eq!(current.transaction_index().value(), std::option::Option::Some(&7));
|
|
assert!(current.meta().value().is_some());
|
|
assert_eq!(legacy.version().value(), std::option::Option::Some(&crate::SolanaTransactionVersion::Legacy));
|
|
assert!(legacy.meta().is_null());
|
|
assert!(legacy.transaction_index().is_null());
|
|
assert!(omitted.meta().is_omitted());
|
|
assert!(omitted.version().is_omitted());
|
|
assert!(omitted.transaction_index().is_omitted());
|
|
}
|
|
|
|
#[test]
|
|
fn confirmed_transaction_rejects_unknown_version_shape() {
|
|
let result = crate::SolanaConfirmedTransaction::decode_wire(
|
|
"getTransaction",
|
|
serde_json::json!({"slot":1,"blockTime":null,"transaction":"legacy","meta":null,"version":"v0"}),
|
|
);
|
|
assert_eq!(result.expect_err("unknown version label must fail").code(), crate::ERROR_CODE_INVALID_RESPONSE);
|
|
}
|
|
|
|
#[test]
|
|
fn simulation_result_fixture_preserves_rich_optional_fields_and_positional_null_accounts() {
|
|
let value = serde_json::from_str::<serde_json::Value>(include_str!("../fixtures/http/simulate_transaction_result.v4_2_1.json"))
|
|
.expect("simulation result fixture must decode");
|
|
let result = crate::SolanaSimulateTransactionResult::decode_wire("simulateTransaction", value).expect("simulation result must decode");
|
|
assert!(result.err().is_null());
|
|
assert_eq!(result.logs().value().expect("logs must be present").len(), 2);
|
|
let accounts = result.accounts().value().expect("accounts must be present");
|
|
assert_eq!(accounts.len(), 2);
|
|
assert_eq!(accounts[0].as_ref().expect("first account must be present").lamports(), 1_000);
|
|
assert!(accounts[1].is_none());
|
|
assert_eq!(result.units_consumed().value(), std::option::Option::Some(&150));
|
|
assert_eq!(result.loaded_accounts_data_size().value(), std::option::Option::Some(&64));
|
|
assert_eq!(result.fee().value(), std::option::Option::Some(&5_000));
|
|
let replacement = result.replacement_blockhash().value().expect("replacement blockhash must be present");
|
|
assert_eq!(replacement.blockhash(), "ComputeBudget111111111111111111111111111111");
|
|
assert!(result.return_data().value().is_some());
|
|
assert!(result.inner_instructions().value().is_some());
|
|
assert!(result.pre_token_balances().value().is_some());
|
|
assert!(result.post_token_balances().value().is_some());
|
|
assert!(result.loaded_addresses().value().is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn simulation_result_distinguishes_omitted_from_explicit_null_fields() {
|
|
let result = crate::SolanaSimulateTransactionResult::decode_wire("simulateTransaction", serde_json::json!({"err":null,"logs":null,"unitsConsumed":1}))
|
|
.expect("partial simulation result must decode");
|
|
assert!(result.err().is_null());
|
|
assert!(result.logs().is_null());
|
|
assert_eq!(result.units_consumed().value(), std::option::Option::Some(&1));
|
|
assert!(result.accounts().is_omitted());
|
|
assert!(result.replacement_blockhash().is_omitted());
|
|
assert!(result.loaded_addresses().is_omitted());
|
|
}
|
|
|
|
fn transaction_pool_for_url(url: &str) -> crate::HttpTransportPool {
|
|
return transaction_pool_for_urls(&[(url, 10)], std::time::Duration::from_secs(1), 0);
|
|
}
|
|
|
|
fn transaction_pool_for_urls(urls: &[(&str, u32)], request_timeout: std::time::Duration, max_retries: u32) -> crate::HttpTransportPool {
|
|
let mut endpoints = std::vec::Vec::with_capacity(urls.len());
|
|
for (index, (url, priority)) in urls.iter().enumerate() {
|
|
let role = crate::HttpEndpointRoleSettings::new(
|
|
crate::HttpRoleName::new("default"),
|
|
true,
|
|
std::vec![crate::HttpRequestKind::wildcard()],
|
|
*priority,
|
|
crate::HttpRoleLimits::new(std::option::Option::None, std::option::Option::None, std::option::Option::None, std::option::Option::None),
|
|
);
|
|
endpoints.push(crate::HttpEndpointSettings::new(
|
|
format!("fixture-{index}"),
|
|
true,
|
|
crate::HttpProviderName::new("fixture"),
|
|
crate::HttpClusterName::new("local"),
|
|
crate::HttpEndpointUrl::parse(*url).expect("fixture URL must parse"),
|
|
std::time::Duration::from_millis(100),
|
|
request_timeout,
|
|
std::option::Option::Some(1),
|
|
std::vec![role],
|
|
));
|
|
}
|
|
let settings = crate::HttpTransportSettings::new(
|
|
endpoints,
|
|
crate::HttpRetrySettings::new(max_retries, std::time::Duration::from_millis(1), std::time::Duration::from_millis(2)),
|
|
);
|
|
return crate::HttpTransportPool::new(settings).expect("fixture pool must build");
|
|
}
|
|
|
|
fn serve_transaction_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 || {
|
|
loop {
|
|
let (mut stream, _) = listener.accept().expect("fixture server must accept one request");
|
|
let request = read_transaction_request(&mut stream);
|
|
if !transaction_request_complete(request.as_bytes()) {
|
|
continue;
|
|
}
|
|
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);
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn transaction_fixture_ignores_abandoned_connection_before_complete_request() {
|
|
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/get_transaction.null.json"));
|
|
let address = url.strip_prefix("http://").expect("fixture URL must use HTTP");
|
|
let abandoned = std::net::TcpStream::connect(address).expect("abandoned fixture connection must connect");
|
|
drop(abandoned);
|
|
let pool = transaction_pool_for_url(url.as_str());
|
|
let result = pool
|
|
.get_transaction(&crate::HttpRoleName::new("default"), "fixture-signature", std::option::Option::None)
|
|
.await
|
|
.expect("fixture must remain available after abandoned pre-request connection");
|
|
assert!(result.is_none());
|
|
let request = handle.join().expect("fixture server must join");
|
|
assert_eq!(transaction_request_body(request.as_str())["method"], serde_json::json!("getTransaction"));
|
|
}
|
|
|
|
fn serve_transaction_status_and_count(status_line: &'static str) -> (std::string::String, std::thread::JoinHandle<(usize, 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 first request");
|
|
let first_request = read_transaction_request(&mut stream);
|
|
let response = format!("HTTP/1.1 {status_line}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
|
|
std::io::Write::write_all(&mut stream, response.as_bytes()).expect("fixture response must write");
|
|
let mut count = 1_usize;
|
|
listener.set_nonblocking(true).expect("fixture listener must become nonblocking");
|
|
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(120);
|
|
while std::time::Instant::now() < deadline {
|
|
match listener.accept() {
|
|
std::result::Result::Ok((mut retry_stream, _)) => {
|
|
let _ = read_transaction_request(&mut retry_stream);
|
|
std::io::Write::write_all(&mut retry_stream, response.as_bytes()).expect("fixture retry response must write");
|
|
count = count.saturating_add(1);
|
|
},
|
|
std::result::Result::Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
|
|
std::thread::sleep(std::time::Duration::from_millis(5));
|
|
},
|
|
std::result::Result::Err(error) => panic!("fixture listener failed while counting retries: {error}"),
|
|
}
|
|
}
|
|
return (count, first_request);
|
|
});
|
|
return (format!("http://{address}"), handle);
|
|
}
|
|
|
|
fn serve_transaction_timeout_and_count(delay: std::time::Duration) -> (std::string::String, std::thread::JoinHandle<(usize, 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 first request");
|
|
let first_request = read_transaction_request(&mut stream);
|
|
std::thread::sleep(delay);
|
|
let mut count = 1_usize;
|
|
listener.set_nonblocking(true).expect("fixture listener must become nonblocking");
|
|
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(120);
|
|
while std::time::Instant::now() < deadline {
|
|
match listener.accept() {
|
|
std::result::Result::Ok((mut retry_stream, _)) => {
|
|
let _ = read_transaction_request(&mut retry_stream);
|
|
count = count.saturating_add(1);
|
|
},
|
|
std::result::Result::Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
|
|
std::thread::sleep(std::time::Duration::from_millis(5));
|
|
},
|
|
std::result::Result::Err(error) => panic!("fixture listener failed while counting timeout retries: {error}"),
|
|
}
|
|
}
|
|
return (count, first_request);
|
|
});
|
|
return (format!("http://{address}"), handle);
|
|
}
|
|
|
|
fn serve_transaction_status_then_body(
|
|
status_line: &'static str,
|
|
body: &'static str,
|
|
) -> (std::string::String, std::thread::JoinHandle<(std::string::String, 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 first_stream, _) = listener.accept().expect("fixture server must accept first request");
|
|
let first_request = read_transaction_request(&mut first_stream);
|
|
let first_response = format!("HTTP/1.1 {status_line}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
|
|
std::io::Write::write_all(&mut first_stream, first_response.as_bytes()).expect("fixture first response must write");
|
|
listener.set_nonblocking(true).expect("fixture listener must become nonblocking");
|
|
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(500);
|
|
return loop {
|
|
match listener.accept() {
|
|
std::result::Result::Ok((mut retry_stream, _)) => {
|
|
let retry_request = read_transaction_request(&mut retry_stream);
|
|
let retry_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 retry_stream, retry_response.as_bytes()).expect("fixture retry response must write");
|
|
break (first_request, retry_request);
|
|
},
|
|
std::result::Result::Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
|
|
if std::time::Instant::now() >= deadline {
|
|
panic!("fixture server did not receive retry before deadline");
|
|
}
|
|
std::thread::sleep(std::time::Duration::from_millis(5));
|
|
},
|
|
std::result::Result::Err(error) => panic!("fixture listener failed while waiting for retry: {error}"),
|
|
}
|
|
};
|
|
});
|
|
return (format!("http://{address}"), handle);
|
|
}
|
|
|
|
fn unused_local_url() -> std::string::String {
|
|
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("unused-port probe must bind");
|
|
let address = listener.local_addr().expect("unused-port probe address must resolve");
|
|
drop(listener);
|
|
return format!("http://{address}");
|
|
}
|
|
|
|
fn read_transaction_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 transaction_request_complete(bytes.as_slice()) {
|
|
break;
|
|
}
|
|
}
|
|
return std::string::String::from_utf8(bytes).expect("fixture request must be UTF-8");
|
|
}
|
|
|
|
fn transaction_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 transaction_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");
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn typed_get_fee_for_message_serializes_exact_params_and_decodes_contextual_fee() {
|
|
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/get_fee_for_message.success.json"));
|
|
let pool = transaction_pool_for_url(url.as_str());
|
|
let config = crate::SolanaContextConfig::new(std::option::Option::Some(crate::SolanaCommitment::Confirmed), std::option::Option::Some(431_000_000));
|
|
let response = pool
|
|
.get_fee_for_message(&crate::HttpRoleName::new("default"), "AQIDBA==", std::option::Option::Some(&config))
|
|
.await
|
|
.expect("getFeeForMessage fixture must succeed");
|
|
assert_eq!(response.context().slot(), 431_000_001);
|
|
assert_eq!(response.context().api_version(), std::option::Option::Some("4.2.1"));
|
|
assert_eq!(response.value(), &std::option::Option::Some(5_000));
|
|
let request = handle.join().expect("fixture server must join");
|
|
let body = transaction_request_body(request.as_str());
|
|
assert_eq!(body["method"], serde_json::json!("getFeeForMessage"));
|
|
assert_eq!(body["params"], serde_json::json!(["AQIDBA==", {"commitment":"confirmed","minContextSlot":431000000}]));
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn typed_get_fee_for_message_preserves_null_and_omits_absent_config() {
|
|
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/get_fee_for_message.null.json"));
|
|
let pool = transaction_pool_for_url(url.as_str());
|
|
let response = pool
|
|
.get_fee_for_message(&crate::HttpRoleName::new("default"), "opaque-message", std::option::Option::None)
|
|
.await
|
|
.expect("null fee fixture must succeed");
|
|
assert_eq!(response.value(), &std::option::Option::None);
|
|
let request = handle.join().expect("fixture server must join");
|
|
assert_eq!(transaction_request_body(request.as_str())["params"], serde_json::json!(["opaque-message"]));
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn typed_get_fee_for_message_preserves_rpc_application_error() {
|
|
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/get_fee_for_message.error.json"));
|
|
let pool = transaction_pool_for_url(url.as_str());
|
|
let result = pool.get_fee_for_message(&crate::HttpRoleName::new("default"), "not-decoded-locally", std::option::Option::None).await;
|
|
let error = result.expect_err("remote invalid message 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_latest_blockhash_omits_empty_config_and_decodes_contextual_result() {
|
|
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/get_latest_blockhash.success.json"));
|
|
let pool = transaction_pool_for_url(url.as_str());
|
|
let config = crate::SolanaContextConfig::default();
|
|
let response = pool
|
|
.get_latest_blockhash(&crate::HttpRoleName::new("default"), std::option::Option::Some(&config))
|
|
.await
|
|
.expect("getLatestBlockhash fixture must succeed");
|
|
assert_eq!(response.context().slot(), 431_000_003);
|
|
assert_eq!(response.value().blockhash(), "11111111111111111111111111111111");
|
|
assert_eq!(response.value().last_valid_block_height(), 431_123_456);
|
|
let request = handle.join().expect("fixture server must join");
|
|
assert_eq!(transaction_request_body(request.as_str())["params"], serde_json::json!([]));
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn typed_get_latest_blockhash_rejects_empty_blockhash_from_http_response() {
|
|
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/get_latest_blockhash.invalid_empty.json"));
|
|
let pool = transaction_pool_for_url(url.as_str());
|
|
let result = pool.get_latest_blockhash(&crate::HttpRoleName::new("default"), std::option::Option::None).await;
|
|
let error = result.expect_err("empty latest blockhash must fail typed decoding");
|
|
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RESPONSE);
|
|
handle.join().expect("fixture server must join");
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn typed_get_transaction_count_serializes_context_config_and_decodes_u64() {
|
|
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/get_transaction_count.success.json"));
|
|
let pool = transaction_pool_for_url(url.as_str());
|
|
let config = crate::SolanaContextConfig::new(std::option::Option::Some(crate::SolanaCommitment::Finalized), std::option::Option::None);
|
|
let count = pool
|
|
.get_transaction_count(&crate::HttpRoleName::new("default"), std::option::Option::Some(&config))
|
|
.await
|
|
.expect("getTransactionCount fixture must succeed");
|
|
assert_eq!(count, 987_654_321);
|
|
let request = handle.join().expect("fixture server must join");
|
|
let body = transaction_request_body(request.as_str());
|
|
assert_eq!(body["method"], serde_json::json!("getTransactionCount"));
|
|
assert_eq!(body["params"], serde_json::json!([{"commitment":"finalized"}]));
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn typed_is_blockhash_valid_preserves_opaque_blockhash_and_contextual_bool() {
|
|
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/is_blockhash_valid.success.json"));
|
|
let pool = transaction_pool_for_url(url.as_str());
|
|
let config = crate::SolanaContextConfig::new(std::option::Option::Some(crate::SolanaCommitment::Processed), std::option::Option::Some(431_000_004));
|
|
let response = pool
|
|
.is_blockhash_valid(&crate::HttpRoleName::new("default"), "opaque-blockhash", std::option::Option::Some(&config))
|
|
.await
|
|
.expect("isBlockhashValid fixture must succeed");
|
|
assert_eq!(response.context().slot(), 431_000_005);
|
|
assert_eq!(response.value(), &true);
|
|
let request = handle.join().expect("fixture server must join");
|
|
let body = transaction_request_body(request.as_str());
|
|
assert_eq!(body["method"], serde_json::json!("isBlockhashValid"));
|
|
assert_eq!(body["params"], serde_json::json!(["opaque-blockhash", {"commitment":"processed","minContextSlot":431000004}]));
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn typed_get_recent_prioritization_fees_serializes_addresses_and_preserves_server_order() {
|
|
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/get_recent_prioritization_fees.success.json"));
|
|
let pool = transaction_pool_for_url(url.as_str());
|
|
let first = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
|
|
let second = "SysvarRent111111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
|
|
let first_text = first.to_string();
|
|
let second_text = second.to_string();
|
|
let accounts = std::vec![first, second];
|
|
let fees = pool
|
|
.get_recent_prioritization_fees(&crate::HttpRoleName::new("default"), std::option::Option::Some(accounts.as_slice()))
|
|
.await
|
|
.expect("getRecentPrioritizationFees fixture must succeed");
|
|
assert_eq!(fees.len(), 2);
|
|
assert_eq!(fees[0].slot(), 431_000_030);
|
|
assert_eq!(fees[0].prioritization_fee(), 1_200);
|
|
assert_eq!(fees[1].slot(), 431_000_029);
|
|
assert_eq!(fees[1].prioritization_fee(), 0);
|
|
let request = handle.join().expect("fixture server must join");
|
|
let body = transaction_request_body(request.as_str());
|
|
assert_eq!(body["method"], serde_json::json!("getRecentPrioritizationFees"));
|
|
assert_eq!(body["params"], serde_json::json!([[first_text, second_text]]));
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn typed_get_recent_prioritization_fees_omits_optional_accounts_and_rejects_more_than_128_before_io() {
|
|
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/get_recent_prioritization_fees.success.json"));
|
|
let pool = transaction_pool_for_url(url.as_str());
|
|
let fees = pool
|
|
.get_recent_prioritization_fees(&crate::HttpRoleName::new("default"), std::option::Option::None)
|
|
.await
|
|
.expect("getRecentPrioritizationFees without addresses must succeed");
|
|
assert_eq!(fees.len(), 2);
|
|
let request = handle.join().expect("fixture server must join");
|
|
assert_eq!(transaction_request_body(request.as_str())["params"], serde_json::json!([]));
|
|
let account = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
|
|
let max_accounts = std::vec![account; 128];
|
|
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/get_recent_prioritization_fees.success.json"));
|
|
let max_pool = transaction_pool_for_url(url.as_str());
|
|
let fees = max_pool
|
|
.get_recent_prioritization_fees(&crate::HttpRoleName::new("default"), std::option::Option::Some(max_accounts.as_slice()))
|
|
.await
|
|
.expect("exactly 128 prioritization-fee accounts must remain accepted");
|
|
assert_eq!(fees.len(), 2);
|
|
let request = handle.join().expect("fixture server must join");
|
|
let body = transaction_request_body(request.as_str());
|
|
assert_eq!(body["params"][0].as_array().expect("address parameter must be an array").len(), 128);
|
|
let invalid_pool = transaction_pool_for_url("http://127.0.0.1:9");
|
|
let accounts = std::vec![account; 129];
|
|
let result = invalid_pool.get_recent_prioritization_fees(&crate::HttpRoleName::new("default"), std::option::Option::Some(accounts.as_slice())).await;
|
|
let error = result.expect_err("more than 128 prioritization-fee accounts must reject before I/O");
|
|
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS);
|
|
assert_eq!(error.context()[1].key(), "account_count");
|
|
assert_eq!(error.context()[1].value(), "129");
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn typed_get_signatures_for_address_serializes_pagination_and_preserves_newest_first_result() {
|
|
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/get_signatures_for_address.success.json"));
|
|
let pool = transaction_pool_for_url(url.as_str());
|
|
let address = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
|
|
let config = crate::SolanaSignaturesForAddressConfig::new(
|
|
std::option::Option::Some("before-signature".to_owned()),
|
|
std::option::Option::Some("until-signature".to_owned()),
|
|
std::option::Option::Some(1_000),
|
|
std::option::Option::Some(crate::SolanaCommitment::Finalized),
|
|
std::option::Option::Some(431_000_000),
|
|
);
|
|
let signatures = pool
|
|
.get_signatures_for_address(&crate::HttpRoleName::new("default"), &address, std::option::Option::Some(&config))
|
|
.await
|
|
.expect("getSignaturesForAddress fixture must succeed");
|
|
assert_eq!(signatures.len(), 2);
|
|
assert_eq!(signatures[0].signature(), "newest-signature");
|
|
assert_eq!(signatures[0].slot(), 431_000_040);
|
|
assert_eq!(signatures[0].memo(), std::option::Option::Some("memo-newest"));
|
|
assert_eq!(signatures[0].block_time(), std::option::Option::Some(1_787_000_100));
|
|
assert_eq!(signatures[0].confirmation_status(), std::option::Option::Some(crate::SolanaTransactionConfirmationStatus::Finalized));
|
|
assert_eq!(signatures[0].transaction_index(), std::option::Option::Some(9));
|
|
assert_eq!(signatures[1].signature(), "older-signature");
|
|
assert!(signatures[1].err().is_some());
|
|
assert_eq!(signatures[1].memo(), std::option::Option::None);
|
|
assert_eq!(signatures[1].block_time(), std::option::Option::None);
|
|
assert_eq!(signatures[1].transaction_index(), std::option::Option::None);
|
|
let request = handle.join().expect("fixture server must join");
|
|
let body = transaction_request_body(request.as_str());
|
|
assert_eq!(body["method"], serde_json::json!("getSignaturesForAddress"));
|
|
assert_eq!(
|
|
body["params"],
|
|
serde_json::json!([
|
|
address.to_string(),
|
|
{
|
|
"before":"before-signature",
|
|
"until":"until-signature",
|
|
"limit":1000,
|
|
"commitment":"finalized",
|
|
"minContextSlot":431000000
|
|
}
|
|
])
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn typed_get_signatures_for_address_rejects_limit_outside_1_to_1000_before_io() {
|
|
let pool = transaction_pool_for_url("http://127.0.0.1:9");
|
|
let address = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
|
|
for limit in [0_usize, 1_001_usize] {
|
|
let config = crate::SolanaSignaturesForAddressConfig::new(
|
|
std::option::Option::None,
|
|
std::option::Option::None,
|
|
std::option::Option::Some(limit),
|
|
std::option::Option::None,
|
|
std::option::Option::None,
|
|
);
|
|
let result = pool.get_signatures_for_address(&crate::HttpRoleName::new("default"), &address, std::option::Option::Some(&config)).await;
|
|
let error = result.expect_err("out-of-range getSignaturesForAddress limit must reject before I/O");
|
|
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS);
|
|
assert_eq!(error.context()[1].key(), "limit");
|
|
assert_eq!(error.context()[1].value(), limit.to_string());
|
|
}
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn typed_get_signatures_for_address_preserves_rpc_application_error() {
|
|
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/get_signatures_for_address.error.json"));
|
|
let pool = transaction_pool_for_url(url.as_str());
|
|
let address = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
|
|
let result = pool.get_signatures_for_address(&crate::HttpRoleName::new("default"), &address, std::option::Option::None).await;
|
|
let error = result.expect_err("remote transaction-history error 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_signature_statuses_preserves_positional_nulls_and_history_config() {
|
|
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/get_signature_statuses.success.json"));
|
|
let pool = transaction_pool_for_url(url.as_str());
|
|
let signatures = std::vec!["missing-signature".to_owned(), "finalized-signature".to_owned(), "confirmed-signature".to_owned()];
|
|
let config = crate::SolanaSignatureStatusesConfig::new(std::option::Option::Some(true));
|
|
let response = pool
|
|
.get_signature_statuses(&crate::HttpRoleName::new("default"), signatures.as_slice(), std::option::Option::Some(&config))
|
|
.await
|
|
.expect("getSignatureStatuses fixture must succeed");
|
|
assert_eq!(response.context().slot(), 431_000_050);
|
|
assert_eq!(response.context().api_version(), std::option::Option::Some("4.2.1"));
|
|
assert_eq!(response.value().len(), 3);
|
|
assert!(response.value()[0].is_none());
|
|
let finalized = response.value()[1].as_ref().expect("second positional status must be present");
|
|
assert_eq!(finalized.slot(), 431_000_049);
|
|
assert_eq!(finalized.confirmations(), std::option::Option::None);
|
|
assert_eq!(finalized.status(), &serde_json::json!({"Ok":null}));
|
|
assert_eq!(finalized.confirmation_status(), std::option::Option::Some(crate::SolanaTransactionConfirmationStatus::Finalized));
|
|
let confirmed = response.value()[2].as_ref().expect("third positional status must be present");
|
|
assert_eq!(confirmed.confirmations(), std::option::Option::Some(2));
|
|
assert!(confirmed.err().is_some());
|
|
let request = handle.join().expect("fixture server must join");
|
|
let body = transaction_request_body(request.as_str());
|
|
assert_eq!(body["method"], serde_json::json!("getSignatureStatuses"));
|
|
assert_eq!(body["params"], serde_json::json!([signatures, {"searchTransactionHistory":true}]));
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn typed_get_signature_statuses_accepts_empty_input_and_omits_empty_config() {
|
|
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/get_signature_statuses.empty.json"));
|
|
let pool = transaction_pool_for_url(url.as_str());
|
|
let signatures = std::vec::Vec::<std::string::String>::new();
|
|
let config = crate::SolanaSignatureStatusesConfig::default();
|
|
let response = pool
|
|
.get_signature_statuses(&crate::HttpRoleName::new("default"), signatures.as_slice(), std::option::Option::Some(&config))
|
|
.await
|
|
.expect("empty getSignatureStatuses input must remain accepted");
|
|
assert!(response.value().is_empty());
|
|
let request = handle.join().expect("fixture server must join");
|
|
assert_eq!(transaction_request_body(request.as_str())["params"], serde_json::json!([[]]));
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn typed_get_signature_statuses_accepts_256_and_rejects_more_than_256_before_io() {
|
|
let signatures = std::vec!["signature".to_owned(); 256];
|
|
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/get_signature_statuses.max_256.json"));
|
|
let pool = transaction_pool_for_url(url.as_str());
|
|
let response = pool
|
|
.get_signature_statuses(&crate::HttpRoleName::new("default"), signatures.as_slice(), std::option::Option::None)
|
|
.await
|
|
.expect("exactly 256 signatures must remain accepted");
|
|
assert_eq!(response.value().len(), 256);
|
|
let request = handle.join().expect("fixture server must join");
|
|
let body = transaction_request_body(request.as_str());
|
|
assert_eq!(body["params"][0].as_array().expect("signature parameter must be an array").len(), 256);
|
|
let invalid_pool = transaction_pool_for_url("http://127.0.0.1:9");
|
|
let signatures = std::vec!["signature".to_owned(); 257];
|
|
let result = invalid_pool.get_signature_statuses(&crate::HttpRoleName::new("default"), signatures.as_slice(), std::option::Option::None).await;
|
|
let error = result.expect_err("more than 256 signatures must reject before I/O");
|
|
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS);
|
|
assert_eq!(error.context()[1].key(), "signature_count");
|
|
assert_eq!(error.context()[1].value(), "257");
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn typed_get_signature_statuses_rejects_response_count_mismatch() {
|
|
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/get_signature_statuses.count_mismatch.json"));
|
|
let pool = transaction_pool_for_url(url.as_str());
|
|
let requested = std::vec!["first".to_owned(), "second".to_owned()];
|
|
let result = pool.get_signature_statuses(&crate::HttpRoleName::new("default"), requested.as_slice(), std::option::Option::None).await;
|
|
let error = result.expect_err("status response count mismatch must reject 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(), "2");
|
|
assert_eq!(error.context()[2].key(), "actual_count");
|
|
assert_eq!(error.context()[2].value(), "1");
|
|
handle.join().expect("fixture server must join");
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn typed_get_transaction_modern_supports_omitted_and_explicit_empty_config() {
|
|
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/get_transaction.null.json"));
|
|
let pool = transaction_pool_for_url(url.as_str());
|
|
let result = pool
|
|
.get_transaction(&crate::HttpRoleName::new("default"), "fixture-signature", std::option::Option::None)
|
|
.await
|
|
.expect("getTransaction without config must succeed");
|
|
assert!(result.is_none());
|
|
let request = handle.join().expect("fixture server must join");
|
|
assert_eq!(transaction_request_body(request.as_str())["params"], serde_json::json!(["fixture-signature"]));
|
|
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/get_transaction.null.json"));
|
|
let pool = transaction_pool_for_url(url.as_str());
|
|
let config = crate::SolanaGetTransactionConfig::default();
|
|
let result = pool
|
|
.get_transaction(&crate::HttpRoleName::new("default"), "fixture-signature", std::option::Option::Some(&config))
|
|
.await
|
|
.expect("getTransaction with explicit empty modern config must succeed");
|
|
assert!(result.is_none());
|
|
let request = handle.join().expect("fixture server must join");
|
|
assert_eq!(transaction_request_body(request.as_str())["params"], serde_json::json!(["fixture-signature", {}]));
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn typed_get_transaction_modern_serializes_full_config_and_preserves_rich_json_parsed_wire() {
|
|
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/get_transaction.json_parsed.json"));
|
|
let pool = transaction_pool_for_url(url.as_str());
|
|
let config = crate::SolanaGetTransactionConfig::new(
|
|
std::option::Option::Some(crate::SolanaCommitment::Confirmed),
|
|
std::option::Option::Some(crate::SolanaTransactionEncoding::JsonParsed),
|
|
std::option::Option::Some(0),
|
|
);
|
|
let result = pool
|
|
.get_transaction(&crate::HttpRoleName::new("default"), "fixture-signature", std::option::Option::Some(&config))
|
|
.await
|
|
.expect("modern getTransaction fixture must succeed")
|
|
.expect("modern getTransaction fixture must contain a transaction");
|
|
assert_eq!(result.slot(), 431_000_064);
|
|
assert_eq!(result.block_time(), std::option::Option::Some(1_787_000_204));
|
|
assert_eq!(result.version().value(), std::option::Option::Some(&crate::SolanaTransactionVersion::Legacy));
|
|
assert!(result.transaction_index().is_null());
|
|
let transaction = match result.transaction() {
|
|
crate::SolanaEncodedTransaction::Json(value) => value,
|
|
_ => panic!("jsonParsed response must remain a JSON object"),
|
|
};
|
|
assert_eq!(transaction["message"]["accountKeys"][0]["source"], serde_json::json!("transaction"));
|
|
assert_eq!(transaction["message"]["instructions"][0]["parsed"]["type"], serde_json::json!("transfer"));
|
|
let meta = result.meta().value().expect("metadata must be present");
|
|
assert_eq!(meta["computeUnitsConsumed"], serde_json::json!(150));
|
|
assert_eq!(meta["costUnits"], serde_json::json!(190));
|
|
assert!(meta["loadedAddresses"].is_null());
|
|
let request = handle.join().expect("fixture server must join");
|
|
let body = transaction_request_body(request.as_str());
|
|
assert_eq!(body["method"], serde_json::json!("getTransaction"));
|
|
assert_eq!(
|
|
body["params"],
|
|
serde_json::json!([
|
|
"fixture-signature",
|
|
{"commitment":"confirmed","encoding":"jsonParsed","maxSupportedTransactionVersion":0}
|
|
])
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn typed_get_transaction_modern_covers_all_agave_v4_2_1_encoding_labels_and_response_shapes() {
|
|
let cases = [
|
|
(crate::SolanaTransactionEncoding::Binary, include_str!("../fixtures/http/get_transaction.binary_legacy.json")),
|
|
(crate::SolanaTransactionEncoding::Base58, include_str!("../fixtures/http/get_transaction.base58.json")),
|
|
(crate::SolanaTransactionEncoding::Base64, include_str!("../fixtures/http/get_transaction.base64.json")),
|
|
(crate::SolanaTransactionEncoding::Json, include_str!("../fixtures/http/get_transaction.json.json")),
|
|
(crate::SolanaTransactionEncoding::JsonParsed, include_str!("../fixtures/http/get_transaction.json_parsed.json")),
|
|
];
|
|
for (encoding, fixture) in cases {
|
|
let (url, handle) = serve_transaction_once(fixture);
|
|
let pool = transaction_pool_for_url(url.as_str());
|
|
let config = crate::SolanaGetTransactionConfig::new(std::option::Option::None, std::option::Option::Some(encoding), std::option::Option::None);
|
|
let result = pool
|
|
.get_transaction(&crate::HttpRoleName::new("default"), "fixture-signature", std::option::Option::Some(&config))
|
|
.await
|
|
.expect("encoding-specific modern getTransaction must succeed")
|
|
.expect("encoding-specific modern getTransaction must contain a transaction");
|
|
match encoding {
|
|
crate::SolanaTransactionEncoding::Binary => assert!(matches!(result.transaction(), crate::SolanaEncodedTransaction::LegacyBinary(_))),
|
|
crate::SolanaTransactionEncoding::Base58 => assert!(matches!(
|
|
result.transaction(),
|
|
crate::SolanaEncodedTransaction::Binary { encoding: crate::SolanaTransactionBinaryEncoding::Base58, .. }
|
|
)),
|
|
crate::SolanaTransactionEncoding::Base64 => assert!(matches!(
|
|
result.transaction(),
|
|
crate::SolanaEncodedTransaction::Binary { encoding: crate::SolanaTransactionBinaryEncoding::Base64, .. }
|
|
)),
|
|
crate::SolanaTransactionEncoding::Json | crate::SolanaTransactionEncoding::JsonParsed => {
|
|
assert!(matches!(result.transaction(), crate::SolanaEncodedTransaction::Json(_)));
|
|
},
|
|
}
|
|
let request = handle.join().expect("fixture server must join");
|
|
let body = transaction_request_body(request.as_str());
|
|
assert_eq!(body["params"], serde_json::json!(["fixture-signature", {"encoding":encoding.as_str()}]));
|
|
}
|
|
}
|
|
|
|
#[allow(deprecated)]
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn typed_get_transaction_legacy_covers_all_bare_encoding_labels() {
|
|
let cases = [
|
|
(crate::SolanaTransactionEncoding::Binary, include_str!("../fixtures/http/get_transaction.binary_legacy.json")),
|
|
(crate::SolanaTransactionEncoding::Base58, include_str!("../fixtures/http/get_transaction.base58.json")),
|
|
(crate::SolanaTransactionEncoding::Base64, include_str!("../fixtures/http/get_transaction.base64.json")),
|
|
(crate::SolanaTransactionEncoding::Json, include_str!("../fixtures/http/get_transaction.json.json")),
|
|
(crate::SolanaTransactionEncoding::JsonParsed, include_str!("../fixtures/http/get_transaction.json_parsed.json")),
|
|
];
|
|
for (encoding, fixture) in cases {
|
|
let (url, handle) = serve_transaction_once(fixture);
|
|
let pool = transaction_pool_for_url(url.as_str());
|
|
let result = pool
|
|
.get_transaction_legacy(&crate::HttpRoleName::new("default"), "fixture-signature", encoding)
|
|
.await
|
|
.expect("legacy bare-encoding getTransaction must succeed");
|
|
assert!(result.is_some());
|
|
let request = handle.join().expect("fixture server must join");
|
|
let body = transaction_request_body(request.as_str());
|
|
assert_eq!(body["method"], serde_json::json!("getTransaction"));
|
|
assert_eq!(body["params"], serde_json::json!(["fixture-signature", encoding.as_str()]));
|
|
}
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn typed_get_transaction_preserves_raw_json_meta_version_and_transaction_index() {
|
|
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/get_transaction.json.json"));
|
|
let pool = transaction_pool_for_url(url.as_str());
|
|
let config = crate::SolanaGetTransactionConfig::new(
|
|
std::option::Option::Some(crate::SolanaCommitment::Finalized),
|
|
std::option::Option::Some(crate::SolanaTransactionEncoding::Json),
|
|
std::option::Option::Some(0),
|
|
);
|
|
let result = pool
|
|
.get_transaction(&crate::HttpRoleName::new("default"), "fixture-signature", std::option::Option::Some(&config))
|
|
.await
|
|
.expect("raw JSON getTransaction must succeed")
|
|
.expect("raw JSON getTransaction must contain a transaction");
|
|
assert_eq!(result.version().value(), std::option::Option::Some(&crate::SolanaTransactionVersion::Number(0)));
|
|
assert_eq!(result.transaction_index().value(), std::option::Option::Some(&7));
|
|
let meta = result.meta().value().expect("metadata must be present");
|
|
assert_eq!(meta["status"], serde_json::json!({"Ok":null}));
|
|
assert_eq!(meta["loadedAddresses"]["writable"], serde_json::json!([]));
|
|
assert_eq!(meta["returnData"]["data"], serde_json::json!(["AQID", "base64"]));
|
|
assert_eq!(meta["costUnits"], serde_json::json!(190));
|
|
let request = handle.join().expect("fixture server must join");
|
|
assert_eq!(
|
|
transaction_request_body(request.as_str())["params"],
|
|
serde_json::json!([
|
|
"fixture-signature",
|
|
{"commitment":"finalized","encoding":"json","maxSupportedTransactionVersion":0}
|
|
])
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn typed_get_transaction_observed_reports_actual_winner_after_retry_reroute() {
|
|
let (first_url, first_handle) = serve_transaction_status_and_count("429 Too Many Requests");
|
|
let (winner_url, winner_handle) = serve_transaction_once(include_str!("../fixtures/http/get_transaction.base64.json"));
|
|
let urls = [(first_url.as_str(), "first-endpoint", "first-provider"), (winner_url.as_str(), "winner-endpoint", "winner-provider")];
|
|
let mut endpoints = std::vec::Vec::with_capacity(urls.len());
|
|
for (url, endpoint_name, provider) in urls {
|
|
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),
|
|
);
|
|
endpoints.push(crate::HttpEndpointSettings::new(
|
|
endpoint_name,
|
|
true,
|
|
crate::HttpProviderName::new(provider),
|
|
crate::HttpClusterName::new("local"),
|
|
crate::HttpEndpointUrl::parse(url).expect("fixture URL must parse"),
|
|
std::time::Duration::from_millis(100),
|
|
std::time::Duration::from_secs(1),
|
|
std::option::Option::Some(1),
|
|
std::vec![role],
|
|
));
|
|
}
|
|
let pool = crate::HttpTransportPool::new(crate::HttpTransportSettings::new(
|
|
endpoints,
|
|
crate::HttpRetrySettings::new(1, std::time::Duration::from_millis(1), std::time::Duration::from_millis(2)),
|
|
))
|
|
.expect("observed fixture pool must build");
|
|
let config = crate::SolanaGetTransactionConfig::new(
|
|
std::option::Option::Some(crate::SolanaCommitment::Confirmed),
|
|
std::option::Option::Some(crate::SolanaTransactionEncoding::Base64),
|
|
std::option::Option::Some(0),
|
|
);
|
|
let observed = pool
|
|
.get_transaction_observed(&crate::HttpRoleName::new("default"), "fixture-signature", std::option::Option::Some(&config))
|
|
.await
|
|
.expect("retry-safe observed getTransaction must succeed on the second endpoint");
|
|
assert_eq!(observed.endpoint_name(), "winner-endpoint");
|
|
assert_eq!(observed.provider().as_str(), "winner-provider");
|
|
let transaction = observed.value().as_ref().expect("winning response must contain a transaction");
|
|
assert_eq!(transaction.slot(), 431_000_062);
|
|
assert!(matches!(
|
|
transaction.transaction(),
|
|
crate::SolanaEncodedTransaction::Binary { encoding: crate::SolanaTransactionBinaryEncoding::Base64, .. }
|
|
));
|
|
let (first_count, first_request) = first_handle.join().expect("first fixture server must join");
|
|
assert_eq!(first_count, 1);
|
|
assert_eq!(transaction_request_body(first_request.as_str())["method"], serde_json::json!("getTransaction"));
|
|
let winner_request = winner_handle.join().expect("winner fixture server must join");
|
|
assert_eq!(transaction_request_body(winner_request.as_str())["method"], serde_json::json!("getTransaction"));
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn typed_get_transaction_observed_preserves_null_and_redacts_typed_value_debug() {
|
|
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/get_transaction.null.json"));
|
|
let pool = transaction_pool_for_url(url.as_str());
|
|
let observed = pool
|
|
.get_transaction_observed(&crate::HttpRoleName::new("default"), "fixture-signature", std::option::Option::None)
|
|
.await
|
|
.expect("observed null getTransaction must succeed");
|
|
assert!(observed.value().is_none());
|
|
assert_eq!(observed.endpoint_name(), "fixture-0");
|
|
assert_eq!(observed.provider().as_str(), "fixture");
|
|
let rendered = format!("{observed:?}");
|
|
assert!(rendered.contains("fixture-0"));
|
|
assert!(rendered.contains("fixture"));
|
|
assert!(rendered.contains("<available>"));
|
|
assert!(!rendered.contains("fixture-signature"));
|
|
handle.join().expect("fixture server must join");
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn typed_get_transaction_preserves_unsupported_version_rpc_error() {
|
|
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/get_transaction.error_unsupported_version.json"));
|
|
let pool = transaction_pool_for_url(url.as_str());
|
|
let config = crate::SolanaGetTransactionConfig::new(
|
|
std::option::Option::None,
|
|
std::option::Option::Some(crate::SolanaTransactionEncoding::Base64),
|
|
std::option::Option::Some(0),
|
|
);
|
|
let result = pool.get_transaction(&crate::HttpRoleName::new("default"), "fixture-signature", std::option::Option::Some(&config)).await;
|
|
let error = result.expect_err("unsupported transaction version 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_transaction_rejects_processed_commitment_before_io() {
|
|
let pool = transaction_pool_for_url("http://127.0.0.1:9");
|
|
let config = crate::SolanaGetTransactionConfig::new(
|
|
std::option::Option::Some(crate::SolanaCommitment::Processed),
|
|
std::option::Option::Some(crate::SolanaTransactionEncoding::Json),
|
|
std::option::Option::None,
|
|
);
|
|
let result = pool.get_transaction(&crate::HttpRoleName::new("default"), "fixture-signature", std::option::Option::Some(&config)).await;
|
|
let error = result.expect_err("processed getTransaction commitment must reject before I/O");
|
|
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS);
|
|
assert_eq!(error.context()[1].key(), "commitment");
|
|
assert_eq!(error.context()[1].value(), "processed");
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn typed_request_airdrop_exposes_runtime_config_and_canonicalizes_empty_config() {
|
|
let recipient = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
|
|
let config = crate::SolanaRequestAirdropConfig::new(
|
|
std::option::Option::Some("recent-blockhash-fixture".to_owned()),
|
|
std::option::Option::Some(crate::SolanaCommitment::Finalized),
|
|
);
|
|
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/request_airdrop.success.json"));
|
|
let pool = transaction_pool_for_url(url.as_str());
|
|
let signature = pool
|
|
.request_airdrop(&crate::HttpRoleName::new("default"), &recipient, 1_000_000_000, std::option::Option::Some(&config))
|
|
.await
|
|
.expect("requestAirdrop full-config fixture must succeed");
|
|
assert_eq!(signature, "airdrop-fixture-signature-111111111111111111111111111111111111111111111111");
|
|
let request = handle.join().expect("fixture server must join");
|
|
let body = transaction_request_body(request.as_str());
|
|
assert_eq!(body["method"], serde_json::json!("requestAirdrop"));
|
|
assert_eq!(
|
|
body["params"],
|
|
serde_json::json!([
|
|
"11111111111111111111111111111111",
|
|
1000000000,
|
|
{"recentBlockhash":"recent-blockhash-fixture","commitment":"finalized"}
|
|
])
|
|
);
|
|
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/request_airdrop.success.json"));
|
|
let pool = transaction_pool_for_url(url.as_str());
|
|
let empty = crate::SolanaRequestAirdropConfig::default();
|
|
let _ = pool
|
|
.request_airdrop(&crate::HttpRoleName::new("default"), &recipient, 1, std::option::Option::Some(&empty))
|
|
.await
|
|
.expect("requestAirdrop empty-config fixture must succeed");
|
|
let request = handle.join().expect("fixture server must join");
|
|
assert_eq!(transaction_request_body(request.as_str())["params"], serde_json::json!(["11111111111111111111111111111111", 1]));
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn typed_send_transaction_exposes_all_current_options_and_both_binary_encodings() {
|
|
let config = crate::SolanaSendTransactionConfig::new(
|
|
std::option::Option::Some(true),
|
|
std::option::Option::Some(crate::SolanaCommitment::Confirmed),
|
|
std::option::Option::Some(crate::SolanaTransactionBinaryEncoding::Base64),
|
|
std::option::Option::Some(7),
|
|
std::option::Option::Some(431_000_000),
|
|
);
|
|
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/send_transaction.success.json"));
|
|
let pool = transaction_pool_for_url(url.as_str());
|
|
let signature = pool
|
|
.send_transaction(&crate::HttpRoleName::new("default"), "opaque-base64-transaction", std::option::Option::Some(&config))
|
|
.await
|
|
.expect("sendTransaction full-config fixture must succeed");
|
|
assert_eq!(signature, "send-fixture-signature-22222222222222222222222222222222222222222222222222");
|
|
let request = handle.join().expect("fixture server must join");
|
|
let body = transaction_request_body(request.as_str());
|
|
assert_eq!(body["method"], serde_json::json!("sendTransaction"));
|
|
assert_eq!(
|
|
body["params"],
|
|
serde_json::json!([
|
|
"opaque-base64-transaction",
|
|
{
|
|
"skipPreflight":true,
|
|
"preflightCommitment":"confirmed",
|
|
"encoding":"base64",
|
|
"maxRetries":7,
|
|
"minContextSlot":431000000
|
|
}
|
|
])
|
|
);
|
|
let base58 = crate::SolanaSendTransactionConfig::new(
|
|
std::option::Option::None,
|
|
std::option::Option::None,
|
|
std::option::Option::Some(crate::SolanaTransactionBinaryEncoding::Base58),
|
|
std::option::Option::None,
|
|
std::option::Option::None,
|
|
);
|
|
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/send_transaction.success.json"));
|
|
let pool = transaction_pool_for_url(url.as_str());
|
|
let _ = pool
|
|
.send_transaction(&crate::HttpRoleName::new("default"), "opaque-base58-transaction", std::option::Option::Some(&base58))
|
|
.await
|
|
.expect("sendTransaction base58 fixture must succeed");
|
|
let request = handle.join().expect("fixture server must join");
|
|
assert_eq!(transaction_request_body(request.as_str())["params"], serde_json::json!(["opaque-base58-transaction", {"encoding":"base58"}]));
|
|
let empty = crate::SolanaSendTransactionConfig::default();
|
|
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/send_transaction.success.json"));
|
|
let pool = transaction_pool_for_url(url.as_str());
|
|
let _ = pool
|
|
.send_transaction(&crate::HttpRoleName::new("default"), "opaque-default-transaction", std::option::Option::Some(&empty))
|
|
.await
|
|
.expect("sendTransaction empty config must canonicalize to omission");
|
|
let request = handle.join().expect("fixture server must join");
|
|
assert_eq!(transaction_request_body(request.as_str())["params"], serde_json::json!(["opaque-default-transaction"]));
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn typed_write_wrappers_preserve_rpc_application_errors_without_transport_retry() {
|
|
let recipient = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
|
|
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/request_airdrop.error.json"));
|
|
let pool = transaction_pool_for_urls(&[(url.as_str(), 10)], std::time::Duration::from_millis(500), 3);
|
|
let result = pool.request_airdrop(&crate::HttpRoleName::new("default"), &recipient, 1, std::option::Option::None).await;
|
|
assert_eq!(result.expect_err("requestAirdrop RPC error must propagate").code(), crate::ERROR_CODE_RPC_APPLICATION_ERROR);
|
|
handle.join().expect("fixture server must join");
|
|
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/send_transaction.preflight_error.json"));
|
|
let pool = transaction_pool_for_urls(&[(url.as_str(), 10)], std::time::Duration::from_millis(500), 3);
|
|
let result = pool.send_transaction(&crate::HttpRoleName::new("default"), "opaque-transaction", std::option::Option::None).await;
|
|
assert_eq!(result.expect_err("sendTransaction preflight error must propagate").code(), crate::ERROR_CODE_RPC_APPLICATION_ERROR);
|
|
handle.join().expect("fixture server must join");
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn typed_write_wrappers_never_resend_after_http_429_dispatch() {
|
|
let recipient = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
|
|
let (url, handle) = serve_transaction_status_and_count("429 Too Many Requests");
|
|
let pool = transaction_pool_for_urls(&[(url.as_str(), 10)], std::time::Duration::from_millis(500), 3);
|
|
let result = pool.request_airdrop(&crate::HttpRoleName::new("default"), &recipient, 1, std::option::Option::None).await;
|
|
assert_eq!(result.expect_err("airdrop 429 must stop after dispatch").code(), crate::ERROR_CODE_RATE_LIMITED);
|
|
let (count, request) = handle.join().expect("fixture server must join");
|
|
assert_eq!(count, 1);
|
|
assert_eq!(transaction_request_body(request.as_str())["method"], serde_json::json!("requestAirdrop"));
|
|
let (url, handle) = serve_transaction_status_and_count("429 Too Many Requests");
|
|
let pool = transaction_pool_for_urls(&[(url.as_str(), 10)], std::time::Duration::from_millis(500), 3);
|
|
let result = pool.send_transaction(&crate::HttpRoleName::new("default"), "opaque-transaction", std::option::Option::None).await;
|
|
assert_eq!(result.expect_err("send 429 must stop after dispatch").code(), crate::ERROR_CODE_RATE_LIMITED);
|
|
let (count, request) = handle.join().expect("fixture server must join");
|
|
assert_eq!(count, 1);
|
|
assert_eq!(transaction_request_body(request.as_str())["method"], serde_json::json!("sendTransaction"));
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn typed_write_wrappers_never_resend_after_temporary_http_dispatch() {
|
|
let recipient = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
|
|
let (url, handle) = serve_transaction_status_and_count("503 Service Unavailable");
|
|
let pool = transaction_pool_for_urls(&[(url.as_str(), 10)], std::time::Duration::from_millis(500), 3);
|
|
let result = pool.request_airdrop(&crate::HttpRoleName::new("default"), &recipient, 1, std::option::Option::None).await;
|
|
assert_eq!(result.expect_err("airdrop 503 must stop after dispatch").code(), crate::ERROR_CODE_HTTP_REQUEST_FAILED);
|
|
let (count, _) = handle.join().expect("fixture server must join");
|
|
assert_eq!(count, 1);
|
|
let (url, handle) = serve_transaction_status_and_count("503 Service Unavailable");
|
|
let pool = transaction_pool_for_urls(&[(url.as_str(), 10)], std::time::Duration::from_millis(500), 3);
|
|
let result = pool.send_transaction(&crate::HttpRoleName::new("default"), "opaque-transaction", std::option::Option::None).await;
|
|
assert_eq!(result.expect_err("send 503 must stop after dispatch").code(), crate::ERROR_CODE_HTTP_REQUEST_FAILED);
|
|
let (count, _) = handle.join().expect("fixture server must join");
|
|
assert_eq!(count, 1);
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn typed_write_wrappers_never_resend_after_ambiguous_timeout_dispatch() {
|
|
let recipient = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
|
|
let (url, handle) = serve_transaction_timeout_and_count(std::time::Duration::from_millis(80));
|
|
let pool = transaction_pool_for_urls(&[(url.as_str(), 10)], std::time::Duration::from_millis(20), 3);
|
|
let result = pool.request_airdrop(&crate::HttpRoleName::new("default"), &recipient, 1, std::option::Option::None).await;
|
|
assert_eq!(result.expect_err("airdrop timeout must stop after ambiguous dispatch").code(), crate::ERROR_CODE_TIMEOUT);
|
|
let (count, _) = handle.join().expect("fixture server must join");
|
|
assert_eq!(count, 1);
|
|
let (url, handle) = serve_transaction_timeout_and_count(std::time::Duration::from_millis(80));
|
|
let pool = transaction_pool_for_urls(&[(url.as_str(), 10)], std::time::Duration::from_millis(20), 3);
|
|
let result = pool.send_transaction(&crate::HttpRoleName::new("default"), "opaque-transaction", std::option::Option::None).await;
|
|
assert_eq!(result.expect_err("send timeout must stop after ambiguous dispatch").code(), crate::ERROR_CODE_TIMEOUT);
|
|
let (count, _) = handle.join().expect("fixture server must join");
|
|
assert_eq!(count, 1);
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn typed_write_wrappers_can_retry_when_connection_failure_proves_not_dispatched() {
|
|
let recipient = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
|
|
let closed = unused_local_url();
|
|
let (secondary, handle) = serve_transaction_once(include_str!("../fixtures/http/request_airdrop.success.json"));
|
|
let pool = transaction_pool_for_urls(&[(closed.as_str(), 10), (secondary.as_str(), 10)], std::time::Duration::from_millis(500), 1);
|
|
let result = pool
|
|
.request_airdrop(&crate::HttpRoleName::new("default"), &recipient, 1, std::option::Option::None)
|
|
.await
|
|
.expect("NotDispatched connection failure may retry requestAirdrop safely");
|
|
assert_eq!(result, "airdrop-fixture-signature-111111111111111111111111111111111111111111111111");
|
|
let request = handle.join().expect("secondary fixture server must join");
|
|
assert_eq!(transaction_request_body(request.as_str())["method"], serde_json::json!("requestAirdrop"));
|
|
let closed = unused_local_url();
|
|
let (secondary, handle) = serve_transaction_once(include_str!("../fixtures/http/send_transaction.success.json"));
|
|
let pool = transaction_pool_for_urls(&[(closed.as_str(), 10), (secondary.as_str(), 10)], std::time::Duration::from_millis(500), 1);
|
|
let result = pool
|
|
.send_transaction(&crate::HttpRoleName::new("default"), "opaque-transaction", std::option::Option::None)
|
|
.await
|
|
.expect("NotDispatched connection failure may retry sendTransaction safely");
|
|
assert_eq!(result, "send-fixture-signature-22222222222222222222222222222222222222222222222222");
|
|
let request = handle.join().expect("secondary fixture server must join");
|
|
assert_eq!(transaction_request_body(request.as_str())["method"], serde_json::json!("sendTransaction"));
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn typed_simulate_transaction_serializes_complete_config_and_preserves_rich_result() {
|
|
let address = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
|
|
let accounts = crate::SolanaSimulationAccountsConfig::new(std::option::Option::Some(crate::SolanaAccountEncoding::Base64), std::vec![address]);
|
|
let config = crate::SolanaSimulateTransactionConfig::new(
|
|
std::option::Option::Some(crate::SolanaCommitment::Confirmed),
|
|
std::option::Option::Some(crate::SolanaTransactionBinaryEncoding::Base64),
|
|
std::option::Option::Some(true),
|
|
std::option::Option::Some(false),
|
|
std::option::Option::Some(431_000_000),
|
|
std::option::Option::Some(true),
|
|
std::option::Option::Some(accounts),
|
|
);
|
|
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/simulate_transaction.success.json"));
|
|
let pool = transaction_pool_for_url(url.as_str());
|
|
let response = pool
|
|
.simulate_transaction(&crate::HttpRoleName::new("default"), "opaque-base64-transaction", std::option::Option::Some(&config))
|
|
.await
|
|
.expect("simulateTransaction rich fixture must succeed");
|
|
assert_eq!(response.context().slot(), 431_000_030);
|
|
assert_eq!(response.context().api_version(), std::option::Option::Some("4.2.1"));
|
|
assert!(response.value().err().is_null());
|
|
assert_eq!(response.value().units_consumed().value(), std::option::Option::Some(&150));
|
|
assert_eq!(response.value().loaded_accounts_data_size().value(), std::option::Option::Some(&64));
|
|
assert_eq!(response.value().fee().value(), std::option::Option::Some(&5_000));
|
|
assert_eq!(response.value().pre_balances().value(), std::option::Option::Some(&std::vec![10_000, 0]));
|
|
assert_eq!(response.value().post_balances().value(), std::option::Option::Some(&std::vec![5_000, 5_000]));
|
|
let returned_accounts = response.value().accounts().value().expect("simulation accounts must be present");
|
|
assert_eq!(returned_accounts.len(), 2);
|
|
assert!(returned_accounts[0].is_some());
|
|
assert!(returned_accounts[1].is_none());
|
|
let replacement = response.value().replacement_blockhash().value().expect("replacement blockhash must be present");
|
|
assert_eq!(replacement.blockhash(), "ComputeBudget111111111111111111111111111111");
|
|
assert_eq!(replacement.last_valid_block_height(), 430_123_999);
|
|
let request = handle.join().expect("fixture server must join");
|
|
let body = transaction_request_body(request.as_str());
|
|
assert_eq!(body["method"], serde_json::json!("simulateTransaction"));
|
|
assert_eq!(
|
|
body["params"],
|
|
serde_json::json!([
|
|
"opaque-base64-transaction",
|
|
{
|
|
"commitment":"confirmed",
|
|
"encoding":"base64",
|
|
"replaceRecentBlockhash":true,
|
|
"sigVerify":false,
|
|
"minContextSlot":431000000,
|
|
"innerInstructions":true,
|
|
"accounts":{"encoding":"base64","addresses":["11111111111111111111111111111111"]}
|
|
}
|
|
])
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn typed_simulate_transaction_supports_base58_and_omits_empty_config() {
|
|
let base58 = crate::SolanaSimulateTransactionConfig::new(
|
|
std::option::Option::None,
|
|
std::option::Option::Some(crate::SolanaTransactionBinaryEncoding::Base58),
|
|
std::option::Option::None,
|
|
std::option::Option::None,
|
|
std::option::Option::None,
|
|
std::option::Option::None,
|
|
std::option::Option::None,
|
|
);
|
|
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/simulate_transaction.partial.json"));
|
|
let pool = transaction_pool_for_url(url.as_str());
|
|
let _ = pool
|
|
.simulate_transaction(&crate::HttpRoleName::new("default"), "opaque-base58-transaction", std::option::Option::Some(&base58))
|
|
.await
|
|
.expect("simulateTransaction base58 fixture must succeed");
|
|
let request = handle.join().expect("fixture server must join");
|
|
assert_eq!(transaction_request_body(request.as_str())["params"], serde_json::json!(["opaque-base58-transaction", {"encoding":"base58"}]));
|
|
let empty = crate::SolanaSimulateTransactionConfig::default();
|
|
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/simulate_transaction.partial.json"));
|
|
let pool = transaction_pool_for_url(url.as_str());
|
|
let _ = pool
|
|
.simulate_transaction(&crate::HttpRoleName::new("default"), "opaque-default-transaction", std::option::Option::Some(&empty))
|
|
.await
|
|
.expect("simulateTransaction empty config must canonicalize to omission");
|
|
let request = handle.join().expect("fixture server must join");
|
|
assert_eq!(transaction_request_body(request.as_str())["params"], serde_json::json!(["opaque-default-transaction"]));
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn typed_simulate_transaction_supports_all_current_account_return_encodings() {
|
|
let address = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
|
|
let encodings = [crate::SolanaAccountEncoding::Base64, crate::SolanaAccountEncoding::Base64Zstd, crate::SolanaAccountEncoding::JsonParsed];
|
|
for encoding in encodings {
|
|
let accounts = crate::SolanaSimulationAccountsConfig::new(std::option::Option::Some(encoding), std::vec![address]);
|
|
let config = crate::SolanaSimulateTransactionConfig::new(
|
|
std::option::Option::None,
|
|
std::option::Option::Some(crate::SolanaTransactionBinaryEncoding::Base64),
|
|
std::option::Option::None,
|
|
std::option::Option::None,
|
|
std::option::Option::None,
|
|
std::option::Option::None,
|
|
std::option::Option::Some(accounts),
|
|
);
|
|
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/simulate_transaction.partial.json"));
|
|
let pool = transaction_pool_for_url(url.as_str());
|
|
let _ = pool
|
|
.simulate_transaction(&crate::HttpRoleName::new("default"), "opaque-transaction", std::option::Option::Some(&config))
|
|
.await
|
|
.expect("supported simulation account encoding must execute");
|
|
let request = handle.join().expect("fixture server must join");
|
|
let body = transaction_request_body(request.as_str());
|
|
assert_eq!(body["params"][1]["accounts"]["encoding"], serde_json::json!(encoding.as_str()));
|
|
}
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn typed_simulate_transaction_rejects_deterministic_invalid_configs_before_io() {
|
|
let pool = transaction_pool_for_url(unused_local_url().as_str());
|
|
let conflicting = crate::SolanaSimulateTransactionConfig::new(
|
|
std::option::Option::None,
|
|
std::option::Option::Some(crate::SolanaTransactionBinaryEncoding::Base64),
|
|
std::option::Option::Some(true),
|
|
std::option::Option::Some(true),
|
|
std::option::Option::None,
|
|
std::option::Option::None,
|
|
std::option::Option::None,
|
|
);
|
|
let result = pool.simulate_transaction(&crate::HttpRoleName::new("default"), "opaque-transaction", std::option::Option::Some(&conflicting)).await;
|
|
assert_eq!(result.expect_err("conflicting simulation flags must fail before I/O").code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS);
|
|
let address = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
|
|
let rejected_encodings = [crate::SolanaAccountEncoding::Binary, crate::SolanaAccountEncoding::Base58];
|
|
for encoding in rejected_encodings {
|
|
let accounts = crate::SolanaSimulationAccountsConfig::new(std::option::Option::Some(encoding), std::vec![address]);
|
|
let config = crate::SolanaSimulateTransactionConfig::new(
|
|
std::option::Option::None,
|
|
std::option::Option::Some(crate::SolanaTransactionBinaryEncoding::Base64),
|
|
std::option::Option::None,
|
|
std::option::Option::None,
|
|
std::option::Option::None,
|
|
std::option::Option::None,
|
|
std::option::Option::Some(accounts),
|
|
);
|
|
let result = pool.simulate_transaction(&crate::HttpRoleName::new("default"), "opaque-transaction", std::option::Option::Some(&config)).await;
|
|
assert_eq!(result.expect_err("unsupported simulation account encoding must fail before I/O").code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS);
|
|
}
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn typed_simulate_transaction_preserves_partial_null_and_omitted_result() {
|
|
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/simulate_transaction.partial.json"));
|
|
let pool = transaction_pool_for_url(url.as_str());
|
|
let response = pool
|
|
.simulate_transaction(&crate::HttpRoleName::new("default"), "opaque-transaction", std::option::Option::None)
|
|
.await
|
|
.expect("partial simulateTransaction fixture must succeed");
|
|
handle.join().expect("fixture server must join");
|
|
assert!(response.value().err().is_null());
|
|
assert!(response.value().logs().is_null());
|
|
assert!(response.value().accounts().is_null());
|
|
assert_eq!(response.value().units_consumed().value(), std::option::Option::Some(&99));
|
|
assert!(response.value().loaded_accounts_data_size().is_omitted());
|
|
assert!(response.value().return_data().is_null());
|
|
assert!(response.value().inner_instructions().is_omitted());
|
|
assert!(response.value().replacement_blockhash().is_null());
|
|
assert!(response.value().fee().is_omitted());
|
|
assert!(response.value().loaded_addresses().is_omitted());
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn typed_simulate_transaction_preserves_rpc_application_error() {
|
|
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/simulate_transaction.error.json"));
|
|
let pool = transaction_pool_for_url(url.as_str());
|
|
let result = pool.simulate_transaction(&crate::HttpRoleName::new("default"), "invalid-opaque-transaction", std::option::Option::None).await;
|
|
assert_eq!(result.expect_err("simulation RPC error must propagate").code(), crate::ERROR_CODE_RPC_APPLICATION_ERROR);
|
|
handle.join().expect("fixture server must join");
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn typed_simulate_transaction_retries_temporary_http_failure_safely() {
|
|
let (url, handle) = serve_transaction_status_then_body("503 Service Unavailable", include_str!("../fixtures/http/simulate_transaction.partial.json"));
|
|
let pool = transaction_pool_for_urls(&[(url.as_str(), 10)], std::time::Duration::from_millis(500), 1);
|
|
let response = pool
|
|
.simulate_transaction(&crate::HttpRoleName::new("default"), "opaque-retry-safe-transaction", std::option::Option::None)
|
|
.await
|
|
.expect("retry-safe simulation must recover after temporary HTTP failure");
|
|
assert_eq!(response.value().units_consumed().value(), std::option::Option::Some(&99));
|
|
let (first, second) = handle.join().expect("retry fixture server must join");
|
|
assert_eq!(transaction_request_body(first.as_str())["method"], serde_json::json!("simulateTransaction"));
|
|
assert_eq!(transaction_request_body(second.as_str())["method"], serde_json::json!("simulateTransaction"));
|
|
}
|