diff --git a/Cargo.toml b/Cargo.toml index f3b42fe..840154f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ # file: Cargo.toml -# version: 493 +# version: 494 [workspace] resolver = "3" members = ["crates/ksp-app-backfill-desk", "crates/ksp-app-config-desk", "crates/ksp-app-solprices-desk", "crates/ksp-app-store-desk", "crates/ksp-app-wallet-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-interface-lib", "crates/ksp-job-api", "crates/ksp-job-backfill-lib", "crates/ksp-logging-lib", "crates/ksp-offchain-transport-lib", "crates/ksp-onchain-transport-lib", "crates/ksp-program-api", "crates/ksp-raw-transaction-lib", "crates/ksp-store-api", "crates/ksp-store-lib", "crates/ksp-store-postgres-lib", "crates/ksp-wallet-lib", "crates/ksp-worker-api"] [workspace.package] -version = "0.3.10-pre.3" +version = "0.3.10-pre.4" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project" diff --git a/crates/ksp-job-backfill-lib/tests/http_block_material.rs b/crates/ksp-job-backfill-lib/tests/http_block_material.rs new file mode 100644 index 0000000..cbe2ca8 --- /dev/null +++ b/crates/ksp-job-backfill-lib/tests/http_block_material.rs @@ -0,0 +1,141 @@ +// file: crates/ksp-job-backfill-lib/tests/http_block_material.rs +// version: 1 + +//! Cross-layer canary for the future Worker-owned HTTP block adapter without changing production ownership. + +fn pool_for_url(url: &str) -> ksp_onchain_transport_lib::HttpTransportPool { + let role = ksp_onchain_transport_lib::HttpEndpointRoleSettings::new( + ksp_onchain_transport_lib::HttpRoleName::new("default"), + true, + std::vec![ksp_onchain_transport_lib::HttpRequestKind::wildcard()], + 10, + ksp_onchain_transport_lib::HttpRoleLimits::new( + std::option::Option::None, + std::option::Option::None, + std::option::Option::None, + std::option::Option::None, + ), + ); + let endpoint = ksp_onchain_transport_lib::HttpEndpointSettings::new( + "http-block-fixture", + true, + ksp_onchain_transport_lib::HttpProviderName::new("fixture-provider"), + ksp_onchain_transport_lib::HttpClusterName::new("devnet"), + ksp_onchain_transport_lib::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], + ); + return ksp_onchain_transport_lib::HttpTransportPool::new(ksp_onchain_transport_lib::HttpTransportSettings::new( + std::vec![endpoint], + ksp_onchain_transport_lib::HttpRetrySettings::new(0, std::time::Duration::from_millis(1), std::time::Duration::from_millis(1)), + )) + .expect("fixture pool must build"); +} + +fn serve_once(body: &'static str) -> (std::string::String, std::thread::JoinHandle<()>) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("fixture listener must bind"); + let address = listener.local_addr().expect("fixture listener address must resolve"); + let handle = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("fixture server must accept one request"); + let mut bytes = std::vec::Vec::new(); + let mut buffer = [0_u8; 1024]; + loop { + let count = std::io::Read::read(&mut stream, &mut buffer).expect("fixture request must read"); + if count == 0 { + break; + } + bytes.extend_from_slice(&buffer[..count]); + if bytes.windows(4).any(|window| return window == b"\r\n\r\n") { + break; + } + } + 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; + }); + return (format!("http://{address}"), handle); +} + +fn map_meta(field: &ksp_onchain_transport_lib::SolanaWireField) -> ksp_raw_transaction_lib::RawTransactionWireField { + return match field { + ksp_onchain_transport_lib::SolanaWireField::Omitted => ksp_raw_transaction_lib::RawTransactionWireField::Omitted, + ksp_onchain_transport_lib::SolanaWireField::Null => ksp_raw_transaction_lib::RawTransactionWireField::Null, + ksp_onchain_transport_lib::SolanaWireField::Value(value) => ksp_raw_transaction_lib::RawTransactionWireField::Value(value.clone()), + }; +} + +fn map_version( + field: &ksp_onchain_transport_lib::SolanaWireField, +) -> ksp_raw_transaction_lib::RawTransactionWireField { + return match field { + ksp_onchain_transport_lib::SolanaWireField::Omitted => ksp_raw_transaction_lib::RawTransactionWireField::Omitted, + ksp_onchain_transport_lib::SolanaWireField::Null => ksp_raw_transaction_lib::RawTransactionWireField::Null, + ksp_onchain_transport_lib::SolanaWireField::Value(ksp_onchain_transport_lib::SolanaTransactionVersion::Legacy) => { + ksp_raw_transaction_lib::RawTransactionWireField::Value(ksp_raw_transaction_lib::RawTransactionVersion::Legacy) + }, + ksp_onchain_transport_lib::SolanaWireField::Value(ksp_onchain_transport_lib::SolanaTransactionVersion::Number(number)) => { + ksp_raw_transaction_lib::RawTransactionWireField::Value(ksp_raw_transaction_lib::RawTransactionVersion::Number(*number)) + }, + }; +} + +#[tokio::test(flavor = "current_thread")] +async fn pre_004_http_observed_block_projects_each_base64_transaction_to_common_material_without_new_production_edge() { + const BODY: &str = concat!( + "{\"jsonrpc\":\"2.0\",\"result\":{\"previousBlockhash\":\"previous\",\"blockhash\":\"block\",\"parentSlot\":430000122,", + "\"rewards\":[],\"numRewardPartitions\":0,\"blockTime\":1787072400,\"blockHeight\":410000000,", + "\"transactions\":[{\"transaction\":[\"AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "AAAAAA\",\"base64\"],\"meta\":{\"err\":null,\"fee\":5000},\"version\":\"legacy\"},", + "{\"transaction\":[\"AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEA\",", + "\"base64\"],\"meta\":{\"err\":null,\"fee\":6000},\"version\":0}]},\"id\":1}", + ); + let (url, handle) = serve_once(BODY); + let pool = pool_for_url(url.as_str()); + let config = ksp_onchain_transport_lib::SolanaGetBlockConfig::new( + std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed), + std::option::Option::Some(ksp_onchain_transport_lib::SolanaTransactionEncoding::Base64), + std::option::Option::Some(ksp_onchain_transport_lib::SolanaTransactionDetails::Full), + std::option::Option::Some(0), + std::option::Option::Some(false), + ); + let observed = pool + .get_block_observed(&ksp_onchain_transport_lib::HttpRoleName::new("default"), 430_000_123, std::option::Option::Some(&config)) + .await + .expect("observed block fixture must succeed"); + assert_eq!(observed.endpoint_name(), "http-block-fixture"); + assert_eq!(observed.provider().as_str(), "fixture-provider"); + let block = observed.value().as_ref().expect("fixture block must be present"); + let transactions = block.transactions().value().expect("fixture transactions must be present"); + assert_eq!(transactions.len(), 2); + let network = ksp_store_lib::RawNetworkId::new("devnet").expect("fixture network must be valid"); + let expected_signatures = [[0_u8; 64], [1_u8; 64]]; + for (index, transaction) in transactions.iter().enumerate() { + let (data, meta, version) = match transaction.transaction() { + ksp_onchain_transport_lib::SolanaEncodedTransaction::Binary { + data, + encoding: ksp_onchain_transport_lib::SolanaTransactionBinaryEncoding::Base64, + } => (data.as_str(), map_meta(transaction.meta()), map_version(transaction.version())), + _ => panic!("fixture must remain full Base64 transaction material"), + }; + let transaction_index = u32::try_from(index).expect("fixture transaction index must fit u32"); + let material = ksp_raw_transaction_lib::RawTransactionMaterial::binary_base64_with_embedded_signature( + network.clone(), + 430_000_123, + block.block_time(), + data, + meta, + version, + ksp_raw_transaction_lib::RawTransactionWireField::Value(transaction_index), + ) + .expect("HTTP block material must map to common RAW material"); + let raw = ksp_raw_transaction_lib::canonicalize_raw_transaction(material).expect("mapped HTTP block material must canonicalize"); + assert_eq!(raw.reference().signature().as_bytes(), &expected_signatures[index]); + assert_eq!(raw.slot(), 430_000_123); + assert_eq!(raw.block_time().map(|value| return value.unix_millis()), std::option::Option::Some(1_787_072_400_000)); + assert!(raw.payload().bytes().ends_with(format!("\"transactionIndex\":{transaction_index}}}").as_bytes())); + } + handle.join().expect("fixture server must join"); + return; +} diff --git a/crates/ksp-onchain-transport-lib/USAGE.md b/crates/ksp-onchain-transport-lib/USAGE.md index 6d04f92..d679997 100644 --- a/crates/ksp-onchain-transport-lib/USAGE.md +++ b/crates/ksp-onchain-transport-lib/USAGE.md @@ -1,5 +1,5 @@ - + # Utilisation de `ksp-onchain-transport-lib` @@ -449,7 +449,9 @@ let inflation_rate = pool.get_inflation_rate(&role).await; let stake_minimum = pool.get_stake_minimum_delegation(&role, Some(&context)).await; ``` -`getBlock` possède également une forme bare-encoding legacy séparée et deprecated. Les valeurs Economics restent celles du runtime : le consumer ne doit pas supposer localement un taux d'inflation ou un minimum de délégation constant. +`getBlock` possède également une forme bare-encoding legacy séparée et deprecated. Pour les consumers qui doivent conserver la provenance exacte d’un pool multi-endpoint, `get_block_observed(...)` exécute la même forme moderne et retourne la valeur typée avec le nom sûr de l’endpoint et le provider réellement gagnants après sélection/retry/reroute. Cette projection n’expose ni URL, ni headers, ni body HTTP brut. Une réponse RPC `null` reste `None` dans la valeur observée. + +Lorsque `transactionDetails = full` et `encoding = base64`, chaque `SolanaBlockTransaction` conserve le transaction wire Base64, `meta` et `version`. Le consumer peut alors projeter ces DTOs vers sa couche métier sans faire dépendre Transport d’une canonicalisation RAW particulière. Les valeurs Economics restent celles du runtime : le consumer ne doit pas supposer localement un taux d'inflation ou un minimum de délégation constant. ## 6. Exécution JSON-RPC standard générique diff --git a/crates/ksp-onchain-transport-lib/fixtures/http/get_block.observed_material.success.json b/crates/ksp-onchain-transport-lib/fixtures/http/get_block.observed_material.success.json new file mode 100644 index 0000000..2f559b9 --- /dev/null +++ b/crates/ksp-onchain-transport-lib/fixtures/http/get_block.observed_material.success.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","result":{"previousBlockhash":"previous-blockhash-fixture","blockhash":"blockhash-fixture","parentSlot":430000122,"rewards":[],"numRewardPartitions":0,"blockTime":1787072400,"blockHeight":410000000,"transactions":[{"transaction":["AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","base64"],"meta":{"err":null,"fee":5000},"version":"legacy"}]},"id":1} diff --git a/crates/ksp-onchain-transport-lib/src/rpc_blocks.rs b/crates/ksp-onchain-transport-lib/src/rpc_blocks.rs index b5dbd5d..a069e83 100644 --- a/crates/ksp-onchain-transport-lib/src/rpc_blocks.rs +++ b/crates/ksp-onchain-transport-lib/src/rpc_blocks.rs @@ -1,5 +1,5 @@ // file: crates/ksp-onchain-transport-lib/src/rpc_blocks.rs -// version: 7 +// version: 8 const MAX_GET_BLOCKS_RANGE: u64 = 500_000; const MAX_GET_RECENT_PERFORMANCE_SAMPLES: u64 = 720; @@ -591,25 +591,42 @@ impl crate::HttpTransportPool { slot: u64, config: std::option::Option<&crate::SolanaGetBlockConfig>, ) -> ksp_core_lib::Result> { - if let std::option::Option::Some(config) = config - && config.commitment() == std::option::Option::Some(crate::SolanaCommitment::Processed) - { - return std::result::Result::Err( - ksp_core_lib::Error::new( - crate::ERROR_CODE_INVALID_RPC_PARAMETERS, - "getBlock commitment must be confirmed or finalized when explicitly provided", - ) - .with_context("rpc_method", "getBlock") - .with_context("commitment", "processed"), - ); - } - let mut params = std::vec![serde_json::json!(slot)]; - if let std::option::Option::Some(config) = config { - params.push((*config).to_json_value()); - } + let params = get_block_params(slot, config); + let params = match params { + std::result::Result::Ok(params) => params, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; return self.execute_get_block(role, params).await; } + /// Executes the current object-form `getBlock` request and reports the safe identity of the endpoint that produced the successful response. + /// + /// Routing, admission, timeout and retry behavior are identical to [`Self::get_block`]. The returned observation never contains an endpoint URL, + /// HTTP headers or a raw HTTP body. + pub async fn get_block_observed( + &self, + role: &crate::HttpRoleName, + slot: u64, + config: std::option::Option<&crate::SolanaGetBlockConfig>, + ) -> ksp_core_lib::Result>> { + let params = get_block_params(slot, config); + let params = match params { + std::result::Result::Ok(params) => params, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let observed = self.execute_blocks_rpc_observed("getBlock", role, params).await; + let observed = match observed { + std::result::Result::Ok(observed) => observed, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let (value, endpoint_name, provider) = observed.into_parts(); + let block = decode_get_block(value); + return match block { + std::result::Result::Ok(block) => std::result::Result::Ok(crate::HttpObservedValue::new(block, endpoint_name, provider)), + std::result::Result::Err(error) => std::result::Result::Err(error), + }; + } + /// Executes the deprecated bare-encoding `getBlock` request form retained by Solana RPC for backwards compatibility. #[deprecated(note = "use HttpTransportPool::get_block with SolanaGetBlockConfig; the bare encoding request form is deprecated")] pub async fn get_block_legacy( @@ -635,16 +652,8 @@ impl crate::HttpTransportPool { params: std::vec::Vec, ) -> ksp_core_lib::Result> { let value = self.execute_blocks_rpc("getBlock", role, params).await; - let value = match value { - std::result::Result::Ok(value) => value, - std::result::Result::Err(error) => return std::result::Result::Err(error), - }; - if value.is_null() { - return std::result::Result::Ok(std::option::Option::None); - } - let block = crate::SolanaConfirmedBlock::decode_wire("getBlock", value); - return match block { - std::result::Result::Ok(block) => std::result::Result::Ok(std::option::Option::Some(block)), + return match value { + std::result::Result::Ok(value) => decode_get_block(value), std::result::Result::Err(error) => std::result::Result::Err(error), }; } @@ -829,6 +838,48 @@ impl crate::HttpTransportPool { }; return self.execute_standard_rpc(role, method, params).await; } + + async fn execute_blocks_rpc_observed( + &self, + method_name: &'static str, + role: &crate::HttpRoleName, + params: std::vec::Vec, + ) -> ksp_core_lib::Result> { + let method = blocks_descriptor(method_name); + let method = match method { + std::result::Result::Ok(method) => method, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return self.execute_standard_rpc_observed(role, method, params).await; + } +} + +fn decode_get_block(value: serde_json::Value) -> ksp_core_lib::Result> { + if value.is_null() { + return std::result::Result::Ok(std::option::Option::None); + } + let block = crate::SolanaConfirmedBlock::decode_wire("getBlock", value); + return match block { + std::result::Result::Ok(block) => std::result::Result::Ok(std::option::Option::Some(block)), + std::result::Result::Err(error) => std::result::Result::Err(error), + }; +} + +fn get_block_params(slot: u64, config: std::option::Option<&crate::SolanaGetBlockConfig>) -> ksp_core_lib::Result> { + if let std::option::Option::Some(config) = config + && config.commitment() == std::option::Option::Some(crate::SolanaCommitment::Processed) + { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RPC_PARAMETERS, "getBlock commitment must be confirmed or finalized when explicitly provided") + .with_context("rpc_method", "getBlock") + .with_context("commitment", "processed"), + ); + } + let mut params = std::vec![serde_json::json!(slot)]; + if let std::option::Option::Some(config) = config { + params.push((*config).to_json_value()); + } + return std::result::Result::Ok(params); } #[derive(serde::Deserialize)] diff --git a/crates/ksp-onchain-transport-lib/tests/public_api.rs b/crates/ksp-onchain-transport-lib/tests/public_api.rs index b8cdc1e..aa721fe 100644 --- a/crates/ksp-onchain-transport-lib/tests/public_api.rs +++ b/crates/ksp-onchain-transport-lib/tests/public_api.rs @@ -1,5 +1,5 @@ // file: crates/ksp-onchain-transport-lib/tests/public_api.rs -// version: 49 +// version: 50 //! Integration tests for the public `ksp-onchain-transport-lib` consumer contract. @@ -1021,3 +1021,10 @@ fn public_v0_2_9_pre_010_yellowstone_reconnect_snapshot_is_available_from_crate_ let _observed = ksp_onchain_transport_lib::YellowstoneGrpcSubscribeSnapshot::last_observed_slot; let _terminal = ksp_onchain_transport_lib::YellowstoneGrpcSubscribeSnapshot::terminal_error_code; } + +#[test] +fn public_v0_3_10_pre_004_observed_get_block_surface_is_available_from_crate_root() { + let method = ksp_onchain_transport_lib::HttpTransportPool::get_block_observed; + let _ = method; + return; +} diff --git a/crates/ksp-onchain-transport-lib/unit_tests/rpc_blocks.rs b/crates/ksp-onchain-transport-lib/unit_tests/rpc_blocks.rs index e8071f1..f017502 100644 --- a/crates/ksp-onchain-transport-lib/unit_tests/rpc_blocks.rs +++ b/crates/ksp-onchain-transport-lib/unit_tests/rpc_blocks.rs @@ -1,5 +1,5 @@ // file: crates/ksp-onchain-transport-lib/unit_tests/rpc_blocks.rs -// version: 6 +// version: 7 #[test] fn transaction_details_and_get_block_config_preserve_all_modern_options() { @@ -188,6 +188,35 @@ fn serve_once(body: &'static str) -> (std::string::String, std::thread::JoinHand return (format!("http://{address}"), handle); } +fn serve_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_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_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 read_request(stream: &mut std::net::TcpStream) -> std::string::String { let mut bytes = std::vec::Vec::new(); let mut buffer = [0_u8; 1024]; @@ -784,3 +813,92 @@ async fn typed_get_block_rejects_processed_commitment_before_io() { assert_eq!(error.context()[1].key(), "commitment"); assert_eq!(error.context()[1].value(), "processed"); } + +#[tokio::test(flavor = "current_thread")] +async fn pre_004_get_block_observed_reports_actual_winner_after_retry_reroute() { + let (first_url, first_handle) = serve_status_and_count("429 Too Many Requests"); + let (winner_url, winner_handle) = serve_once(include_str!("../fixtures/http/get_block.observed_material.success.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::SolanaGetBlockConfig::new( + std::option::Option::Some(crate::SolanaCommitment::Confirmed), + std::option::Option::Some(crate::SolanaTransactionEncoding::Base64), + std::option::Option::Some(crate::SolanaTransactionDetails::Full), + std::option::Option::Some(0), + std::option::Option::Some(false), + ); + let observed = pool + .get_block_observed(&crate::HttpRoleName::new("default"), 430_000_123, std::option::Option::Some(&config)) + .await + .expect("retry-safe observed getBlock must succeed on the second endpoint"); + assert_eq!(observed.endpoint_name(), "winner-endpoint"); + assert_eq!(observed.provider().as_str(), "winner-provider"); + let block = observed.value().as_ref().expect("winning response must contain a block"); + let transactions = block.transactions().value().expect("winning block must contain transactions"); + assert_eq!(transactions.len(), 1); + assert!(matches!( + transactions[0].transaction(), + crate::SolanaEncodedTransaction::Binary { encoding: crate::SolanaTransactionBinaryEncoding::Base64, .. } + )); + let rendered = format!("{observed:?}"); + assert!(rendered.contains("winner-endpoint")); + assert!(rendered.contains("winner-provider")); + assert!(rendered.contains("")); + assert!(!rendered.contains("AQAAAAAAAA")); + let (first_count, first_request) = first_handle.join().expect("first fixture server must join"); + assert_eq!(first_count, 1); + assert_eq!(request_body(first_request.as_str())["method"], serde_json::json!("getBlock")); + let winner_request = winner_handle.join().expect("winner fixture server must join"); + assert_eq!(request_body(winner_request.as_str())["method"], serde_json::json!("getBlock")); + return; +} + +#[tokio::test(flavor = "current_thread")] +async fn pre_004_get_block_observed_preserves_null_and_reuses_get_block_validation() { + let (url, handle) = serve_once(include_str!("../fixtures/http/get_block.null.json")); + let pool = pool_for_url(url.as_str()); + let observed = pool + .get_block_observed(&crate::HttpRoleName::new("default"), 430_000_123, std::option::Option::None) + .await + .expect("observed null getBlock must succeed"); + assert!(observed.value().is_none()); + assert_eq!(observed.endpoint_name(), "fixture"); + assert_eq!(observed.provider().as_str(), "fixture"); + handle.join().expect("fixture server must join"); + let processed = crate::SolanaGetBlockConfig::new( + std::option::Option::Some(crate::SolanaCommitment::Processed), + std::option::Option::Some(crate::SolanaTransactionEncoding::Base64), + std::option::Option::Some(crate::SolanaTransactionDetails::Full), + std::option::Option::Some(0), + std::option::Option::Some(false), + ); + let result = pool.get_block_observed(&crate::HttpRoleName::new("default"), 1, std::option::Option::Some(&processed)).await; + let error = result.expect_err("processed observed getBlock commitment must reject before I/O"); + assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS); + return; +} diff --git a/crates/ksp-raw-transaction-lib/Cargo.toml b/crates/ksp-raw-transaction-lib/Cargo.toml index c6515cc..37cf3ed 100644 --- a/crates/ksp-raw-transaction-lib/Cargo.toml +++ b/crates/ksp-raw-transaction-lib/Cargo.toml @@ -1,5 +1,5 @@ # file: crates/ksp-raw-transaction-lib/Cargo.toml -# version: 1 +# version: 2 [package] name = "ksp-raw-transaction-lib" @@ -8,6 +8,7 @@ edition.workspace = true repository.workspace = true [dependencies] +base64.workspace = true ksp-core-lib = { path = "../ksp-core-lib" } ksp-store-api = { path = "../ksp-store-api" } serde_json = { workspace = true } diff --git a/crates/ksp-raw-transaction-lib/src/canonical.rs b/crates/ksp-raw-transaction-lib/src/canonical.rs index eed7344..614704f 100644 --- a/crates/ksp-raw-transaction-lib/src/canonical.rs +++ b/crates/ksp-raw-transaction-lib/src/canonical.rs @@ -1,5 +1,5 @@ // file: crates/ksp-raw-transaction-lib/src/canonical.rs -// version: 1 +// version: 2 use sha2::Digest; // rust-rules: trait-import @@ -75,6 +75,26 @@ impl crate::RawTransactionMaterial { version, }; } + + /// Creates complete source-neutral RAW v1 material from a Base64 transaction wire that embeds its own canonical signature array. + pub fn binary_base64_with_embedded_signature( + network: ksp_store_api::RawNetworkId, + slot: u64, + block_time: std::option::Option, + transaction_data: impl std::convert::Into, + meta: crate::RawTransactionWireField, + version: crate::RawTransactionWireField, + transaction_index: crate::RawTransactionWireField, + ) -> ksp_core_lib::Result { + let transaction_data = transaction_data.into(); + let signature = crate::extract_raw_transaction_signature_from_binary_base64(transaction_data.as_str()); + return match signature { + std::result::Result::Ok(signature) => { + std::result::Result::Ok(Self { block_time, meta, network, signature, slot, transaction_data, transaction_index, version }) + }, + std::result::Result::Err(error) => std::result::Result::Err(error), + }; + } } impl std::fmt::Debug for crate::RawTransactionMaterial { diff --git a/crates/ksp-raw-transaction-lib/src/error.rs b/crates/ksp-raw-transaction-lib/src/error.rs index 77cd0a3..e0a7d81 100644 --- a/crates/ksp-raw-transaction-lib/src/error.rs +++ b/crates/ksp-raw-transaction-lib/src/error.rs @@ -1,12 +1,12 @@ // file: crates/ksp-raw-transaction-lib/src/error.rs -// version: 1 +// version: 2 /// Error code used when RAW v1 canonical payload construction cannot preserve the frozen format contract. pub const ERROR_CODE_RAW_TRANSACTION_CANONICALIZATION_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("raw_transaction", "canonicalization_invalid"); /// Error code used when source-neutral RAW transaction material violates a bounded semantic invariant. pub const ERROR_CODE_RAW_TRANSACTION_MATERIAL_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("raw_transaction", "material_invalid"); -/// Error code used when textual Solana transaction signature material cannot decode to exactly 64 canonical bytes. +/// Error code used when Solana transaction signature material cannot resolve to exactly 64 canonical bytes. pub const ERROR_CODE_RAW_TRANSACTION_SIGNATURE_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("raw_transaction", "signature_invalid"); /// Creates a safe canonicalization error without copying source payload material. @@ -21,7 +21,7 @@ pub(crate) fn material_error(field: &'static str) -> ksp_core_lib::Error { .with_context("field", field); } -/// Creates a safe signature error without copying textual signature material. +/// Creates a safe signature error without copying source signature or transaction material. pub(crate) fn signature_error() -> ksp_core_lib::Error { return ksp_core_lib::Error::new(crate::ERROR_CODE_RAW_TRANSACTION_SIGNATURE_INVALID, "invalid canonical Solana transaction signature"); } diff --git a/crates/ksp-raw-transaction-lib/src/lib.rs b/crates/ksp-raw-transaction-lib/src/lib.rs index b90e2a9..c68b1fd 100644 --- a/crates/ksp-raw-transaction-lib/src/lib.rs +++ b/crates/ksp-raw-transaction-lib/src/lib.rs @@ -1,5 +1,5 @@ // file: crates/ksp-raw-transaction-lib/src/lib.rs -// version: 1 +// version: 2 #![warn(missing_docs)] #![deny(unreachable_pub)] @@ -37,12 +37,14 @@ pub use self::canonical::canonicalize_raw_transaction; pub use self::error::ERROR_CODE_RAW_TRANSACTION_CANONICALIZATION_INVALID; /// Error code used when source-neutral RAW transaction material violates a bounded semantic invariant. pub use self::error::ERROR_CODE_RAW_TRANSACTION_MATERIAL_INVALID; -/// Error code used when textual Solana transaction signature material cannot decode to exactly 64 canonical bytes. +/// Error code used when Solana transaction signature material cannot resolve to exactly 64 canonical bytes. pub use self::error::ERROR_CODE_RAW_TRANSACTION_SIGNATURE_INVALID; /// Maximum UTF-8 byte length admitted for one textual Base58 Solana transaction signature. pub use self::signature::MAX_RAW_TRANSACTION_SIGNATURE_TEXT_BYTES; /// Minimum UTF-8 byte length admitted for one textual Base58 Solana transaction signature. pub use self::signature::MIN_RAW_TRANSACTION_SIGNATURE_TEXT_BYTES; +/// Extracts the first canonical 64-byte Solana signature from one complete Base64 transaction wire. +pub use self::signature::extract_raw_transaction_signature_from_binary_base64; /// Parses one bounded Base58 Solana transaction signature to exactly 64 canonical bytes. pub use self::signature::parse_raw_transaction_signature; diff --git a/crates/ksp-raw-transaction-lib/src/signature.rs b/crates/ksp-raw-transaction-lib/src/signature.rs index db4b5b0..5bb3089 100644 --- a/crates/ksp-raw-transaction-lib/src/signature.rs +++ b/crates/ksp-raw-transaction-lib/src/signature.rs @@ -1,5 +1,7 @@ // file: crates/ksp-raw-transaction-lib/src/signature.rs -// version: 1 +// version: 2 + +use base64::Engine; // rust-rules: trait-import /// Maximum UTF-8 byte length admitted for one textual Base58 Solana transaction signature. pub const MAX_RAW_TRANSACTION_SIGNATURE_TEXT_BYTES: usize = 88; @@ -50,6 +52,80 @@ pub fn parse_raw_transaction_signature(value: &str) -> ksp_core_lib::Result ksp_core_lib::Result { + if value.is_empty() || value.len() > ksp_store_api::MAX_RAW_PAYLOAD_BYTES { + return std::result::Result::Err(crate::signature_error()); + } + let decoded = base64::engine::general_purpose::STANDARD.decode(value); + let decoded = match decoded { + std::result::Result::Ok(decoded) => decoded, + std::result::Result::Err(_) => return std::result::Result::Err(crate::signature_error()), + }; + if base64::engine::general_purpose::STANDARD.encode(decoded.as_slice()) != value { + return std::result::Result::Err(crate::signature_error()); + } + let prefix = decode_signature_count(decoded.as_slice()); + let (signature_count, prefix_len) = match prefix { + std::result::Result::Ok(prefix) => prefix, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + if signature_count == 0 { + return std::result::Result::Err(crate::signature_error()); + } + let signatures_len = match signature_count.checked_mul(64) { + std::option::Option::Some(length) => length, + std::option::Option::None => return std::result::Result::Err(crate::signature_error()), + }; + let message_offset = match prefix_len.checked_add(signatures_len) { + std::option::Option::Some(offset) => offset, + std::option::Option::None => return std::result::Result::Err(crate::signature_error()), + }; + if message_offset >= decoded.len() { + return std::result::Result::Err(crate::signature_error()); + } + let first_end = match prefix_len.checked_add(64) { + std::option::Option::Some(end) => end, + std::option::Option::None => return std::result::Result::Err(crate::signature_error()), + }; + let first = match decoded.get(prefix_len..first_end) { + std::option::Option::Some(first) => first, + std::option::Option::None => return std::result::Result::Err(crate::signature_error()), + }; + let mut signature = [0_u8; 64]; + signature.copy_from_slice(first); + return std::result::Result::Ok(ksp_store_api::RawTransactionSignature::new(signature)); +} + +fn decode_signature_count(bytes: &[u8]) -> ksp_core_lib::Result<(usize, usize)> { + let mut value = 0_usize; + for index in 0..3_usize { + let byte = match bytes.get(index) { + std::option::Option::Some(byte) => *byte, + std::option::Option::None => return std::result::Result::Err(crate::signature_error()), + }; + let payload = usize::from(byte & 0x7f); + let shift = index * 7; + value |= payload << shift; + if byte & 0x80 == 0 { + if index != 0 { + let minimum = 1_usize << shift; + if value < minimum { + return std::result::Result::Err(crate::signature_error()); + } + } + if value > usize::from(u16::MAX) { + return std::result::Result::Err(crate::signature_error()); + } + return std::result::Result::Ok((value, index + 1)); + } + } + return std::result::Result::Err(crate::signature_error()); +} + fn base58_digit(byte: u8) -> std::option::Option { return match byte { b'1'..=b'9' => std::option::Option::Some(byte - b'1'), diff --git a/crates/ksp-raw-transaction-lib/tests/dependency_boundary.rs b/crates/ksp-raw-transaction-lib/tests/dependency_boundary.rs index 29cbd3a..78126aa 100644 --- a/crates/ksp-raw-transaction-lib/tests/dependency_boundary.rs +++ b/crates/ksp-raw-transaction-lib/tests/dependency_boundary.rs @@ -1,5 +1,5 @@ // file: crates/ksp-raw-transaction-lib/tests/dependency_boundary.rs -// version: 2 +// version: 3 //! Dependency-boundary canaries for the common RAW Transaction foundation. @@ -18,8 +18,9 @@ fn production_sources() -> std::string::String { } #[test] -fn pre_002_manifest_uses_api_models_and_exact_common_dependencies() { +fn pre_004_manifest_adds_only_base64_to_the_exact_common_dependency_surface() { let manifest = manifest(); + assert!(manifest.contains("base64.workspace = true")); assert!(manifest.contains("ksp-core-lib = { path = \"../ksp-core-lib\" }")); assert!(manifest.contains("ksp-store-api = { path = \"../ksp-store-api\" }")); assert!(manifest.contains("serde_json = { workspace = true }")); diff --git a/crates/ksp-raw-transaction-lib/tests/public_api.rs b/crates/ksp-raw-transaction-lib/tests/public_api.rs index 99e6135..c04fa80 100644 --- a/crates/ksp-raw-transaction-lib/tests/public_api.rs +++ b/crates/ksp-raw-transaction-lib/tests/public_api.rs @@ -1,5 +1,5 @@ // file: crates/ksp-raw-transaction-lib/tests/public_api.rs -// version: 2 +// version: 3 //! Integration canaries for the public common RAW Transaction crate-root surface. @@ -77,3 +77,25 @@ fn pre_002_acquisition_assembly_accepts_store_api_producer_owned_metadata() { assert_eq!(acquisition.transaction().reference(), acquisition.observation().transaction()); return; } + +#[test] +fn pre_004_embedded_signature_and_block_material_contract_are_available_from_crate_root() { + let encoded = "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + let signature = ksp_raw_transaction_lib::extract_raw_transaction_signature_from_binary_base64(encoded); + assert!(signature.is_ok()); + let network = ksp_store_api::RawNetworkId::new("devnet"); + assert!(network.is_ok()); + if let std::result::Result::Ok(network) = network { + let material = ksp_raw_transaction_lib::RawTransactionMaterial::binary_base64_with_embedded_signature( + network, + 1, + std::option::Option::None, + encoded, + ksp_raw_transaction_lib::RawTransactionWireField::Omitted, + ksp_raw_transaction_lib::RawTransactionWireField::Omitted, + ksp_raw_transaction_lib::RawTransactionWireField::Value(0), + ); + assert!(material.is_ok()); + } + return; +} diff --git a/crates/ksp-raw-transaction-lib/unit_tests/canonical.rs b/crates/ksp-raw-transaction-lib/unit_tests/canonical.rs index 9aef4dc..d0d9fa9 100644 --- a/crates/ksp-raw-transaction-lib/unit_tests/canonical.rs +++ b/crates/ksp-raw-transaction-lib/unit_tests/canonical.rs @@ -1,5 +1,5 @@ // file: crates/ksp-raw-transaction-lib/unit_tests/canonical.rs -// version: 1 +// version: 2 fn network() -> std::option::Option { return match ksp_store_api::RawNetworkId::new("devnet") { @@ -217,3 +217,31 @@ fn pre_002_material_and_wire_debug_do_not_render_transaction_or_meta_material() assert_eq!(std::format!("{:?}", crate::RawTransactionWireField::Value(secret_meta)), "Value(..)"); return; } + +#[test] +fn pre_004_embedded_signature_material_preserves_block_fields_and_canonicalizes() { + let network = match ksp_store_api::RawNetworkId::new("devnet") { + std::result::Result::Ok(network) => network, + std::result::Result::Err(_) => return, + }; + let material = crate::RawTransactionMaterial::binary_base64_with_embedded_signature( + network, + 430_000_123, + std::option::Option::Some(1_787_072_400), + "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + crate::RawTransactionWireField::Value(serde_json::json!({"err":null,"fee":5000})), + crate::RawTransactionWireField::Value(crate::RawTransactionVersion::Legacy), + crate::RawTransactionWireField::Value(0_u32), + ); + assert!(material.is_ok()); + let transaction = match material.and_then(crate::canonicalize_raw_transaction) { + std::result::Result::Ok(transaction) => transaction, + std::result::Result::Err(_) => return, + }; + assert_eq!(transaction.reference().signature().as_bytes(), &[0_u8; 64]); + assert_eq!(transaction.slot(), 430_000_123); + assert_eq!(transaction.block_time().map(|value| return value.unix_millis()), std::option::Option::Some(1_787_072_400_000)); + assert!(transaction.payload().bytes().starts_with(b"{\"transaction\":[\"AQAAAA")); + assert!(transaction.payload().bytes().ends_with(b"\"version\":\"legacy\",\"transactionIndex\":0}")); + return; +} diff --git a/crates/ksp-raw-transaction-lib/unit_tests/signature.rs b/crates/ksp-raw-transaction-lib/unit_tests/signature.rs index 38faa79..d990416 100644 --- a/crates/ksp-raw-transaction-lib/unit_tests/signature.rs +++ b/crates/ksp-raw-transaction-lib/unit_tests/signature.rs @@ -1,5 +1,5 @@ // file: crates/ksp-raw-transaction-lib/unit_tests/signature.rs -// version: 1 +// version: 2 #[test] fn pre_002_signature_parser_accepts_exact_sixty_four_zero_bytes() { @@ -31,3 +31,32 @@ fn pre_002_signature_parser_rejects_text_bounds_invalid_base58_and_noncanonical_ } return; } + +#[test] +fn pre_004_embedded_signature_extracts_first_signature_from_canonical_transaction_base64() { + let encoded = "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + let signature = crate::extract_raw_transaction_signature_from_binary_base64(encoded); + assert!(signature.is_ok()); + if let std::result::Result::Ok(signature) = signature { + assert_eq!(signature.as_bytes(), &[0_u8; 64]); + } + return; +} + +#[test] +fn pre_004_embedded_signature_rejects_invalid_base64_noncanonical_short_vec_and_truncation() { + for encoded in [ + "not-base64", + "gQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + ] { + let result = crate::extract_raw_transaction_signature_from_binary_base64(encoded); + assert!(result.is_err()); + if let std::result::Result::Err(error) = result { + assert_eq!(error.code(), crate::ERROR_CODE_RAW_TRANSACTION_SIGNATURE_INVALID); + assert!(!error.to_string().contains(encoded)); + } + } + return; +} diff --git a/deltas/0.3.10/pre.004.md b/deltas/0.3.10/pre.004.md new file mode 100644 index 0000000..fc03f2d --- /dev/null +++ b/deltas/0.3.10/pre.004.md @@ -0,0 +1,279 @@ + + + +# Delta `0.3.10-pre.004` — HTTP observed block + matériau RAW block + +## Base requise + +```text +0.3.10-pre.003 +workspace.package.version = 0.3.10-pre.3 +``` + +Le gate opérateur communiqué le 2026-09-06 ferme intégralement `pre.003` : audits statiques propres, `cargo check --workspace` PASS, Clippy workspace `--all-targets --all-features -- -D warnings` PASS, tests de `ksp-raw-transaction-lib` PASS, tests de `ksp-job-backfill-lib` PASS avec 51 tests unitaires et 20 tests d’intégration, arbres normal/features conformes. + +## Objectif + +Fermer `TR-B` et la preuve HTTP block de `TR-C` sans ouvrir le Worker : + +```text +getBlock pool HTTP + -> valeur observée + provider/endpoint winner + -> SolanaConfirmedBlock + -> N SolanaBlockTransaction full Base64 + -> matériau RAW source-neutral +``` + +La tranche ne crée aucun adapter productif Transport -> common. Conformément à `TR-C2`, cet adapter reste producer-owned et sera matérialisé dans le futur `ksp-worker-raw-transaction-ingest-lib`. + +## Version + +```text +workspace.package.version = 0.3.10-pre.4 +``` + +Le header du `Cargo.toml` racine passe de `493` à `494`. Le nombre de membres workspace reste `20`. + +## `get_block_observed` + +`ksp-onchain-transport-lib` ajoute : + +```text +HttpTransportPool::get_block_observed(...) +``` + +Le wrapper : + +```text +réutilise exactement la validation/config moderne de get_block +utilise le routing/admission/timeout/retry standard du pool +retourne HttpObservedValue> +conserve endpoint_name + provider du winner réel après retry/reroute +préserve result = null comme None +n’expose ni URL, ni headers, ni raw HTTP body +réutilise le décodage typé SolanaConfirmedBlock +``` + +`get_block(...)` et `get_block_observed(...)` partagent désormais le même helper privé de paramètres et la même validation du commitment `processed`. + +## Provenance winner déterministe + +Le canari Transport configure deux endpoints : + +```text +endpoint 1 -> HTTP 429 +endpoint 2 -> getBlock success +``` + +La valeur observée doit reporter exclusivement : + +```text +endpoint_name = winner-endpoint +provider = winner-provider +``` + +Le Debug conserve uniquement la projection sûre et ne rend pas le transaction wire Base64. + +## Matériau Base64 source-neutral + +`ksp-raw-transaction-lib` ajoute `base64` à son graphe common et expose : + +```text +extract_raw_transaction_signature_from_binary_base64(...) +RawTransactionMaterial::binary_base64_with_embedded_signature(...) +``` + +L’extracteur lit directement le wire transaction Solana Base64 sans dépendance à une crate transaction runtime : + +```text +borne la représentation textuelle avant décodage +exige Base64 STANDARD canonique +lit un short_vec de signature count borné et canonique +refuse count = 0 +refuse short_vec tronqué/non canonique +refuse tableau de signatures tronqué +exige au moins un byte de message après les signatures déclarées +retourne uniquement la première signature exacte de 64 octets +ne copie jamais le transaction wire dans erreur/Debug +``` + +Le constructeur common réutilise cette signature puis conserve les dimensions RAW v1 existantes : + +```text +network +slot +block_time +transaction Base64 +meta omitted/null/value +version omitted/null/legacy/number +transaction_index +``` + +Le format canonique reste strictement `ksp.solana.raw_transaction` v1. + +## Preuve cross-layer sans nouveau couplage de production + +Le nouveau canari test-only : + +```text +crates/ksp-job-backfill-lib/tests/http_block_material.rs +``` + +utilise une crate qui dépend déjà de Transport et de common pour prouver la composition future sans créer d’edge architectural supplémentaire : + +```text +get_block_observed + -> SolanaConfirmedBlock + -> 2 SolanaBlockTransaction distinctes + -> full Base64 + meta + version + transactionIndex 0/1 + -> RawTransactionMaterial::binary_base64_with_embedded_signature + -> canonicalize_raw_transaction +``` + +Les deux transaction wires minimales font `66` octets chacune et utilisent des signatures distinctes : + +```text +transaction 0 -> signature [0; 64] +transaction 1 -> signature [1; 64] +``` + +Le canari vérifie que ces signatures et les index 0/1 restent associés à la bonne transaction, avec le slot et le block time communs du bloc. + +Cette preuve reste strictement test-only. Aucun symbole `ksp_raw_transaction_lib` n’est ajouté aux sources de production de `ksp-onchain-transport-lib`. + +## Dépendances common + +Le graphe runtime direct de `ksp-raw-transaction-lib` devient exactement : + +```text +base64 +ksp-core-lib +ksp-store-api +serde_json +sha2 +``` + +Restent interdits : + +```text +ksp-store-lib +ksp-onchain-transport-lib +ksp-config-lib +ksp-job-api +ksp-job-backfill-lib +ksp-worker-api +tokio +futures +``` + +La règle `DEP-PIPE-006` reste donc respectée. + +## Documentation durable + +`crates/ksp-onchain-transport-lib/USAGE.md` documente la différence entre : + +```text +get_block(...) +get_block_observed(...) +``` + +sans transformer le guide en journal de prerelease. + +`docs/architecture/011-RAW_TRANSACTION_ACQUISITION.md` ferme `TR-B`, conserve `TR-C2` producer-owned et corrige le graphe common vers `ksp-store-api`. + +Le plan `031` et la validation `027` enregistrent également la fermeture opérateur de `pre.003` et le périmètre exact de `pre.004`. + +## Fichiers ajoutés + +```text +crates/ksp-job-backfill-lib/tests/http_block_material.rs +crates/ksp-onchain-transport-lib/fixtures/http/get_block.observed_material.success.json +deltas/0.3.10/pre.004.md +``` + +## Fichiers modifiés + +```text +Cargo.toml +crates/ksp-onchain-transport-lib/USAGE.md +crates/ksp-onchain-transport-lib/src/rpc_blocks.rs +crates/ksp-onchain-transport-lib/tests/public_api.rs +crates/ksp-onchain-transport-lib/unit_tests/rpc_blocks.rs +crates/ksp-raw-transaction-lib/Cargo.toml +crates/ksp-raw-transaction-lib/src/canonical.rs +crates/ksp-raw-transaction-lib/src/error.rs +crates/ksp-raw-transaction-lib/src/lib.rs +crates/ksp-raw-transaction-lib/src/signature.rs +crates/ksp-raw-transaction-lib/tests/dependency_boundary.rs +crates/ksp-raw-transaction-lib/tests/public_api.rs +crates/ksp-raw-transaction-lib/unit_tests/canonical.rs +crates/ksp-raw-transaction-lib/unit_tests/signature.rs +docs/architecture/011-RAW_TRANSACTION_ACQUISITION.md +docs/plans/031-V0_3_10_RAW_TRANSACTION_INGEST_PLAN.md +docs/validation/027-V0_3_10_RAW_TRANSACTION_INGEST.md +``` + +## Fichiers supprimés + +Aucun. + +## Validations exécutées dans l’environnement d’assemblage + +```text +python3 scripts/audit_rust_workspace_rules.py +General Rust rule audit: clean +Rust export completeness audit: 0 candidate(s) +KSP workspace Rust rule audit: clean + +python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas +Markdown table audit: clean (339 table(s), 757 file(s)) +``` + +L’environnement d’assemblage ne fournit ni `cargo`, ni `rustc`, ni `rustfmt`. + +## Gate opérateur demandé + +```bash +cargo fmt --all +python3 scripts/audit_rust_workspace_rules.py +python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas +cargo check --workspace +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo test -p ksp-raw-transaction-lib +cargo test -p ksp-onchain-transport-lib +cargo test -p ksp-job-backfill-lib --test http_block_material +cargo tree -p ksp-raw-transaction-lib --edges normal +cargo tree -p ksp-onchain-transport-lib --edges normal +``` + +## Validations non exécutées localement + +```text +cargo fmt --all +cargo check --workspace +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo test -p ksp-raw-transaction-lib +cargo test -p ksp-onchain-transport-lib +cargo test -p ksp-job-backfill-lib --test http_block_material +cargo tree -p ksp-raw-transaction-lib --edges normal +cargo tree -p ksp-onchain-transport-lib --edges normal +smokes live : non requis pour cette tranche déterministe +``` + +## Décisions prises + +```text +TR-B est fermé par get_block_observed +get_block et get_block_observed partagent validation + décodage +la common extrait la signature d’un transaction wire Base64 complet +aucune dépendance Solana transaction runtime n’est ajoutée à la common +aucun adapter productif Transport -> common dans Transport +TR-C2 reste Worker-owned +la preuve block -> common est cross-layer et test-only +RAW v1 reste inchangé +aucun Worker live n’est ouvert dans pre.004 +``` + +## Questions ouvertes + +Aucune question ne bloque le gate de `pre.004`. `pre.005` reste dédiée à la parité WS `blockSubscribe` + Helius `transactionSubscribe`, avec golden parity ou fallback hydration explicitement qualifié selon la représentation réellement disponible. diff --git a/docs/architecture/011-RAW_TRANSACTION_ACQUISITION.md b/docs/architecture/011-RAW_TRANSACTION_ACQUISITION.md index 6f37df1..fcba4af 100644 --- a/docs/architecture/011-RAW_TRANSACTION_ACQUISITION.md +++ b/docs/architecture/011-RAW_TRANSACTION_ACQUISITION.md @@ -1,5 +1,5 @@ - + # Acquisition et alimentation `RawTransaction` @@ -539,13 +539,13 @@ KSP possède déjà : get_signatures_for_address get_transaction / get_transaction_observed get_blocks / get_blocks_with_limit -get_block +get_block / get_block_observed get_slot get_first_available_block minimum_ledger_slot ``` -Gap principal : `getBlock` n'a pas encore d'équivalent `get_block_observed`. Pour un pool multi-endpoint, la provenance provider/endpoint exacte doit être conservée avant de persister une observation. +`get_block_observed` conserve désormais la provenance sûre provider/endpoint du winner réel d’un pool HTTP, symétriquement à `get_transaction_observed`. La projection d’une transaction full issue d’un bloc vers le matériau RAW commun reste volontairement hors de Transport : l’adapter productif appartient au producer (Worker/Job) afin de ne créer aucun edge `Transport -> ksp-raw-transaction-lib`. ### 12.2 Transport WebSocket @@ -615,7 +615,7 @@ Les prix/tiers d'audit ne doivent pas devenir une politique runtime. La canonicalisation RAW v1 actuellement prouvée dans `ksp-job-backfill-lib::conversion` ne doit ni y rester enfermée ni être copiée dans le Worker. -Le handoff retient une petite crate source-neutral commune, à matérialiser pendant `0.3.10` : +Le handoff retient la crate source-neutral commune désormais matérialisée : ```text ksp-raw-transaction-lib @@ -650,13 +650,16 @@ backend Store physique Graphe conceptuel : ```text -ksp-job-backfill-lib --------------------> ksp-raw-transaction-lib ----> Store façade -ksp-worker-raw-transaction-ingest-lib ---> ksp-raw-transaction-lib ----> Store façade +ksp-job-backfill-lib --------------------> ksp-raw-transaction-lib ----> ksp-store-api +ksp-worker-raw-transaction-ingest-lib ---> ksp-raw-transaction-lib ----> ksp-store-api +ksp-job-backfill-lib --------------------> ksp-store-lib +ksp-worker-raw-transaction-ingest-lib ---> ksp-store-lib ksp-job-backfill-lib --------------------> ksp-onchain-transport-lib ksp-worker-raw-transaction-ingest-lib ---> ksp-onchain-transport-lib aucun edge Job <-> Worker +aucun edge Transport <-> ksp-raw-transaction-lib ``` La migration doit préserver exactement les golden bytes/hash RAW v1 déjà prouvés. Aucun RAW v2 n'est justifié. @@ -709,7 +712,7 @@ sources EARLY via adapter extensible | ID | Adaptation | Motif | |--------|-----------------------------------------------------------------------------------|------------------------------------------------------| -| `TR-B` | `get_block_observed` symétrique de `get_transaction_observed` | provenance exacte en pool HTTP | +| `TR-B` | fermé : `get_block_observed` symétrique de `get_transaction_observed` | provenance exacte en pool HTTP | | `TR-C` | projection source-neutral des transactions full WS/Yellowstone | éviter plusieurs canonicalizers filaires | | `TR-D` | métadonnées sûres d'acquisition live au moment de la conversion | observation uniforme | | `TR-E` | conserver/exploiter `from_slot`, replay info et snapshots de continuité existants | ne pas créer un second moteur Yellowstone | diff --git a/docs/plans/031-V0_3_10_RAW_TRANSACTION_INGEST_PLAN.md b/docs/plans/031-V0_3_10_RAW_TRANSACTION_INGEST_PLAN.md index 8318957..5261a84 100644 --- a/docs/plans/031-V0_3_10_RAW_TRANSACTION_INGEST_PLAN.md +++ b/docs/plans/031-V0_3_10_RAW_TRANSACTION_INGEST_PLAN.md @@ -1,5 +1,5 @@ - + # Plan v0.3.10 — RAW Transaction commune + Worker d’ingestion multi-source @@ -1050,9 +1050,15 @@ La canonicalisation privée du Backfill est supprimée au profit de `ksp-raw-tra Les invariants historiques restent gelés : `ksp.solana.raw_transaction` v1, golden `112` octets, SHA-256 `220792d2b15d262fda242cb220774ee9ddeffebf04dcfadabcf8ef76a9b1a7c3`, origin `Backfill`, provider/endpoint/commitment/capture-session, observation key de campagne et sémantique `Missing`. Les erreurs common sont remappées sur `ERROR_CODE_BACKFILL_RAW_CONVERSION_INVALID` afin de ne pas modifier le contrat externe du Job. +Le rejeu opérateur du 2026-09-06 ferme `pre.003` : audits statiques propres, `cargo check --workspace` PASS, Clippy strict PASS, `cargo test -p ksp-raw-transaction-lib` PASS, `cargo test -p ksp-job-backfill-lib` PASS avec 51 tests unitaires et 20 tests d’intégration, et arbres normal/features conformes. + ### `pre.004` — HTTP observed block + block material -Ajouter `get_block_observed`, fixtures provenance, extraction transaction par transaction et adapters HTTP block vers matériau common. Aucun Worker live complet encore. +**Statut : réalisé.** + +`HttpTransportPool::get_block_observed(...)` complète la provenance HTTP observée sans exposer URL/header/body et conserve le provider/endpoint du winner réel après retry/reroute. La common RAW ajoute l’extraction bornée de la première signature depuis un transaction wire Base64 complet et `RawTransactionMaterial::binary_base64_with_embedded_signature(...)` pour préserver slot, block time, meta, version et transaction index. + +Conformément à `TR-C2`, aucun adapter productif Transport DTO -> `RawTransactionMaterial` n’est placé dans Transport : une preuve cross-layer test-only démontre `get_block_observed -> N SolanaBlockTransaction -> RawTransactionMaterial -> RAW v1` sur deux transactions distinctes avec index 0/1, tandis que l’adapter productif reste réservé au futur Worker. Aucun Worker live complet n’est ouvert. ### `pre.005` — parité WS/Helius full diff --git a/docs/validation/027-V0_3_10_RAW_TRANSACTION_INGEST.md b/docs/validation/027-V0_3_10_RAW_TRANSACTION_INGEST.md index 247bda1..b35ab66 100644 --- a/docs/validation/027-V0_3_10_RAW_TRANSACTION_INGEST.md +++ b/docs/validation/027-V0_3_10_RAW_TRANSACTION_INGEST.md @@ -1,5 +1,5 @@ - + # Validation v0.3.10 — RAW Transaction commune + Worker d’ingestion @@ -661,3 +661,109 @@ cargo tree -p ksp-job-backfill-lib --edges normal cargo tree -p ksp-job-backfill-lib -e features ``` +## 13. Fermeture opérateur `pre.003` et gate `pre.004` — HTTP observed block + +### 13.1 Fermeture opérateur de `pre.003` + +Le rejeu communiqué le 2026-09-06 ferme la migration Backfill : + +```text +cargo fmt --all : exécuté +General Rust rule audit: clean +Rust export completeness audit: 0 candidate(s) +KSP workspace Rust rule audit: clean +Markdown table audit: clean (339 table(s), 756 file(s)) +cargo check --workspace : PASS +cargo clippy --workspace --all-targets --all-features -- -D warnings : PASS +cargo test -p ksp-raw-transaction-lib : PASS, 9 unit + 8 intégration +cargo test -p ksp-job-backfill-lib : PASS, 51 unit + 20 intégration +cargo tree -p ksp-job-backfill-lib --edges normal : edge common présent, graphe attendu +cargo tree -p ksp-job-backfill-lib -e features : aucune feature KSP inattendue +``` + +`pre.004` peut donc être ouverte sur une base entièrement validée. + +### 13.2 Surface Transport `pre.004` + +`HttpTransportPool::get_block_observed(...)` : + +```text +réutilise exactement la validation/config moderne de get_block +utilise le même routing/admission/retry standard +retourne Option +conserve endpoint_name + provider du winner réel +préserve result = null comme None +n’expose ni URL, ni headers, ni raw HTTP body +Debug de HttpObservedValue ne rend pas le block payload +``` + +Les fixtures déterministes couvrent le retry/reroute depuis un premier endpoint `429` vers un second winner et vérifient que la provenance retournée appartient au second endpoint. + +### 13.3 Matériau common depuis un bloc Base64 + +`ksp-raw-transaction-lib` ajoute : + +```text +extract_raw_transaction_signature_from_binary_base64(...) +RawTransactionMaterial::binary_base64_with_embedded_signature(...) +``` + +L’extracteur : + +```text +borne l’entrée avant décodage +exige un Base64 STANDARD canonique +lit le short_vec Solana de signatures sans dépendance Solana transaction runtime +refuse count = 0 +refuse short_vec tronqué/non canonique +refuse tableau de signatures tronqué +exige des bytes message après le tableau déclaré +copie uniquement la première signature exacte de 64 octets +ne copie jamais le transaction wire dans erreur/Debug +``` + +Les fixtures de référence utilisent deux transactions minimales de `66` octets : prefix `1`, signatures respectives `[0; 64]` et `[1; 64]`, puis un byte de message. Les signatures extraites sont donc exactement distinctes et restent associées aux index 0 et 1 du bloc. + +### 13.4 Preuve cross-layer sans mauvais edge + +Le canari d’intégration `ksp-job-backfill-lib/tests/http_block_material.rs` utilise les deux dépendances déjà autorisées de cette crate pour prouver : + +```text +get_block_observed + -> SolanaConfirmedBlock + -> 2 SolanaBlockTransaction distinctes + -> full Base64 transaction + meta + version + index 0/1 + -> RawTransactionMaterial::binary_base64_with_embedded_signature + -> canonicalize_raw_transaction + -> signatures [0;64] / [1;64] préservées +``` + +Cette preuve est volontairement test-only. `TR-C2` reste inchangé : l’adapter productif Transport DTO -> common sera Worker-owned. Il n’existe toujours aucun edge de production `ksp-onchain-transport-lib -> ksp-raw-transaction-lib` ni l’inverse. + +### 13.5 Audits d’assemblage + +Exécuté dans l’environnement d’assemblage après fermeture du delta : + +```text +python3 scripts/audit_rust_workspace_rules.py +General Rust rule audit: clean +Rust export completeness audit: 0 candidate(s) +KSP workspace Rust rule audit: clean + +python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas +Markdown table audit: clean (339 table(s), 757 file(s)) +``` + +Les gates Cargo restent à rejouer côté opérateur dans l’environnement Rust complet : + +```bash +cargo fmt --all +cargo check --workspace +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo test -p ksp-raw-transaction-lib +cargo test -p ksp-onchain-transport-lib +cargo test -p ksp-job-backfill-lib --test http_block_material +cargo tree -p ksp-raw-transaction-lib --edges normal +cargo tree -p ksp-onchain-transport-lib --edges normal +``` +