diff --git a/starknet-core/src/types/byte_array.rs b/starknet-core/src/types/byte_array.rs new file mode 100644 index 00000000..3b892b4c --- /dev/null +++ b/starknet-core/src/types/byte_array.rs @@ -0,0 +1,370 @@ +//! Support for [`String`] compatibility with Cairo `ByteArray`. +//! . +//! +//! The basic concept of this `ByteArray` is relying on a string being +//! represented as an array of bytes packed by 31 bytes ([`Bytes31`]) in a [`Felt`]. +//! To support any string even if the length is not a multiple of 31, +//! the `ByteArray` struct has a `pending_word` field, which is the last +//! word that is always shorter than 31 bytes. +use alloc::{ + str::{self}, + string::{FromUtf8Error, String}, + vec::Vec, +}; + +use crate::types::{Bytes31, Felt}; + +const MAX_WORD_LEN: usize = 31; + +/// A struct representing a Cairo `ByteArray`. +#[derive(Debug, Clone, Eq, PartialEq, Default)] +pub struct ByteArray { + /// An array of full "words" of 31 bytes each. + /// The first byte of each word in the byte array is the most significant byte in the word. + pub data: Vec, + /// A `felt252` that actually represents a `bytes31`, with less than 31 bytes. + /// It is represented as a `felt252` to improve performance of building the byte array. + /// The first byte is the most significant byte among the `pending_word_len` bytes in the word. + pub pending_word: Bytes31, + /// The number of bytes in `pending_word`. + /// Its value should be in the range [0, 30]. + pub pending_word_len: usize, +} + +impl ByteArray { + /// Converts a `String` into a `ByteArray`. + /// The rust type `String` implies UTF-8 encoding, + /// event if this function is not directly bound to this encoding. + /// + /// # Arguments + /// + /// * `string` - The always valid UTF-8 string to convert. + fn from_string(string: &str) -> Self { + let bytes = string.as_bytes(); + let chunks: Vec<_> = bytes.chunks(MAX_WORD_LEN).collect(); + + let remainder = if bytes.len() % MAX_WORD_LEN != 0 { + chunks.last().copied().map(|last| last.to_vec()) + } else { + None + }; + + let full_chunks = if remainder.is_some() { + &chunks[..chunks.len() - 1] + } else { + &chunks[..] + }; + + let (pending_word, pending_word_len) = if let Some(r) = remainder { + let len = r.len(); + ( + // Safe to unwrap here as slices are at most 31 bytes long. + Bytes31::try_from(Felt::from_bytes_be_slice(&r)).unwrap(), + len, + ) + } else { + (Bytes31::try_from(Felt::ZERO).unwrap(), 0) + }; + + let mut data = Vec::new(); + for chunk in full_chunks { + // Safe to unwrap here as slices are at most 31 bytes long. + data.push(Bytes31::try_from(Felt::from_bytes_be_slice(chunk)).unwrap()) + } + + Self { + data, + pending_word, + pending_word_len, + } + } + + /// Converts [`ByteArray`] instance into an UTF-8 encoded string on success. + /// Returns error if the [`ByteArray`] contains an invalid UTF-8 string. + fn to_string(&self) -> Result { + let mut s = String::new(); + + for d in &self.data { + // Chunks are always 31 bytes long (MAX_WORD_LEN). + s.push_str(&d.to_string(MAX_WORD_LEN)?); + } + + if self.pending_word_len > 0 { + s.push_str(&self.pending_word.to_string(self.pending_word_len)?); + } + + Ok(s) + } +} + +impl TryFrom for String { + type Error = FromUtf8Error; + + fn try_from(value: ByteArray) -> Result { + value.to_string() + } +} + +impl From for ByteArray { + fn from(value: String) -> Self { + Self::from_string(&value) + } +} + +impl From<&str> for ByteArray { + fn from(value: &str) -> Self { + Self::from_string(value) + } +} + +#[cfg(test)] +mod tests { + use super::{ByteArray, Bytes31, Felt}; + + #[test] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn test_byte_array_from_string_empty() { + let b = ByteArray::from_string(""); + assert_eq!(b, ByteArray::default()); + } + + #[test] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn test_byte_array_from_string_only_pending_word() { + let b = ByteArray::from_string("ABCD"); + assert_eq!( + b, + ByteArray { + data: vec![], + pending_word: Bytes31::from_hex("0x41424344").unwrap(), + pending_word_len: 4, + } + ); + } + + #[test] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn test_byte_array_from_string_max_pending_word_len() { + // pending word is at most 30 bytes long. + let b = ByteArray::from_string("ABCDEFGHIJKLMNOPQRSTUVWXYZ1234"); + + assert_eq!( + b, + ByteArray { + data: vec![], + pending_word: Bytes31::from_hex( + "0x00004142434445464748494a4b4c4d4e4f505152535455565758595a31323334" + ) + .unwrap(), + pending_word_len: 30, + } + ); + } + + #[test] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn test_byte_array_from_string_data_only() { + let b = ByteArray::from_string("ABCDEFGHIJKLMNOPQRSTUVWXYZ12345"); + + assert_eq!( + b, + ByteArray { + data: vec![Bytes31::from_hex( + "0x004142434445464748494a4b4c4d4e4f505152535455565758595a3132333435" + ) + .unwrap()], + pending_word: Felt::ZERO.try_into().unwrap(), + pending_word_len: 0, + } + ); + } + + #[test] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn test_byte_array_from_string_data_only_multiple_values() { + let b = ByteArray::from_string( + "ABCDEFGHIJKLMNOPQRSTUVWXYZ12345ABCDEFGHIJKLMNOPQRSTUVWXYZ12345", + ); + + assert_eq!( + b, + ByteArray { + data: vec![ + Bytes31::from_hex( + "0x004142434445464748494a4b4c4d4e4f505152535455565758595a3132333435" + ) + .unwrap(), + Bytes31::from_hex( + "0x004142434445464748494a4b4c4d4e4f505152535455565758595a3132333435" + ) + .unwrap(), + ], + pending_word: Felt::ZERO.try_into().unwrap(), + pending_word_len: 0, + } + ); + } + + #[test] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn test_byte_array_from_string_data_and_pending_word() { + let b = ByteArray::from_string( + "ABCDEFGHIJKLMNOPQRSTUVWXYZ12345ABCDEFGHIJKLMNOPQRSTUVWXYZ12345ABCD", + ); + + assert_eq!( + b, + ByteArray { + data: vec![ + Bytes31::from_hex( + "0x004142434445464748494a4b4c4d4e4f505152535455565758595a3132333435" + ) + .unwrap(), + Bytes31::from_hex( + "0x004142434445464748494a4b4c4d4e4f505152535455565758595a3132333435" + ) + .unwrap(), + ], + pending_word: Bytes31::from_hex( + "0x0000000000000000000000000000000000000000000000000000000041424344" + ) + .unwrap(), + pending_word_len: 4, + } + ); + } + + #[test] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn test_byte_array_to_string_empty() { + let b = ByteArray::default(); + assert_eq!(b.to_string().unwrap(), ""); + } + + #[test] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn test_byte_array_to_string_only_pending_word() { + let b = ByteArray { + data: vec![], + pending_word: Bytes31::from_hex( + "0x0000000000000000000000000000000000000000000000000000000041424344", + ) + .unwrap(), + pending_word_len: 4, + }; + + assert_eq!(b.to_string().unwrap(), "ABCD"); + } + + #[test] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn test_byte_array_to_string_max_pending_word_len() { + let b = ByteArray { + data: vec![], + pending_word: Bytes31::from_hex( + "0x00004142434445464748494a4b4c4d4e4f505152535455565758595a31323334", + ) + .unwrap(), + pending_word_len: 30, + }; + + assert_eq!(b.to_string().unwrap(), "ABCDEFGHIJKLMNOPQRSTUVWXYZ1234"); + } + + #[test] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn test_byte_array_to_string_data_only() { + let b = ByteArray { + data: vec![Bytes31::from_hex( + "0x004142434445464748494a4b4c4d4e4f505152535455565758595a3132333435", + ) + .unwrap()], + pending_word: Felt::ZERO.try_into().unwrap(), + pending_word_len: 0, + }; + + assert_eq!(b.to_string().unwrap(), "ABCDEFGHIJKLMNOPQRSTUVWXYZ12345"); + } + + #[test] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn test_byte_array_to_string_data_only_multiple_values() { + let b = ByteArray { + data: vec![ + Bytes31::from_hex( + "0x004142434445464748494a4b4c4d4e4f505152535455565758595a3132333435", + ) + .unwrap(), + Bytes31::from_hex( + "0x004142434445464748494a4b4c4d4e4f505152535455565758595a3132333435", + ) + .unwrap(), + ], + pending_word: Felt::ZERO.try_into().unwrap(), + pending_word_len: 0, + }; + + assert_eq!( + b.to_string().unwrap(), + "ABCDEFGHIJKLMNOPQRSTUVWXYZ12345ABCDEFGHIJKLMNOPQRSTUVWXYZ12345" + ); + } + + #[test] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn test_byte_array_to_string_data_and_pending_word() { + let b = ByteArray { + data: vec![ + Bytes31::from_hex( + "0x004142434445464748494a4b4c4d4e4f505152535455565758595a3132333435", + ) + .unwrap(), + Bytes31::from_hex( + "0x004142434445464748494a4b4c4d4e4f505152535455565758595a3132333435", + ) + .unwrap(), + ], + pending_word: Bytes31::from_hex( + "0x0000000000000000000000000000000000000000000000000000000041424344", + ) + .unwrap(), + pending_word_len: 4, + }; + + assert_eq!( + b.to_string().unwrap(), + "ABCDEFGHIJKLMNOPQRSTUVWXYZ12345ABCDEFGHIJKLMNOPQRSTUVWXYZ12345ABCD" + ); + } + + #[test] + #[should_panic] + fn test_byte_array_to_string_invalid_utf8() { + let invalid = Felt::from_bytes_be_slice(b"\xF0\x90\x80"); + + let b = ByteArray { + data: vec![], + pending_word: invalid.try_into().unwrap(), + pending_word_len: 4, + }; + + b.to_string().unwrap(); + } + + #[test] + fn test_from_utf8() { + let b: ByteArray = "🦀🌟".into(); + + assert_eq!( + b, + ByteArray { + data: vec![], + pending_word: Bytes31::from_hex( + "0x000000000000000000000000000000000000000000000000f09fa680f09f8c9f", + ) + .unwrap(), + pending_word_len: 8, + } + ); + } +} diff --git a/starknet-core/src/types/bytes_31.rs b/starknet-core/src/types/bytes_31.rs new file mode 100644 index 00000000..06a296cd --- /dev/null +++ b/starknet-core/src/types/bytes_31.rs @@ -0,0 +1,152 @@ +//! Support for `Bytes31` Cairo primitive. +//! . +//! +//! This type is mostly used internally for [`crate::types::ByteArray`] internal logic. +use alloc::{ + string::{FromUtf8Error, String}, + vec::Vec, +}; +use starknet_types_core::felt::FromStrError; + +use crate::types::Felt; + +pub const MAX_BYTES_COUNT: usize = 31; + +pub const BYTES31_UPPER_BOUND: Felt = Felt::from_raw([ + 18446744062762287109, + 20123647, + 18446744073709514624, + 576460566199926936, +]); + +/// A 31 byte array primitive used mostly for [`crate::types::ByteArray`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)] +pub struct Bytes31(Felt); + +mod errors { + use core::fmt::{Display, Formatter, Result}; + + #[derive(Debug)] + pub struct FromFieldElementError; + + #[cfg(feature = "std")] + impl std::error::Error for FromFieldElementError {} + + impl Display for FromFieldElementError { + fn fmt(&self, f: &mut Formatter<'_>) -> Result { + write!(f, "Felt value out of range for Bytes31") + } + } +} +pub use errors::FromFieldElementError; + +impl Bytes31 { + /// Converts a [`Bytes31`] into a UTF-8 string. + /// Returns an error if the [`Bytes31`] contains an invalid UTF-8 string. + /// + /// # Arguments + /// + /// * `len` - The number of bytes in the [`Bytes31`] to consider in the string, at most 31. + pub fn to_string(self, len: usize) -> Result { + let mut buffer = Vec::new(); + + // Bytes31 always enforce to have the first byte equal to 0 in the felt. + // That's why we start to 1. + for byte in &self.0.to_bytes_be()[1 + MAX_BYTES_COUNT - len..] { + buffer.push(*byte) + } + + String::from_utf8(buffer) + } + + /// Converts a hex string to a [`Bytes31`]. + pub fn from_hex(hex: &str) -> Result { + Ok(Self(Felt::from_hex(hex)?)) + } +} + +impl From for Felt { + fn from(value: Bytes31) -> Self { + value.0 + } +} + +impl TryFrom for Bytes31 { + type Error = FromFieldElementError; + + fn try_from(value: Felt) -> Result { + if value < BYTES31_UPPER_BOUND { + Ok(Self(value)) + } else { + Err(FromFieldElementError) + } + } +} + +#[cfg(test)] +mod tests { + use super::{Bytes31, Felt, FromFieldElementError, BYTES31_UPPER_BOUND}; + + #[test] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn test_bytes31_from_felt_out_of_range() { + match Bytes31::try_from(Felt::MAX) { + Err(FromFieldElementError) => {} + _ => { + panic!("Expected Bytes31::try_from(Felt::MAX) to return Err(FromFieldElementError)") + } + } + } + + #[test] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn test_bytes31_from_felt() { + let expected_felt = BYTES31_UPPER_BOUND - Felt::ONE; + + match Bytes31::try_from(expected_felt) { + Ok(bytes31) => assert_eq!(Felt::from(bytes31), expected_felt), + _ => panic!("Expected Bytes31 from Felt to be valid"), + } + } + + #[test] + #[should_panic] + fn test_bytes31_from_invalid_utf8() { + let invalid = b"Hello \xF0\x90\x80World"; + let felt = Felt::from_bytes_be_slice(invalid); + let bytes31 = Bytes31::try_from(felt).unwrap(); + + if bytes31.to_string(4).is_ok() { + panic!("Expected Bytes31 to contain invalid UTF-8") + } + } + + #[test] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn test_bytes31_from_valid_utf8() { + let felt = + Felt::from_hex("0x000000000000000000000000000000000000000000000000f09fa680f09f8c9f") + .unwrap(); + + let bytes31 = Bytes31::try_from(felt).unwrap(); + let string = bytes31.to_string(8).unwrap(); + + assert_eq!(string, "🦀🌟"); + } + + #[test] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn test_bytes31_from_string_empty() { + let bytes31 = Bytes31::try_from(Felt::ZERO).unwrap(); + let string = bytes31.to_string(0).unwrap(); + + assert_eq!(string, ""); + } + + #[test] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn test_bytes31_from_hex() { + let bytes31 = Bytes31::from_hex("0x1").unwrap(); + assert_eq!(Felt::ONE, bytes31.into()); + } +} diff --git a/starknet-core/src/types/mod.rs b/starknet-core/src/types/mod.rs index 8693f7a1..163d5e8e 100644 --- a/starknet-core/src/types/mod.rs +++ b/starknet-core/src/types/mod.rs @@ -59,6 +59,12 @@ pub use execution_result::ExecutionResult; mod receipt_block; pub use receipt_block::ReceiptBlock; +mod bytes_31; +pub use bytes_31::Bytes31; + +mod byte_array; +pub use byte_array::ByteArray; + mod msg; pub use msg::MsgToL2;