diff --git a/Cargo.toml b/Cargo.toml index 6831ba6..ed8ba52 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ # file: Cargo.toml -# version: 490 +# version: 491 [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-store-api", "crates/ksp-store-lib", "crates/ksp-store-postgres-lib", "crates/ksp-wallet-lib", "crates/ksp-worker-api"] +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.1" +version = "0.3.10-pre.2" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project" diff --git a/crates/ksp-raw-transaction-lib/Cargo.toml b/crates/ksp-raw-transaction-lib/Cargo.toml new file mode 100644 index 0000000..c6515cc --- /dev/null +++ b/crates/ksp-raw-transaction-lib/Cargo.toml @@ -0,0 +1,17 @@ +# file: crates/ksp-raw-transaction-lib/Cargo.toml +# version: 1 + +[package] +name = "ksp-raw-transaction-lib" +version.workspace = true +edition.workspace = true +repository.workspace = true + +[dependencies] +ksp-core-lib = { path = "../ksp-core-lib" } +ksp-store-api = { path = "../ksp-store-api" } +serde_json = { workspace = true } +sha2 = { workspace = true } + +[lints] +workspace = true diff --git a/crates/ksp-raw-transaction-lib/src/acquisition.rs b/crates/ksp-raw-transaction-lib/src/acquisition.rs new file mode 100644 index 0000000..1b3b2fb --- /dev/null +++ b/crates/ksp-raw-transaction-lib/src/acquisition.rs @@ -0,0 +1,49 @@ +// file: crates/ksp-raw-transaction-lib/src/acquisition.rs +// version: 1 + +/// Complete source-neutral RAW transaction acquisition containing one canonical entity and one producer-owned observation. +pub struct RawTransactionAcquisition { + observation: ksp_store_api::RawTransactionObservation, + transaction: ksp_store_api::RawTransaction, +} + +impl crate::RawTransactionAcquisition { + /// Returns the producer-owned acquisition observation linked to the canonical transaction. + #[must_use] + pub const fn observation(&self) -> &ksp_store_api::RawTransactionObservation { + return &self.observation; + } + + /// Returns the canonical source-independent RAW transaction. + #[must_use] + pub const fn transaction(&self) -> &ksp_store_api::RawTransaction { + return &self.transaction; + } + + /// Consumes this acquisition into its canonical transaction and observation. + #[must_use] + pub fn into_parts(self) -> (ksp_store_api::RawTransaction, ksp_store_api::RawTransactionObservation) { + return (self.transaction, self.observation); + } +} + +impl std::fmt::Debug for crate::RawTransactionAcquisition { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + return formatter.write_str("RawTransactionAcquisition(..)"); + } +} + +/// Assembles one canonical transaction with a producer-owned observation key and safe acquisition provenance. +#[must_use] +pub fn assemble_raw_transaction_acquisition( + transaction: ksp_store_api::RawTransaction, + observation_key: ksp_store_api::RawObservationKey, + provenance: ksp_store_api::RawAcquisitionProvenance, +) -> crate::RawTransactionAcquisition { + let observation = ksp_store_api::RawTransactionObservation::new(observation_key, transaction.reference().clone(), provenance); + return crate::RawTransactionAcquisition { observation, transaction }; +} + +#[cfg(test)] +#[path = "../unit_tests/acquisition.rs"] +mod tests; diff --git a/crates/ksp-raw-transaction-lib/src/canonical.rs b/crates/ksp-raw-transaction-lib/src/canonical.rs new file mode 100644 index 0000000..eed7344 --- /dev/null +++ b/crates/ksp-raw-transaction-lib/src/canonical.rs @@ -0,0 +1,305 @@ +// file: crates/ksp-raw-transaction-lib/src/canonical.rs +// version: 1 + +use sha2::Digest; // rust-rules: trait-import + +/// KSP-owned source-independent RAW transaction format identifier. +pub const RAW_TRANSACTION_FORMAT_ID: &str = "ksp.solana.raw_transaction"; +/// Frozen KSP-owned RAW transaction format version. +pub const RAW_TRANSACTION_FORMAT_VERSION: u32 = 1; + +/// Source-wire presence state preserving omitted, explicit null and concrete values independently. +#[non_exhaustive] +pub enum RawTransactionWireField { + /// The source field was omitted entirely. + Omitted, + /// The source field was explicitly present as JSON/protocol null. + Null, + /// The source field carried one concrete value. + Value(T), +} + +impl std::fmt::Debug for crate::RawTransactionWireField { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + return match self { + Self::Omitted => formatter.write_str("Omitted"), + Self::Null => formatter.write_str("Null"), + Self::Value(_) => formatter.write_str("Value(..)"), + }; + } +} + +/// Canonical Solana transaction version admitted by RAW transaction format v1. +#[non_exhaustive] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RawTransactionVersion { + /// Legacy unversioned transaction representation. + Legacy, + /// Explicit versioned transaction number admitted by the current Solana wire contract. + Number(u8), +} + +/// Complete source-neutral material required to construct one canonical RAW transaction. +pub struct RawTransactionMaterial { + block_time: std::option::Option, + meta: crate::RawTransactionWireField, + network: ksp_store_api::RawNetworkId, + signature: ksp_store_api::RawTransactionSignature, + slot: u64, + transaction_data: std::string::String, + transaction_index: crate::RawTransactionWireField, + version: crate::RawTransactionWireField, +} + +impl crate::RawTransactionMaterial { + /// Creates complete source-neutral material for the frozen RAW v1 binary Base64 body path. + #[must_use] + pub fn binary_base64( + network: ksp_store_api::RawNetworkId, + signature: ksp_store_api::RawTransactionSignature, + slot: u64, + block_time: std::option::Option, + transaction_data: impl std::convert::Into, + meta: crate::RawTransactionWireField, + version: crate::RawTransactionWireField, + transaction_index: crate::RawTransactionWireField, + ) -> Self { + return Self { + block_time, + meta, + network, + signature, + slot, + transaction_data: transaction_data.into(), + transaction_index, + version, + }; + } +} + +impl std::fmt::Debug for crate::RawTransactionMaterial { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + return formatter + .debug_struct("RawTransactionMaterial") + .field("network", &self.network) + .field("slot", &self.slot) + .field("block_time", &self.block_time) + .field("transaction_data_len", &self.transaction_data.len()) + .field("meta", &wire_field_state(&self.meta)) + .field("version", &wire_field_state(&self.version)) + .field("transaction_index", &wire_field_state(&self.transaction_index)) + .finish_non_exhaustive(); + } +} + +/// Canonicalizes complete source-neutral transaction material into the frozen KSP RAW transaction v1 representation. +pub fn canonicalize_raw_transaction(material: crate::RawTransactionMaterial) -> ksp_core_lib::Result { + let block_time = convert_block_time(material.block_time); + let block_time = match block_time { + std::result::Result::Ok(block_time) => block_time, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let bytes = canonical_payload_bytes(&material); + let bytes = match bytes { + std::result::Result::Ok(bytes) => bytes, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + if bytes.is_empty() || bytes.len() > ksp_store_api::MAX_RAW_PAYLOAD_BYTES { + return std::result::Result::Err(crate::canonicalization_error("payload.bytes")); + } + let hash: [u8; 32] = sha2::Sha256::digest(bytes.as_slice()).into(); + let format_id = ksp_store_api::RawFormatId::new(crate::RAW_TRANSACTION_FORMAT_ID); + let format_id = match format_id { + std::result::Result::Ok(format_id) => format_id, + std::result::Result::Err(_) => return std::result::Result::Err(crate::canonicalization_error("payload.format_id")), + }; + let payload = ksp_store_api::RawPayload::try_new( + format_id, + crate::RAW_TRANSACTION_FORMAT_VERSION, + bytes.into_boxed_slice(), + ksp_store_api::RawContentHash::new(hash), + ); + let payload = match payload { + std::result::Result::Ok(payload) => payload, + std::result::Result::Err(_) => return std::result::Result::Err(crate::canonicalization_error("payload")), + }; + let reference = ksp_store_api::RawTransactionReference::new(material.network, material.signature); + return std::result::Result::Ok(ksp_store_api::RawTransaction::new(reference, material.slot, block_time, payload)); +} + +fn append_canonical_json(output: &mut std::vec::Vec, value: &serde_json::Value) -> ksp_core_lib::Result<()> { + return match value { + serde_json::Value::Null => { + output.extend_from_slice(b"null"); + std::result::Result::Ok(()) + }, + serde_json::Value::Bool(value) => { + if *value { + output.extend_from_slice(b"true"); + } else { + output.extend_from_slice(b"false"); + } + std::result::Result::Ok(()) + }, + serde_json::Value::Number(value) => { + output.extend_from_slice(value.to_string().as_bytes()); + std::result::Result::Ok(()) + }, + serde_json::Value::String(value) => append_json_string(output, value.as_str()), + serde_json::Value::Array(values) => { + output.push(b'['); + for (index, item) in values.iter().enumerate() { + if index != 0 { + output.push(b','); + } + let item_result = append_canonical_json(output, item); + if let std::result::Result::Err(error) = item_result { + return std::result::Result::Err(error); + } + } + output.push(b']'); + std::result::Result::Ok(()) + }, + serde_json::Value::Object(values) => { + output.push(b'{'); + let mut keys = values.keys().collect::>(); + keys.sort_unstable(); + for (index, key) in keys.iter().enumerate() { + if index != 0 { + output.push(b','); + } + let key_result = append_json_string(output, key.as_str()); + if let std::result::Result::Err(error) = key_result { + return std::result::Result::Err(error); + } + output.push(b':'); + let item = values.get(key.as_str()); + let item = match item { + std::option::Option::Some(item) => item, + std::option::Option::None => return std::result::Result::Err(crate::canonicalization_error("payload.meta")), + }; + let item_result = append_canonical_json(output, item); + if let std::result::Result::Err(error) = item_result { + return std::result::Result::Err(error); + } + } + output.push(b'}'); + std::result::Result::Ok(()) + }, + }; +} + +fn append_json_string(output: &mut std::vec::Vec, value: &str) -> ksp_core_lib::Result<()> { + let encoded = serde_json::to_vec(value); + return match encoded { + std::result::Result::Ok(encoded) => { + output.extend_from_slice(encoded.as_slice()); + std::result::Result::Ok(()) + }, + std::result::Result::Err(_) => std::result::Result::Err(crate::canonicalization_error("payload.json")), + }; +} + +fn append_wire_value( + output: &mut std::vec::Vec, + key: &str, + field: &crate::RawTransactionWireField, + mut append_value: F, +) -> ksp_core_lib::Result<()> +where + F: FnMut(&mut std::vec::Vec, &T) -> ksp_core_lib::Result<()>, +{ + return match field { + crate::RawTransactionWireField::Omitted => std::result::Result::Ok(()), + crate::RawTransactionWireField::Null => { + output.push(b','); + let key_result = append_json_string(output, key); + if let std::result::Result::Err(error) = key_result { + return std::result::Result::Err(error); + } + output.extend_from_slice(b":null"); + std::result::Result::Ok(()) + }, + crate::RawTransactionWireField::Value(value) => { + output.push(b','); + let key_result = append_json_string(output, key); + if let std::result::Result::Err(error) = key_result { + return std::result::Result::Err(error); + } + output.push(b':'); + append_value(output, value) + }, + }; +} + +fn canonical_payload_bytes(material: &crate::RawTransactionMaterial) -> ksp_core_lib::Result> { + let mut bytes = std::vec::Vec::new(); + bytes.extend_from_slice(b"{\"transaction\":["); + let transaction_result = append_json_string(&mut bytes, material.transaction_data.as_str()); + if let std::result::Result::Err(error) = transaction_result { + return std::result::Result::Err(error); + } + bytes.push(b','); + let encoding_result = append_json_string(&mut bytes, "base64"); + if let std::result::Result::Err(error) = encoding_result { + return std::result::Result::Err(error); + } + bytes.push(b']'); + let meta_result = append_wire_value(&mut bytes, "meta", &material.meta, |output, value| return append_canonical_json(output, value)); + if let std::result::Result::Err(error) = meta_result { + return std::result::Result::Err(error); + } + let version_result = append_wire_value(&mut bytes, "version", &material.version, |output, value| { + return match value { + crate::RawTransactionVersion::Legacy => append_json_string(output, "legacy"), + crate::RawTransactionVersion::Number(number) => { + output.extend_from_slice(number.to_string().as_bytes()); + std::result::Result::Ok(()) + }, + }; + }); + if let std::result::Result::Err(error) = version_result { + return std::result::Result::Err(error); + } + let transaction_index_result = append_wire_value(&mut bytes, "transactionIndex", &material.transaction_index, |output, value| { + output.extend_from_slice(value.to_string().as_bytes()); + return std::result::Result::Ok(()); + }); + if let std::result::Result::Err(error) = transaction_index_result { + return std::result::Result::Err(error); + } + bytes.push(b'}'); + return std::result::Result::Ok(bytes); +} + +fn convert_block_time(value: std::option::Option) -> ksp_core_lib::Result> { + let seconds = match value { + std::option::Option::Some(seconds) => seconds, + std::option::Option::None => return std::result::Result::Ok(std::option::Option::None), + }; + let seconds = match u64::try_from(seconds) { + std::result::Result::Ok(seconds) => seconds, + std::result::Result::Err(_) => return std::result::Result::Err(crate::material_error("block_time")), + }; + let millis = match seconds.checked_mul(1_000) { + std::option::Option::Some(millis) => millis, + std::option::Option::None => return std::result::Result::Err(crate::material_error("block_time")), + }; + let timestamp = ksp_store_api::RawTimestamp::from_unix_millis(millis); + return match timestamp { + std::result::Result::Ok(timestamp) => std::result::Result::Ok(std::option::Option::Some(timestamp)), + std::result::Result::Err(_) => std::result::Result::Err(crate::material_error("block_time")), + }; +} + +fn wire_field_state(field: &crate::RawTransactionWireField) -> &'static str { + return match field { + crate::RawTransactionWireField::Omitted => "omitted", + crate::RawTransactionWireField::Null => "null", + crate::RawTransactionWireField::Value(_) => "value", + }; +} + +#[cfg(test)] +#[path = "../unit_tests/canonical.rs"] +mod tests; diff --git a/crates/ksp-raw-transaction-lib/src/error.rs b/crates/ksp-raw-transaction-lib/src/error.rs new file mode 100644 index 0000000..77cd0a3 --- /dev/null +++ b/crates/ksp-raw-transaction-lib/src/error.rs @@ -0,0 +1,27 @@ +// file: crates/ksp-raw-transaction-lib/src/error.rs +// version: 1 + +/// 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. +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. +pub(crate) fn canonicalization_error(field: &'static str) -> ksp_core_lib::Error { + return ksp_core_lib::Error::new(crate::ERROR_CODE_RAW_TRANSACTION_CANONICALIZATION_INVALID, "invalid canonical RAW transaction representation") + .with_context("field", field); +} + +/// Creates a safe source-material error without copying source payload material. +pub(crate) fn material_error(field: &'static str) -> ksp_core_lib::Error { + return ksp_core_lib::Error::new(crate::ERROR_CODE_RAW_TRANSACTION_MATERIAL_INVALID, "invalid source-neutral RAW transaction material") + .with_context("field", field); +} + +/// Creates a safe signature error without copying textual signature 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 new file mode 100644 index 0000000..b90e2a9 --- /dev/null +++ b/crates/ksp-raw-transaction-lib/src/lib.rs @@ -0,0 +1,54 @@ +// file: crates/ksp-raw-transaction-lib/src/lib.rs +// version: 1 + +#![warn(missing_docs)] +#![deny(unreachable_pub)] +#![forbid(unsafe_code)] + +//! Source-neutral canonicalization of complete Solana transactions into KSP RAW transaction v1. +//! +//! This lower layer owns the frozen RAW transaction format, textual signature parsing, canonical +//! JSON construction, content hashing and assembly of one canonical transaction with one +//! producer-owned observation. It owns no Transport, Config, Job, Worker, async runtime or Store +//! backend behavior. + +mod acquisition; +mod canonical; +mod error; +mod signature; + +/// Complete source-neutral RAW transaction acquisition containing one canonical entity and one producer-owned observation. +pub use self::acquisition::RawTransactionAcquisition; +/// Assembles one canonical transaction with a producer-owned observation key and safe acquisition provenance. +pub use self::acquisition::assemble_raw_transaction_acquisition; +/// KSP-owned source-independent RAW transaction format identifier. +pub use self::canonical::RAW_TRANSACTION_FORMAT_ID; +/// Frozen KSP-owned RAW transaction format version. +pub use self::canonical::RAW_TRANSACTION_FORMAT_VERSION; +/// Complete source-neutral material required to construct one canonical RAW transaction. +pub use self::canonical::RawTransactionMaterial; +/// Canonical Solana transaction version admitted by RAW transaction format v1. +pub use self::canonical::RawTransactionVersion; +/// Source-wire presence state preserving omitted, explicit null and concrete values independently. +pub use self::canonical::RawTransactionWireField; +/// Canonicalizes complete source-neutral transaction material into the frozen KSP RAW transaction v1 representation. +pub use self::canonical::canonicalize_raw_transaction; +/// Error code used when RAW v1 canonical payload construction cannot preserve the frozen format contract. +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. +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; +/// Parses one bounded Base58 Solana transaction signature to exactly 64 canonical bytes. +pub use self::signature::parse_raw_transaction_signature; + +/// Creates a safe canonicalization error without copying source payload material. +pub(crate) use self::error::canonicalization_error; +/// Creates a safe source-material error without copying source payload material. +pub(crate) use self::error::material_error; +/// Creates a safe signature error without copying textual signature material. +pub(crate) use self::error::signature_error; diff --git a/crates/ksp-raw-transaction-lib/src/signature.rs b/crates/ksp-raw-transaction-lib/src/signature.rs new file mode 100644 index 0000000..db4b5b0 --- /dev/null +++ b/crates/ksp-raw-transaction-lib/src/signature.rs @@ -0,0 +1,67 @@ +// file: crates/ksp-raw-transaction-lib/src/signature.rs +// version: 1 + +/// Maximum UTF-8 byte length admitted for one textual Base58 Solana transaction signature. +pub const MAX_RAW_TRANSACTION_SIGNATURE_TEXT_BYTES: usize = 88; +/// Minimum UTF-8 byte length admitted for one textual Base58 Solana transaction signature. +pub const MIN_RAW_TRANSACTION_SIGNATURE_TEXT_BYTES: usize = 64; + +/// Parses one bounded Base58 Solana transaction signature to exactly 64 canonical bytes. +pub fn parse_raw_transaction_signature(value: &str) -> ksp_core_lib::Result { + let text = value.as_bytes(); + if text.len() < crate::MIN_RAW_TRANSACTION_SIGNATURE_TEXT_BYTES || text.len() > crate::MAX_RAW_TRANSACTION_SIGNATURE_TEXT_BYTES { + return std::result::Result::Err( + crate::signature_error() + .with_context("actual_len", text.len().to_string()) + .with_context("minimum_len", crate::MIN_RAW_TRANSACTION_SIGNATURE_TEXT_BYTES.to_string()) + .with_context("maximum_len", crate::MAX_RAW_TRANSACTION_SIGNATURE_TEXT_BYTES.to_string()), + ); + } + let mut decoded = [0_u8; 64]; + let mut leading_zeroes = 0_usize; + for byte in text { + if *byte != b'1' { + break; + } + leading_zeroes += 1; + } + for byte in text { + let digit = match base58_digit(*byte) { + std::option::Option::Some(digit) => digit, + std::option::Option::None => return std::result::Result::Err(crate::signature_error()), + }; + let mut carry = u32::from(digit); + for output in decoded.iter_mut().rev() { + let expanded = (u32::from(*output) * 58) + carry; + *output = (expanded & 0xff) as u8; + carry = expanded >> 8; + } + if carry != 0 { + return std::result::Result::Err(crate::signature_error()); + } + } + let significant_len = match decoded.iter().position(|byte| return *byte != 0) { + std::option::Option::Some(index) => decoded.len() - index, + std::option::Option::None => 0, + }; + if leading_zeroes + significant_len != decoded.len() { + return std::result::Result::Err(crate::signature_error()); + } + return std::result::Result::Ok(ksp_store_api::RawTransactionSignature::new(decoded)); +} + +fn base58_digit(byte: u8) -> std::option::Option { + return match byte { + b'1'..=b'9' => std::option::Option::Some(byte - b'1'), + b'A'..=b'H' => std::option::Option::Some((byte - b'A') + 9), + b'J'..=b'N' => std::option::Option::Some((byte - b'J') + 17), + b'P'..=b'Z' => std::option::Option::Some((byte - b'P') + 22), + b'a'..=b'k' => std::option::Option::Some((byte - b'a') + 33), + b'm'..=b'z' => std::option::Option::Some((byte - b'm') + 44), + _ => std::option::Option::None, + }; +} + +#[cfg(test)] +#[path = "../unit_tests/signature.rs"] +mod tests; diff --git a/crates/ksp-raw-transaction-lib/tests/dependency_boundary.rs b/crates/ksp-raw-transaction-lib/tests/dependency_boundary.rs new file mode 100644 index 0000000..1bbc548 --- /dev/null +++ b/crates/ksp-raw-transaction-lib/tests/dependency_boundary.rs @@ -0,0 +1,52 @@ +// file: crates/ksp-raw-transaction-lib/tests/dependency_boundary.rs +// version: 1 + +fn manifest() -> std::string::String { + return std::fs::read_to_string(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml")).unwrap_or_default(); +} + +fn production_sources() -> std::string::String { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut combined = std::string::String::new(); + for name in ["acquisition.rs", "canonical.rs", "error.rs", "lib.rs", "signature.rs"] { + let source = std::fs::read_to_string(root.join(name)).unwrap_or_default(); + combined.push_str(source.as_str()); + } + return combined; +} + +#[test] +fn pre_002_manifest_uses_api_models_and_exact_common_dependencies() { + let manifest = manifest(); + 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 }")); + assert!(manifest.contains("sha2 = { workspace = true }")); + for forbidden in + ["ksp-store-lib", "ksp-onchain-transport-lib", "ksp-config-lib", "ksp-job-api", "ksp-job-backfill-lib", "ksp-worker-api", "tokio", "futures"] + { + assert!(!manifest.contains(forbidden)); + } + return; +} + +#[test] +fn pre_002_production_sources_keep_transport_runtime_job_worker_and_backend_out() { + let sources = production_sources(); + for forbidden in [ + "ksp_store_lib", + "ksp_onchain_transport_lib", + "ksp_config_lib", + "ksp_job_api", + "ksp_job_backfill_lib", + "ksp_worker_api", + "ksp_store_postgres_lib", + "tokio::", + "futures::", + "reqwest::", + "tonic::", + ] { + assert!(!sources.contains(forbidden)); + } + return; +} diff --git a/crates/ksp-raw-transaction-lib/tests/public_api.rs b/crates/ksp-raw-transaction-lib/tests/public_api.rs new file mode 100644 index 0000000..ede368c --- /dev/null +++ b/crates/ksp-raw-transaction-lib/tests/public_api.rs @@ -0,0 +1,77 @@ +// file: crates/ksp-raw-transaction-lib/tests/public_api.rs +// version: 1 + +#[test] +fn pre_002_common_raw_contract_is_consumable_from_crate_root() { + assert_eq!(ksp_raw_transaction_lib::RAW_TRANSACTION_FORMAT_ID, "ksp.solana.raw_transaction"); + assert_eq!(ksp_raw_transaction_lib::RAW_TRANSACTION_FORMAT_VERSION, 1); + assert_eq!(ksp_raw_transaction_lib::MIN_RAW_TRANSACTION_SIGNATURE_TEXT_BYTES, 64); + assert_eq!(ksp_raw_transaction_lib::MAX_RAW_TRANSACTION_SIGNATURE_TEXT_BYTES, 88); + let network = match ksp_store_api::RawNetworkId::new("devnet") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return, + }; + let signature = match ksp_raw_transaction_lib::parse_raw_transaction_signature("1".repeat(64).as_str()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return, + }; + let material = ksp_raw_transaction_lib::RawTransactionMaterial::binary_base64( + network, + signature, + 5, + std::option::Option::None, + "AQID", + ksp_raw_transaction_lib::RawTransactionWireField::Null, + ksp_raw_transaction_lib::RawTransactionWireField::Value(ksp_raw_transaction_lib::RawTransactionVersion::Legacy), + ksp_raw_transaction_lib::RawTransactionWireField::Omitted, + ); + let transaction = ksp_raw_transaction_lib::canonicalize_raw_transaction(material); + assert!(transaction.is_ok()); + return; +} + +#[test] +fn pre_002_acquisition_assembly_accepts_store_api_producer_owned_metadata() { + let network = match ksp_store_api::RawNetworkId::new("devnet") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return, + }; + let signature = match ksp_raw_transaction_lib::parse_raw_transaction_signature("1".repeat(64).as_str()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return, + }; + let material = ksp_raw_transaction_lib::RawTransactionMaterial::binary_base64( + network, + signature, + 5, + std::option::Option::None, + "AQID", + ksp_raw_transaction_lib::RawTransactionWireField::Omitted, + ksp_raw_transaction_lib::RawTransactionWireField::Omitted, + ksp_raw_transaction_lib::RawTransactionWireField::Omitted, + ); + let transaction = match ksp_raw_transaction_lib::canonicalize_raw_transaction(material) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return, + }; + let provider = match ksp_store_api::RawProvenanceCode::new("fixture") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return, + }; + let protocol = match ksp_store_api::RawProvenanceCode::new("fixture.protocol") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return, + }; + let method = match ksp_store_api::RawProvenanceCode::new("fixture.method") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return, + }; + let received_at = match ksp_store_api::RawTimestamp::from_unix_millis(1) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return, + }; + let provenance = ksp_store_api::RawAcquisitionProvenance::new(provider, protocol, method, ksp_store_api::RawAcquisitionOrigin::Import, received_at); + let acquisition = ksp_raw_transaction_lib::assemble_raw_transaction_acquisition(transaction, ksp_store_api::RawObservationKey::new([9_u8; 32]), provenance); + assert_eq!(acquisition.transaction().reference(), acquisition.observation().transaction()); + return; +} diff --git a/crates/ksp-raw-transaction-lib/tests/release_completeness.rs b/crates/ksp-raw-transaction-lib/tests/release_completeness.rs new file mode 100644 index 0000000..c069a9a --- /dev/null +++ b/crates/ksp-raw-transaction-lib/tests/release_completeness.rs @@ -0,0 +1,37 @@ +// file: crates/ksp-raw-transaction-lib/tests/release_completeness.rs +// version: 1 + +#[test] +fn pre_002_production_module_inventory_is_exact() { + let source_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let entries = match std::fs::read_dir(source_root) { + std::result::Result::Ok(entries) => entries, + std::result::Result::Err(_) => return, + }; + let mut names = std::vec::Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(std::ffi::OsStr::to_str) == std::option::Option::Some("rs") { + if let std::option::Option::Some(name) = path.file_name().and_then(std::ffi::OsStr::to_str) { + names.push(name.to_owned()); + } + } + } + names.sort(); + let expected = ["acquisition.rs", "canonical.rs", "error.rs", "lib.rs", "signature.rs"]; + assert_eq!(names.len(), expected.len()); + for (actual, expected_name) in names.iter().zip(expected.iter()) { + assert_eq!(actual.as_str(), *expected_name); + } + return; +} + +#[test] +fn pre_002_common_surface_contains_no_worker_job_or_transport_dependency_or_public_module() { + let lib = std::fs::read_to_string(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/lib.rs")).unwrap_or_default(); + assert!(!lib.contains("pub mod ")); + for forbidden in ["ksp_worker", "ksp_job", "ksp_onchain_transport", "ksp_config", "ksp_store_lib", "ksp_store_postgres"] { + assert!(!lib.contains(forbidden)); + } + return; +} diff --git a/crates/ksp-raw-transaction-lib/tests/security_hardening.rs b/crates/ksp-raw-transaction-lib/tests/security_hardening.rs new file mode 100644 index 0000000..9f102a0 --- /dev/null +++ b/crates/ksp-raw-transaction-lib/tests/security_hardening.rs @@ -0,0 +1,26 @@ +// file: crates/ksp-raw-transaction-lib/tests/security_hardening.rs +// version: 1 + +#[test] +fn pre_002_signature_errors_never_echo_hostile_input() { + let hostile = format!("{}0", "A".repeat(63)); + let result = ksp_raw_transaction_lib::parse_raw_transaction_signature(hostile.as_str()); + assert!(result.is_err()); + if let std::result::Result::Err(error) = result { + assert_eq!(error.code(), ksp_raw_transaction_lib::ERROR_CODE_RAW_TRANSACTION_SIGNATURE_INVALID); + assert!(!std::format!("{error:?}").contains(hostile.as_str())); + assert!(!error.to_string().contains(hostile.as_str())); + } + return; +} + +#[test] +fn pre_002_public_error_codes_are_stable_and_domain_scoped() { + assert_eq!(ksp_raw_transaction_lib::ERROR_CODE_RAW_TRANSACTION_CANONICALIZATION_INVALID.domain(), "raw_transaction"); + assert_eq!(ksp_raw_transaction_lib::ERROR_CODE_RAW_TRANSACTION_CANONICALIZATION_INVALID.code(), "canonicalization_invalid"); + assert_eq!(ksp_raw_transaction_lib::ERROR_CODE_RAW_TRANSACTION_MATERIAL_INVALID.domain(), "raw_transaction"); + assert_eq!(ksp_raw_transaction_lib::ERROR_CODE_RAW_TRANSACTION_MATERIAL_INVALID.code(), "material_invalid"); + assert_eq!(ksp_raw_transaction_lib::ERROR_CODE_RAW_TRANSACTION_SIGNATURE_INVALID.domain(), "raw_transaction"); + assert_eq!(ksp_raw_transaction_lib::ERROR_CODE_RAW_TRANSACTION_SIGNATURE_INVALID.code(), "signature_invalid"); + return; +} diff --git a/crates/ksp-raw-transaction-lib/unit_tests/acquisition.rs b/crates/ksp-raw-transaction-lib/unit_tests/acquisition.rs new file mode 100644 index 0000000..db32001 --- /dev/null +++ b/crates/ksp-raw-transaction-lib/unit_tests/acquisition.rs @@ -0,0 +1,73 @@ +// file: crates/ksp-raw-transaction-lib/unit_tests/acquisition.rs +// version: 1 + +fn canonical_transaction() -> std::option::Option { + let network = match ksp_store_api::RawNetworkId::new("devnet") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return std::option::Option::None, + }; + let signature = match crate::parse_raw_transaction_signature("1".repeat(64).as_str()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return std::option::Option::None, + }; + let material = crate::RawTransactionMaterial::binary_base64( + network, + signature, + 42, + std::option::Option::None, + "AQID", + crate::RawTransactionWireField::Omitted, + crate::RawTransactionWireField::Omitted, + crate::RawTransactionWireField::Omitted, + ); + return match crate::canonicalize_raw_transaction(material) { + std::result::Result::Ok(value) => std::option::Option::Some(value), + std::result::Result::Err(_) => std::option::Option::None, + }; +} + +fn provenance() -> std::option::Option { + let provider = match ksp_store_api::RawProvenanceCode::new("fixture") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return std::option::Option::None, + }; + let protocol = match ksp_store_api::RawProvenanceCode::new("fixture.protocol") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return std::option::Option::None, + }; + let method = match ksp_store_api::RawProvenanceCode::new("fixture.method") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return std::option::Option::None, + }; + let received_at = match ksp_store_api::RawTimestamp::from_unix_millis(1_700_000_000_000) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return std::option::Option::None, + }; + return std::option::Option::Some(ksp_store_api::RawAcquisitionProvenance::new( + provider, + protocol, + method, + ksp_store_api::RawAcquisitionOrigin::Import, + received_at, + )); +} + +#[test] +fn pre_002_acquisition_preserves_producer_key_and_exact_transaction_reference() { + let transaction = match canonical_transaction() { + std::option::Option::Some(value) => value, + std::option::Option::None => return, + }; + let provenance = match provenance() { + std::option::Option::Some(value) => value, + std::option::Option::None => return, + }; + let key = ksp_store_api::RawObservationKey::new([7_u8; 32]); + let acquisition = crate::assemble_raw_transaction_acquisition(transaction, key, provenance); + assert_eq!(acquisition.observation().observation_key(), key); + assert_eq!(acquisition.observation().transaction(), acquisition.transaction().reference()); + assert_eq!(std::format!("{acquisition:?}"), "RawTransactionAcquisition(..)"); + let (transaction, observation) = acquisition.into_parts(); + assert_eq!(observation.transaction(), transaction.reference()); + return; +} diff --git a/crates/ksp-raw-transaction-lib/unit_tests/canonical.rs b/crates/ksp-raw-transaction-lib/unit_tests/canonical.rs new file mode 100644 index 0000000..9aef4dc --- /dev/null +++ b/crates/ksp-raw-transaction-lib/unit_tests/canonical.rs @@ -0,0 +1,219 @@ +// file: crates/ksp-raw-transaction-lib/unit_tests/canonical.rs +// version: 1 + +fn network() -> std::option::Option { + return match ksp_store_api::RawNetworkId::new("devnet") { + std::result::Result::Ok(value) => std::option::Option::Some(value), + std::result::Result::Err(_) => std::option::Option::None, + }; +} + +fn signature() -> std::option::Option { + let result = crate::parse_raw_transaction_signature("1".repeat(64).as_str()); + return match result { + std::result::Result::Ok(value) => std::option::Option::Some(value), + std::result::Result::Err(_) => std::option::Option::None, + }; +} + +fn material( + block_time: std::option::Option, + transaction_data: std::string::String, + meta: crate::RawTransactionWireField, + version: crate::RawTransactionWireField, + transaction_index: crate::RawTransactionWireField, +) -> std::option::Option { + let network = match network() { + std::option::Option::Some(value) => value, + std::option::Option::None => return std::option::Option::None, + }; + let signature = match signature() { + std::option::Option::Some(value) => value, + std::option::Option::None => return std::option::Option::None, + }; + return std::option::Option::Some(crate::RawTransactionMaterial::binary_base64( + network, + signature, + 123, + block_time, + transaction_data, + meta, + version, + transaction_index, + )); +} + +#[test] +fn pre_002_canonical_raw_v1_golden_bytes_and_hash_are_exact() { + let material = match material( + std::option::Option::Some(1_700_000_000), + "AQID".to_owned(), + crate::RawTransactionWireField::Value(serde_json::json!({"z": 1, "a": {"y": true, "x": null}})), + crate::RawTransactionWireField::Value(crate::RawTransactionVersion::Legacy), + crate::RawTransactionWireField::Value(7_u32), + ) { + std::option::Option::Some(value) => value, + std::option::Option::None => return, + }; + let transaction = crate::canonicalize_raw_transaction(material); + assert!(transaction.is_ok()); + let transaction = match transaction { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return, + }; + let expected = b"{\"transaction\":[\"AQID\",\"base64\"],\"meta\":{\"a\":{\"x\":null,\"y\":true},\"z\":1},\"version\":\"legacy\",\"transactionIndex\":7}"; + assert_eq!(transaction.payload().bytes(), expected); + assert_eq!(transaction.payload().byte_len(), 112); + assert_eq!(transaction.payload().format_id().as_str(), crate::RAW_TRANSACTION_FORMAT_ID); + assert_eq!(transaction.payload().format_version(), crate::RAW_TRANSACTION_FORMAT_VERSION); + assert_eq!( + transaction.payload().content_hash().as_bytes(), + &[ + 34, 7, 146, 210, 177, 93, 38, 47, 218, 36, 44, 178, 32, 119, 78, 233, 221, 239, 254, 191, 4, 220, 250, 218, 188, 248, 239, 118, 169, 177, 167, 195 + ] + ); + assert_eq!(transaction.slot(), 123); + assert_eq!(transaction.block_time().map(|value| return value.unix_millis()), std::option::Option::Some(1_700_000_000_000)); + assert_eq!(transaction.reference().network().as_str(), "devnet"); + assert_eq!(transaction.reference().signature().as_bytes(), &[0_u8; 64]); + return; +} + +#[test] +fn pre_002_wire_omitted_null_and_value_states_remain_distinct() { + let omitted = match material( + std::option::Option::None, + "AQID".to_owned(), + crate::RawTransactionWireField::Omitted, + crate::RawTransactionWireField::Omitted, + crate::RawTransactionWireField::Omitted, + ) { + std::option::Option::Some(value) => value, + std::option::Option::None => return, + }; + let omitted = super::canonical_payload_bytes(&omitted); + assert!(omitted.is_ok()); + if let std::result::Result::Ok(omitted) = omitted { + assert_eq!(omitted, b"{\"transaction\":[\"AQID\",\"base64\"]}"); + } + let nulls = match material( + std::option::Option::None, + "AQID".to_owned(), + crate::RawTransactionWireField::Null, + crate::RawTransactionWireField::Null, + crate::RawTransactionWireField::Null, + ) { + std::option::Option::Some(value) => value, + std::option::Option::None => return, + }; + let nulls = super::canonical_payload_bytes(&nulls); + assert!(nulls.is_ok()); + if let std::result::Result::Ok(nulls) = nulls { + assert_eq!(nulls, b"{\"transaction\":[\"AQID\",\"base64\"],\"meta\":null,\"version\":null,\"transactionIndex\":null}"); + } + return; +} + +#[test] +fn pre_002_canonical_json_sorts_objects_preserves_arrays_and_escapes_strings() { + let meta = serde_json::json!({ + "z": [3, 2, 1], + "quote": "a\"b\\c\n", + "a": {"n": -12.5, "b": false, "a": true} + }); + let material = match material( + std::option::Option::None, + "A\"B".to_owned(), + crate::RawTransactionWireField::Value(meta), + crate::RawTransactionWireField::Value(crate::RawTransactionVersion::Number(0)), + crate::RawTransactionWireField::Value(u32::MAX), + ) { + std::option::Option::Some(value) => value, + std::option::Option::None => return, + }; + let bytes = super::canonical_payload_bytes(&material); + assert!(bytes.is_ok()); + if let std::result::Result::Ok(bytes) = bytes { + let expected = b"{\"transaction\":[\"A\\\"B\",\"base64\"],\"meta\":{\"a\":{\"a\":true,\"b\":false,\"n\":-12.5},\"quote\":\"a\\\"b\\\\c\\n\",\"z\":[3,2,1]},\"version\":0,\"transactionIndex\":4294967295}"; + assert_eq!(bytes, expected); + } + return; +} + +#[test] +fn pre_002_block_time_rejects_negative_overflow_and_store_maximum_overrun() { + for block_time in [-1_i64, i64::MAX, 253_402_300_800_i64] { + let material = match material( + std::option::Option::Some(block_time), + "AQID".to_owned(), + crate::RawTransactionWireField::Omitted, + crate::RawTransactionWireField::Omitted, + crate::RawTransactionWireField::Omitted, + ) { + std::option::Option::Some(value) => value, + std::option::Option::None => return, + }; + let result = crate::canonicalize_raw_transaction(material); + assert!(result.is_err()); + if let std::result::Result::Err(error) = result { + assert_eq!(error.code(), crate::ERROR_CODE_RAW_TRANSACTION_MATERIAL_INVALID); + } + } + let maximum = match material( + std::option::Option::Some(253_402_300_799_i64), + "AQID".to_owned(), + crate::RawTransactionWireField::Omitted, + crate::RawTransactionWireField::Omitted, + crate::RawTransactionWireField::Omitted, + ) { + std::option::Option::Some(value) => value, + std::option::Option::None => return, + }; + let maximum = crate::canonicalize_raw_transaction(maximum); + assert!(maximum.is_ok()); + if let std::result::Result::Ok(maximum) = maximum { + assert_eq!(maximum.block_time().map(|value| return value.unix_millis()), std::option::Option::Some(253_402_300_799_000)); + } + return; +} + +#[test] +fn pre_002_payload_bound_is_enforced_by_common_before_store_construction() { + let material = match material( + std::option::Option::None, + "A".repeat(ksp_store_api::MAX_RAW_PAYLOAD_BYTES), + crate::RawTransactionWireField::Omitted, + crate::RawTransactionWireField::Omitted, + crate::RawTransactionWireField::Omitted, + ) { + std::option::Option::Some(value) => value, + std::option::Option::None => return, + }; + let result = crate::canonicalize_raw_transaction(material); + assert!(result.is_err()); + if let std::result::Result::Err(error) = result { + assert_eq!(error.code(), crate::ERROR_CODE_RAW_TRANSACTION_CANONICALIZATION_INVALID); + } + return; +} + +#[test] +fn pre_002_material_and_wire_debug_do_not_render_transaction_or_meta_material() { + let secret_transaction = "SECRET_TRANSACTION_BODY"; + let secret_meta = "SECRET_META_VALUE"; + let material = match material( + std::option::Option::None, + secret_transaction.to_owned(), + crate::RawTransactionWireField::Value(serde_json::json!({"secret": secret_meta})), + crate::RawTransactionWireField::Value(crate::RawTransactionVersion::Legacy), + crate::RawTransactionWireField::Value(1_u32), + ) { + std::option::Option::Some(value) => value, + std::option::Option::None => return, + }; + let debug = std::format!("{material:?}"); + assert!(!debug.contains(secret_transaction)); + assert!(!debug.contains(secret_meta)); + assert_eq!(std::format!("{:?}", crate::RawTransactionWireField::Value(secret_meta)), "Value(..)"); + return; +} diff --git a/crates/ksp-raw-transaction-lib/unit_tests/signature.rs b/crates/ksp-raw-transaction-lib/unit_tests/signature.rs new file mode 100644 index 0000000..38faa79 --- /dev/null +++ b/crates/ksp-raw-transaction-lib/unit_tests/signature.rs @@ -0,0 +1,33 @@ +// file: crates/ksp-raw-transaction-lib/unit_tests/signature.rs +// version: 1 + +#[test] +fn pre_002_signature_parser_accepts_exact_sixty_four_zero_bytes() { + let text = "1".repeat(crate::MIN_RAW_TRANSACTION_SIGNATURE_TEXT_BYTES); + let signature = crate::parse_raw_transaction_signature(text.as_str()); + assert!(signature.is_ok()); + if let std::result::Result::Ok(signature) = signature { + assert_eq!(signature.as_bytes(), &[0_u8; 64]); + } + return; +} + +#[test] +fn pre_002_signature_parser_rejects_text_bounds_invalid_base58_and_noncanonical_lengths() { + for text in [ + "1".repeat(crate::MIN_RAW_TRANSACTION_SIGNATURE_TEXT_BYTES - 1), + "1".repeat(crate::MAX_RAW_TRANSACTION_SIGNATURE_TEXT_BYTES + 1), + format!("{}0", "1".repeat(crate::MIN_RAW_TRANSACTION_SIGNATURE_TEXT_BYTES - 1)), + "1".repeat(65), + "z".repeat(crate::MAX_RAW_TRANSACTION_SIGNATURE_TEXT_BYTES), + ] { + let result = crate::parse_raw_transaction_signature(text.as_str()); + assert!(result.is_err()); + if let std::result::Result::Err(error) = result { + assert_eq!(error.code(), crate::ERROR_CODE_RAW_TRANSACTION_SIGNATURE_INVALID); + let debug = std::format!("{error:?}"); + assert!(!debug.contains(text.as_str())); + } + } + return; +} diff --git a/deltas/0.3.10/pre.002.md b/deltas/0.3.10/pre.002.md new file mode 100644 index 0000000..6562064 --- /dev/null +++ b/deltas/0.3.10/pre.002.md @@ -0,0 +1,261 @@ + + + +# Delta `0.3.10-pre.002` — fondation RAW Transaction commune + +## Base requise + +```text +0.3.10-pre.001 +workspace.package.version = 0.3.10-pre.1 +``` + +Le gate opérateur communiqué pour `pre.001` passe `cargo fmt --all`, les audits Rust/Markdown, `cargo check --workspace` et Clippy workspace `--all-targets --all-features -- -D warnings`. Le message ne fournit pas de `cargo test` supplémentaire ; aucun test non communiqué n’est déclaré exécuté. + +## Objectif + +Créer uniquement `ksp-raw-transaction-lib` comme lower-layer commune de canonicalisation RAW Transaction v1 : matériau source-neutral, parser de signature, canonical JSON, SHA-256 et assemblage transaction/observation. Aucun Worker et aucune migration du Backfill dans cette tranche. + +## Version + +Cette tranche est une prerelease non-fix : + +```text +workspace.package.version = 0.3.10-pre.2 +``` + +Le `Cargo.toml` racine passe du header `490` au header `491` et ajoute `crates/ksp-raw-transaction-lib` aux membres du workspace. + +## Correction normative du plan `pre.001` + +Le sizing `pre.001` avait retenu `ksp-store-lib(default-features=false)` pour la common crate. La relecture au moment de matérialiser la frontière révèle une règle plus spécifique : + +```text +DEP-PIPE-006 +Le pipeline raw ingestion peut dépendre des modèles homogènes de +ksp-onchain-transport-lib et de ksp-store-api, mais pas de ksp-store-lib. +``` + +La dépendance est donc corrigée vers `ksp-store-api`. Cette correction ne change aucun invariant RAW ; elle retire au contraire la façade/runtime Store de la lower-layer réutilisable. + +## Dépendances exactes + +```text +ksp-raw-transaction-lib + -> ksp-core-lib + -> ksp-store-api + -> serde_json + -> sha2 +``` + +Absents de la crate : + +```text +ksp-store-lib +ksp-store-postgres-lib +ksp-onchain-transport-lib +ksp-config-lib +ksp-job-api +ksp-job-backfill-lib +ksp-worker-api +Tokio / futures +provider SDK +``` + +## Surface publique + +```text +RAW_TRANSACTION_FORMAT_ID = ksp.solana.raw_transaction +RAW_TRANSACTION_FORMAT_VERSION = 1 +MIN_RAW_TRANSACTION_SIGNATURE_TEXT_BYTES = 64 +MAX_RAW_TRANSACTION_SIGNATURE_TEXT_BYTES = 88 + +RawTransactionMaterial +RawTransactionWireField +RawTransactionVersion +RawTransactionAcquisition + +parse_raw_transaction_signature(...) +canonicalize_raw_transaction(...) +assemble_raw_transaction_acquisition(...) + +ERROR_CODE_RAW_TRANSACTION_CANONICALIZATION_INVALID +ERROR_CODE_RAW_TRANSACTION_MATERIAL_INVALID +ERROR_CODE_RAW_TRANSACTION_SIGNATURE_INVALID +``` + +Tous les modules restent privés et les éléments consommables sont réexportés au crate-root. + +## Contrat matérialisé + +### Signature + +Le parser reçoit un texte Base58 borné de `64..=88` octets et n’accepte que les représentations décodant exactement vers `64` octets. Aucun Solana SDK ou codec externe n’est ajouté. + +Les erreurs ne recopient jamais la signature hostile ; seules les longueurs numériques sûres peuvent apparaître en contexte. + +### Matériau source-neutral + +`RawTransactionMaterial::binary_base64(...)` reçoit : + +```text +network +signature canonique 64 bytes +slot u64 +block_time Option +transaction data binaire Base64 déjà obtenu de la source +meta omitted/null/value +version omitted/null/value (legacy ou u8) +transactionIndex omitted/null/value u32 +``` + +Aucun type Transport/provider n’entre dans la common crate. La représentation interne peut être étendue ultérieurement pour Yellowstone uniquement lorsqu’une parité RAW v1 sera démontrée. + +### Canonicalisation RAW v1 + +La sortie conserve exactement : + +```text +ordre top-level : transaction -> meta -> version -> transactionIndex +transaction = [data, "base64"] +tri récursif des clés d’objets JSON +ordre des arrays conservé +omitted != null != value +block_time hors payload, converti en RawTimestamp +SHA-256 sur les bytes canoniques exacts +format id/version gelés +``` + +Golden verrouillé : + +```text +bytes = {"transaction":["AQID","base64"],"meta":{"a":{"x":null,"y":true},"z":1},"version":"legacy","transactionIndex":7} +byte_len = 112 +sha256 = 220792d2b15d262fda242cb220774ee9ddeffebf04dcfadabcf8ef76a9b1a7c3 +``` + +### Observation producer-owned + +`assemble_raw_transaction_acquisition(...)` reçoit la `RawObservationKey` déjà choisie par le producteur et construit l’observation sur la référence exacte de la transaction. Aucun domaine de clé Backfill ou Worker n’est introduit dans common. + +## Tests ajoutés + +Tests unitaires : + +```text +signature exacte 64 bytes +signature bornes / caractère Base58 invalide / overflow / longueur décodée non canonique +golden bytes + hash RAW v1 +omitted/null/value +golden JSON récursif / arrays / escaping / nombres +block_time négatif / overflow / maximum +borne payload +Debug material/wire redacted +assemblage acquisition + observation key producer-owned +``` + +Tests d’intégration : + +```text +dependency boundary exacte +absence runtime/Transport/Config/Job/Worker/backend +consommation crate-root externe +assemblage avec modèles Store API +inventaire modules production +absence de contrat Worker/Backfill/Transport dans la façade +error codes stables +signature hostile non recopiée +``` + +## Fichiers ajoutés + +```text +crates/ksp-raw-transaction-lib/Cargo.toml +crates/ksp-raw-transaction-lib/src/acquisition.rs +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/unit_tests/acquisition.rs +crates/ksp-raw-transaction-lib/unit_tests/canonical.rs +crates/ksp-raw-transaction-lib/unit_tests/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/tests/release_completeness.rs +crates/ksp-raw-transaction-lib/tests/security_hardening.rs +deltas/0.3.10/pre.002.md +``` + +## Fichiers modifiés + +```text +Cargo.toml +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. + +## Documentation volontairement différée + +`README.md` et `USAGE.md` de la nouvelle crate ne sont pas finalisés dans cette tranche de fondation. La réconciliation version-neutral reste réservée au couloir documentaire `pre.015`, après parité cross-source, migration Backfill et freeze fonctionnelle. + +## 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), 754 file(s)) + +contrôle TOML : 20 membres workspace, nouvelle crate présente +contrôle manifest common : dépendances exactes core/store-api/serde_json/sha2, aucune feature/dev/build dependency +``` + +L’environnement d’assemblage ne fournit ni `cargo`, ni `rustc`, ni `rustfmt`. Aucun gate Cargo de `pre.002` n’est donc déclaré PASS localement. + +## 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 tree -p ksp-raw-transaction-lib --edges normal +cargo tree -p ksp-raw-transaction-lib -e features +``` + +## 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 tree -p ksp-raw-transaction-lib --edges normal +cargo tree -p ksp-raw-transaction-lib -e features +smokes live : non pertinents pour cette tranche purement déterministe +``` + +## Décisions prises + +```text +RAW v1 reste unique et inchangé +ksp-store-api remplace ksp-store-lib dans common pour conformité DEP-PIPE-006 +RawObservationKey reste producer-owned +aucun type Transport dans common +aucun async/runtime dans common +aucune migration Backfill avant pre.003 +aucun Worker avant pre.007 selon le plan recalibré +``` + +## Questions ouvertes + +Aucune question ne bloque `pre.003` après un gate opérateur vert. Les projections structurées WS/Helius/Yellowstone restent volontairement différées jusqu’aux tranches de parité prévues ; elles ne doivent pas modifier RAW v1 par anticipation. 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 127097a..e7c2cf1 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 @@ -19,7 +19,7 @@ sources live -> matériau transaction complet ou discovery/hydration -> canonicalisation RAW v1 unique -> RawTransaction + RawTransactionObservation - -> ksp-store-lib + -> persistence par le producteur concret via ksp-store-lib ``` Le Worker et le Job Backfill sont deux producteurs autonomes du même modèle durable. Aucun appel, lifecycle, orchestration, checkpoint ou contrat fonctionnel ne doit relier les deux. @@ -161,7 +161,7 @@ Les décisions de fermeture du gate sont : RAW v1 reste unique ; aucun RAW v2. mainnet reste l’identité KSP canonique ; mainnet-beta reste alias externe/legacy. ksp-raw-transaction-lib devient l’unique owner de la canonicalisation RAW v1. -La common crate dépend de Store façade, jamais de Transport/Config/Job/Worker. +La common crate dépend des modèles `ksp-store-api` conformément à `DEP-PIPE-006`, jamais de la façade runtime Store ni de Transport/Config/Job/Worker. RawObservationKey reste producer-owned comme le contrat Store l’indique déjà. Le Backfill conserve son observation-key Job-owned et ses métadonnées de campagne. Le Worker possède son runtime concret, ses source IDs, ses observation keys et sa continuité de run. @@ -290,7 +290,7 @@ Cible retenue : ```text ksp-raw-transaction-lib -> ksp-core-lib - -> ksp-store-lib default-features=false + -> ksp-store-api -> serde_json -> sha2 ``` @@ -298,13 +298,14 @@ ksp-raw-transaction-lib Justification : - `ksp-core-lib` est nécessaire aux codes/erreurs KSP propres à la canonicalisation ; -- `ksp-store-lib` fournit les reexports approuvés de `RawTransaction`, `RawPayload`, provenance et primitives ; +- `ksp-store-api` fournit directement les modèles canoniques `RawTransaction`, `RawPayload`, provenance et primitives, conformément à `DEP-PIPE-006` ; - `serde_json` reste requis par RAW v1 pour canonicaliser `meta` et préserver les états wire ; - `sha2` reste requis pour le digest canonique exact. Interdits : ```text +ksp-store-lib ksp-onchain-transport-lib ksp-config-lib ksp-job-api @@ -979,33 +980,29 @@ Une preuve sur `transactions` ne valide pas automatiquement `blocks`. ## 16. Graphe de dépendances cible ```text -ksp-core-lib - ^ - | -ksp-store-api <- ksp-store-lib <- ksp-raw-transaction-lib - ^ ^ - | | - | +-------+------------------+ - | | | - | ksp-job-backfill-lib ksp-worker-raw-transaction-ingest-lib - | ^ ^ - | | | - +----------+--------------------------+ - | - ksp-onchain-transport-lib +ksp-raw-transaction-lib -> ksp-core-lib +ksp-raw-transaction-lib -> ksp-store-api -> ksp-core-lib -ksp-worker-api --------------------------> ksp-worker-raw-transaction-ingest-lib -ksp-logging-lib -------------------------> ksp-worker-raw-transaction-ingest-lib +ksp-job-backfill-lib -> ksp-raw-transaction-lib +ksp-job-backfill-lib -> ksp-store-lib -> ksp-store-api +ksp-job-backfill-lib -> ksp-onchain-transport-lib +ksp-job-backfill-lib -> ksp-job-api + +ksp-worker-raw-transaction-ingest-lib -> ksp-raw-transaction-lib +ksp-worker-raw-transaction-ingest-lib -> ksp-store-lib -> ksp-store-api +ksp-worker-raw-transaction-ingest-lib -> ksp-onchain-transport-lib +ksp-worker-raw-transaction-ingest-lib -> ksp-worker-api +ksp-worker-raw-transaction-ingest-lib -> ksp-logging-lib ``` -Lecture des flèches du schéma : les consumers dépendent des lower layers ; aucun lower layer ne dépend d’un consumer. Le dessin ne crée pas d’edge `ksp-store-lib -> common` : c’est bien `common -> ksp-store-lib`. +Lecture des flèches du schéma : les consumers dépendent des lower layers ; aucun lower layer ne dépend d’un consumer. `ksp-raw-transaction-lib` dépend directement de `ksp-store-api` comme pipeline RAW réutilisable ; `ksp-store-lib` reste la façade runtime utilisée séparément par les producteurs concrets pour la persistence. Edges exacts attendus : ```text ksp-raw-transaction-lib -> ksp-core-lib - -> ksp-store-lib default-features=false + -> ksp-store-api -> serde_json -> sha2 @@ -1037,7 +1034,11 @@ Présente tranche : archive/règles, common extraction, capability matrix, fraî ### `pre.002` — common RAW foundation -Créer `ksp-raw-transaction-lib`, types source-neutral minimaux, signature parser, canonical JSON, SHA-256 et golden RAW v1 exact. Aucun Worker. +**Statut : réalisé.** + +`ksp-raw-transaction-lib` matérialise les types source-neutral minimaux, le parser Base58 strict vers 64 octets, la canonicalisation JSON RAW v1, SHA-256 et l’assemblage transaction + observation producer-owned. Le golden `112` bytes / `220792...a7c3` est verrouillé par test. Aucun Worker ni migration Backfill n’est ouvert. + +La matérialisation a révélé une correction normative du sizing `pre.001` : `DEP-PIPE-006` impose `ksp-store-api` au pipeline RAW et interdit `ksp-store-lib`. Le graphe et la section 5.4 sont donc corrigés dans cette tranche sans changer le contrat fonctionnel RAW. ### `pre.003` — migration Backfill vers common 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 5f88404..17a1ca3 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 @@ -80,6 +80,31 @@ Tauri build : NON EXÉCUTÉ ici Aucun de ces gates n’est déclaré PASS par cette session. Le journal opérateur fourni pour la stable `0.3.9` montre un gate complet antérieur ; il reste une preuve externe de la base stable, pas une exécution `pre.001` locale. +### 2.4 Gate opérateur après livraison de `pre.001` + +Le 6 septembre 2026, l’opérateur a exécuté sur `workspace.package.version = 0.3.10-pre.1` : + +```text +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 +``` + +Résultats communiqués : + +```text +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), 753 file(s)) +cargo check --workspace : terminé sans erreur +cargo clippy --workspace --all-targets --all-features -- -D warnings : terminé sans erreur +``` + +Ce gate opérateur ferme les contrôles demandés pour passer à `pre.002`. Aucun `cargo test` de `pre.001` n’a été fourni dans ce message et il n’est pas inventé. + ## 3. Preuves de conception fermées en `pre.001` ### 3.1 Common RAW @@ -92,7 +117,7 @@ format_version courant = 1 RawObservationKey est explicitement producer-owned dans Store RawTransaction identity = network + signature RawTransactionObservation est séparée de l’entité -ksp-store-lib reexporte les primitives nécessaires +`DEP-PIPE-006` impose les modèles `ksp-store-api` à la lower-layer RAW réutilisable ; la décision `ksp-store-lib` de `pre.001` est corrigée en `pre.002` Backfill conversion mélange actuellement canonicalisation commune et metadata Job-owned ``` @@ -352,3 +377,104 @@ pre.015 final docs reconciliation pre.016 publication preparation rel.001 stable publication ``` + +## 11. Gate `pre.002` — common RAW foundation + +État : **matérialisé ; gate Cargo opérateur requis**. + +### 11.1 Crate et frontière + +```text +crate = ksp-raw-transaction-lib +workspace.package.version = 0.3.10-pre.2 +runtime async = aucun +Transport / Config / Job / Worker = absents +backend Store = absent +``` + +La dépendance exacte retenue après contrôle normatif est : + +```text +ksp-raw-transaction-lib + -> ksp-core-lib + -> ksp-store-api + -> serde_json + -> sha2 +``` + +Cette correction remplace la proposition `ksp-store-lib(default-features=false)` de `pre.001` car `DEP-PIPE-006` est plus spécifique : le pipeline RAW partagé peut dépendre de `ksp-store-api`, pas de `ksp-store-lib`. + +### 11.2 Surface matérialisée + +```text +RAW_TRANSACTION_FORMAT_ID = ksp.solana.raw_transaction +RAW_TRANSACTION_FORMAT_VERSION = 1 +MIN_RAW_TRANSACTION_SIGNATURE_TEXT_BYTES = 64 +MAX_RAW_TRANSACTION_SIGNATURE_TEXT_BYTES = 88 + +RawTransactionMaterial +RawTransactionWireField +RawTransactionVersion +RawTransactionAcquisition + +parse_raw_transaction_signature(...) +canonicalize_raw_transaction(...) +assemble_raw_transaction_acquisition(...) + +ERROR_CODE_RAW_TRANSACTION_CANONICALIZATION_INVALID +ERROR_CODE_RAW_TRANSACTION_MATERIAL_INVALID +ERROR_CODE_RAW_TRANSACTION_SIGNATURE_INVALID +``` + +`RawObservationKey` reste fournie par le producteur. La common crate ne contient aucun domaine de clé Backfill/Worker. + +### 11.3 Preuves déterministes ajoutées + +```text +signature Base58 : bornes texte + alphabet + overflow + exactement 64 octets +RAW v1 golden : 112 bytes exacts +RAW v1 SHA-256 : 220792d2b15d262fda242cb220774ee9ddeffebf04dcfadabcf8ef76a9b1a7c3 +meta/version/index : omitted != null != value +JSON : clés objet triées récursivement, arrays conservés, strings échappées, nombres conservés +block_time : négatif/overflow/max Store +payload : borne MAX_RAW_PAYLOAD_BYTES +Debug/error : aucune signature, transaction body ou meta hostile recopiée +acquisition : observation key producer-owned et référence exactement égale à la transaction +public API : consommation par crate-root +firewall : absence Store runtime/Transport/Config/Job/Worker/backend +``` + +### 11.4 Validation locale d’assemblage + +Exécuté 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), 754 file(s)) + +manifest TOML : 20 membres workspace, ksp-raw-transaction-lib présent +manifest common : 4 dépendances exactes, aucune feature/dev/build dependency +``` + +`cargo`, `rustc` et `rustfmt` ne sont pas présents dans cet environnement. Les gates Cargo de la nouvelle crate restent donc **NON EXÉCUTÉS LOCALEMENT**. + +### 11.5 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 tree -p ksp-raw-transaction-lib --edges normal +cargo tree -p ksp-raw-transaction-lib -e features +``` + +`pre.003` ne doit migrer le Backfill qu’après ce gate vert. +