From ec0a86edeeec8f14274edc3f71c96e1baff4dc2c Mon Sep 17 00:00:00 2001 From: broody Date: Sun, 22 Feb 2026 10:01:25 -1000 Subject: [PATCH] feat: add bytearray: calldata prefix Add support for Cairo ByteArray serialization in calldata via a bytearray: prefix. Supports two modes: string encoding (bytearray:hello) and raw bytes encoding (bytearray:[0x48,0x65,0x6c,0x6c,0x6f]). Uses cainome_cairo_serde::ByteArray for correct multi-felt serialization. Co-Authored-By: Claude Opus 4.6 --- LLM_USAGE.md | 16 ++++ SKILL.md | 3 + src/commands/calldata.rs | 160 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 178 insertions(+), 1 deletion(-) diff --git a/LLM_USAGE.md b/LLM_USAGE.md index 3ce34c3..e70a023 100644 --- a/LLM_USAGE.md +++ b/LLM_USAGE.md @@ -378,6 +378,9 @@ Calldata values support multiple formats: | Decimal | `100` | Decimal felt (auto-converted) | | `u256:` | `u256:1000000000000000000` | Auto-splits into low/high 128-bit felts | | `str:` | `str:hello` | Cairo short string encoding | +| `bytearray:` | `bytearray:hello` | Cairo ByteArray multi-felt serialization | +| `bytearray:` (quoted) | `bytearray:"hello world"` | ByteArray with spaces (quotes stripped) | +| `bytearray:` (raw) | `bytearray:[0x48,0x65,0x6c,0x6c,0x6f]` | ByteArray from raw byte values | The `u256:` prefix is the recommended way to specify token amounts. It eliminates manual low/high splitting: @@ -389,6 +392,19 @@ controller execute 0x04718f... transfer 0xRECIPIENT,u256:1000000000000000000 --j controller execute 0x04718f... transfer 0xRECIPIENT,0xDE0B6B3A7640000,0x0 --json ``` +The `bytearray:` prefix serializes strings or raw bytes into Cairo's `ByteArray` format (data chunks + pending word + length). Use it for contract entrypoints that expect a `ByteArray` argument: + +```bash +# String mode (simple, no spaces) +controller execute 0x... set_name bytearray:MyName --json + +# Quoted string mode (use quotes for strings with spaces) +controller execute 0x... set_name 'bytearray:"My Game Name"' --json + +# Raw bytes mode +controller execute 0x... set_data bytearray:[0x48,0x65,0x6c,0x6c,0x6f] --json +``` + --- ## Network Selection diff --git a/SKILL.md b/SKILL.md index fdf6a70..22a6047 100644 --- a/SKILL.md +++ b/SKILL.md @@ -113,6 +113,9 @@ Valid keys: `rpc-url`, `keychain-url`, `api-url`, `storage-path`, `json-output`, - Decimal: `100` (auto-converted) - `u256:` prefix: `u256:1000000000000000000` (auto-splits into low/high 128-bit felts) - `str:` prefix: `str:hello` (Cairo short string encoding) +- `bytearray:` prefix: `bytearray:hello` (Cairo ByteArray multi-felt serialization) +- `bytearray:` quoted: `bytearray:"hello world"` (ByteArray with spaces, quotes stripped) +- `bytearray:` raw bytes: `bytearray:[0x48,0x65,0x6c,0x6c,0x6f]` (ByteArray from raw byte values) - Manual u256: split into low,high — e.g., 100 tokens = `0x64,0x0` ## Policy File Format diff --git a/src/commands/calldata.rs b/src/commands/calldata.rs index 9ccb84e..299d557 100644 --- a/src/commands/calldata.rs +++ b/src/commands/calldata.rs @@ -1,7 +1,10 @@ +use cainome_cairo_serde::{ByteArray, Bytes31, CairoSerde}; + use crate::error::{CliError, Result}; use starknet::core::{types::Felt, utils::cairo_short_string_to_felt}; -/// Parse a calldata value, handling special prefixes (u256:, str:) and default felt parsing. +/// Parse a calldata value, handling special prefixes (u256:, str:, bytearray:) and default felt +/// parsing. pub fn parse_calldata_value(value: &str) -> Result> { if let Some(u256_str) = value.strip_prefix("u256:") { // Parse u256 value and split into low/high felts @@ -24,6 +27,49 @@ pub fn parse_calldata_value(value: &str) -> Result> { let high = Felt::from_bytes_be_slice(high_bytes); Ok(vec![low, high]) + } else if let Some(ba_value) = value.strip_prefix("bytearray:") { + // Parse Cairo ByteArray (multi-felt serialization) + let byte_array = if ba_value.starts_with('[') && ba_value.ends_with(']') { + // Raw bytes mode: bytearray:[0xa,0xd,0xff] + let inner = &ba_value[1..ba_value.len() - 1]; + if inner.is_empty() { + ByteArray::default() + } else { + let bytes: Vec = inner + .split(',') + .map(|b| { + let b = b.trim(); + if let Some(hex) = b.strip_prefix("0x").or_else(|| b.strip_prefix("0X")) { + u8::from_str_radix(hex, 16).map_err(|e| { + CliError::InvalidInput(format!( + "Invalid byte value '{b}' in bytearray: {e}" + )) + }) + } else { + b.parse::().map_err(|e| { + CliError::InvalidInput(format!( + "Invalid byte value '{b}' in bytearray: {e}" + )) + }) + } + }) + .collect::>>()?; + byte_array_from_bytes(&bytes) + .map_err(|e| CliError::InvalidInput(format!("Invalid bytearray: {e}")))? + } + } else { + // String mode: bytearray:hello or bytearray:"hello world" + // Strip surrounding double quotes if present + let str_value = + if ba_value.starts_with('"') && ba_value.ends_with('"') && ba_value.len() >= 2 { + &ba_value[1..ba_value.len() - 1] + } else { + ba_value + }; + ByteArray::from_string(str_value) + .map_err(|e| CliError::InvalidInput(format!("Invalid bytearray string: {e}")))? + }; + Ok(ByteArray::cairo_serialize(&byte_array)) } else if let Some(str_value) = value.strip_prefix("str:") { // Parse Cairo short string let felt = cairo_short_string_to_felt(str_value) @@ -43,6 +89,44 @@ pub fn parse_calldata_value(value: &str) -> Result> { } } +/// Construct a `ByteArray` from raw bytes, chunking into 31-byte segments. +fn byte_array_from_bytes( + bytes: &[u8], +) -> std::result::Result { + const MAX_WORD_LEN: usize = 31; + let chunks: Vec<_> = bytes.chunks(MAX_WORD_LEN).collect(); + + let remainder = if !bytes.len().is_multiple_of(MAX_WORD_LEN) { + 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(); + (Felt::from_bytes_be_slice(&r), len) + } else { + (Felt::ZERO, 0) + }; + + let mut data = Vec::new(); + for chunk in full_chunks { + data.push(Bytes31::new(Felt::from_bytes_be_slice(chunk))?); + } + + Ok(ByteArray { + data, + pending_word, + pending_word_len, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -134,4 +218,78 @@ mod tests { let result = parse_calldata_value("str:").unwrap(); assert_eq!(result, vec![Felt::from(0_u128)]); } + + #[test] + fn test_parse_bytearray_short_string() { + // "hello" is 5 bytes, fits in pending_word (no full chunks) + let result = parse_calldata_value("bytearray:hello").unwrap(); + // Expected: [data_length=0, pending_word="hello", pending_word_len=5] + assert_eq!(result.len(), 3); + assert_eq!(result[0], Felt::from(0_u128)); // data_length = 0 + assert_eq!(result[1], Felt::from_bytes_be_slice(b"hello")); // pending_word + assert_eq!(result[2], Felt::from(5_u128)); // pending_word_len + } + + #[test] + fn test_parse_bytearray_empty_string() { + let result = parse_calldata_value("bytearray:").unwrap(); + // Expected: [data_length=0, pending_word=0, pending_word_len=0] + assert_eq!( + result, + vec![Felt::from(0_u128), Felt::ZERO, Felt::from(0_u128)] + ); + } + + #[test] + fn test_parse_bytearray_long_string() { + // 35 bytes = 1 full chunk (31 bytes) + 4 bytes pending + let s = "ABCDEFGHIJKLMNOPQRSTUVWXYZ12345ABCD"; + assert_eq!(s.len(), 35); + let result = parse_calldata_value(&format!("bytearray:{s}")).unwrap(); + // Expected: [data_length=1, chunk0, pending_word="ABCD", pending_word_len=4] + assert_eq!(result.len(), 4); + assert_eq!(result[0], Felt::from(1_u128)); // data_length = 1 + assert_eq!( + result[1], + Felt::from_bytes_be_slice(b"ABCDEFGHIJKLMNOPQRSTUVWXYZ12345") + ); // chunk + assert_eq!(result[2], Felt::from_bytes_be_slice(b"ABCD")); // pending_word + assert_eq!(result[3], Felt::from(4_u128)); // pending_word_len + } + + #[test] + fn test_parse_bytearray_raw_bytes() { + // "Hello" = [0x48, 0x65, 0x6c, 0x6c, 0x6f] + let result = parse_calldata_value("bytearray:[0x48,0x65,0x6c,0x6c,0x6f]").unwrap(); + let expected = parse_calldata_value("bytearray:Hello").unwrap(); + assert_eq!(result, expected); + } + + #[test] + fn test_parse_bytearray_raw_bytes_empty() { + let result = parse_calldata_value("bytearray:[]").unwrap(); + assert_eq!( + result, + vec![Felt::from(0_u128), Felt::ZERO, Felt::from(0_u128)] + ); + } + + #[test] + fn test_parse_bytearray_quoted_string() { + // bytearray:"hello world" should encode "hello world" (quotes stripped) + let result = parse_calldata_value("bytearray:\"hello world\"").unwrap(); + assert_eq!(result.len(), 3); + assert_eq!(result[0], Felt::from(0_u128)); // data_length = 0 + assert_eq!(result[1], Felt::from_bytes_be_slice(b"hello world")); // pending_word + assert_eq!(result[2], Felt::from(11_u128)); // pending_word_len + } + + #[test] + fn test_parse_bytearray_quoted_empty() { + let result = parse_calldata_value("bytearray:\"\"").unwrap(); + assert_eq!( + result, + vec![Felt::from(0_u128), Felt::ZERO, Felt::from(0_u128)] + ); + } }