// file: crates/ksp-raw-transaction-lib/src/canonical.rs // version: 2 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, }; } /// 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 { 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;