Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 28 additions & 5 deletions spel-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ pub fn print_help(idl: &SpelIdl, binary_name: &str) {
println!(" [u8; N] Hex string (2*N hex chars) or UTF-8 string (≤N chars, right-padded)");
println!(" [u32; 8] / program_id Comma-separated u32s: \"0,0,0,0,0,0,0,0\"");
println!(" Vec<[u8; 32]> Comma-separated hex strings: \"aabb...00,ccdd...00\"");
println!(" Vec<String> Repeat the flag, one element per occurrence: --foo a --foo b --foo c");
println!();
println!("CONFIG:");
println!(" Create a spel.toml in your project root to avoid passing --idl and --program:");
Expand Down Expand Up @@ -126,18 +127,40 @@ pub fn print_instruction_help(ix: &IdlInstruction) {
}
}

/// Parse CLI args for an instruction into a key-value map.
pub fn parse_instruction_args(args: &[String], ix: &IdlInstruction) -> HashMap<String, String> {
let mut map = HashMap::new();
/// Parse CLI args for an instruction into a key → list-of-values map.
///
/// Each `--key value` occurrence is appended to the entry for `key`, so a
/// flag may be repeated. Scalar args take the last value; `Vec<String>`
/// args consume every value supplied. A bare `--flag` with no value is
/// only legal for boolean args (per the IDL) or the universal `--help`/
/// `-h`; for anything else we exit with a `missing value` error so a
/// missing CLI value can't be silently stored as the literal "true".
pub fn parse_instruction_args(
args: &[String],
ix: &IdlInstruction,
) -> HashMap<String, Vec<String>> {
let mut map: HashMap<String, Vec<String>> = HashMap::new();
let mut i = 0;
while i < args.len() {
if args[i].starts_with("--") {
let key = args[i][2..].to_string();
if i + 1 < args.len() && !args[i + 1].starts_with("--") {
map.insert(key, args[i + 1].clone());
map.entry(key).or_default().push(args[i + 1].clone());
i += 2;
} else {
map.insert(key, "true".to_string());
// Bare flag (no value follows). Only valid for bool args
// or --help/-h; everything else is a user error.
let is_bool = ix.args.iter().any(|a| {
snake_to_kebab(&a.name) == key
&& matches!(&a.type_, IdlType::Primitive(p) if p == "bool")
});
let is_help = key == "help" || key == "h";
if is_bool || is_help {
map.entry(key).or_default().push("true".to_string());
} else {
eprintln!("❌ --{}: missing value", key);
std::process::exit(1);
}
i += 1;
}
Comment on lines 145 to 165
} else {
Expand Down
50 changes: 50 additions & 0 deletions spel-cli/src/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ pub enum ParsedValue {
ByteArray(Vec<u8>), // [u8; N]
U32Array(Vec<u32>), // [u32; N] / ProgramId
ByteArrayVec(Vec<Vec<u8>>), // Vec<[u8; 32]>
StringVec(Vec<String>), // Vec<String>
None, // Option::None
Some(Box<ParsedValue>), // Option::Some
Raw(String), // fallback
Expand Down Expand Up @@ -49,6 +50,10 @@ impl std::fmt::Display for ParsedValue {
.collect();
write!(f, "[{}]", strs.join(", "))
},
ParsedValue::StringVec(strs) => {
let quoted: Vec<String> = strs.iter().map(|s| format!("\"{}\"", s)).collect();
write!(f, "[{}]", quoted.join(", "))
},
ParsedValue::None => write!(f, "None"),
ParsedValue::Some(inner) => write!(f, "Some({})", inner),
ParsedValue::Raw(s) => write!(f, "{}", s),
Expand Down Expand Up @@ -247,6 +252,14 @@ fn parse_vec(raw: &str, elem_type: &IdlType) -> Result<ParsedValue, String> {
}
}

/// Build a `ParsedValue::StringVec` from one or more repeated `--flag <value>`
/// occurrences on the CLI. The caller is expected to have collected every
/// occurrence of the flag — empty input yields an empty vec, matching the
/// IDL contract `Vec<String>`.
pub fn parse_string_vec(values: &[String]) -> ParsedValue {
ParsedValue::StringVec(values.to_vec())
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -277,6 +290,43 @@ mod tests {
}
}

#[test]
fn parse_string_vec_multiple_values() {
let parsed = parse_string_vec(&["foo".to_string(), "bar".to_string(), "baz".to_string()]);
match parsed {
ParsedValue::StringVec(v) => assert_eq!(v, vec!["foo", "bar", "baz"]),
other => panic!("expected StringVec, got {:?}", other),
}
}

#[test]
fn parse_string_vec_empty_input_yields_empty_vec() {
match parse_string_vec(&[]) {
ParsedValue::StringVec(v) => assert!(v.is_empty()),
other => panic!("expected empty StringVec, got {:?}", other),
}
}

#[test]
fn parse_string_vec_single_element_yields_singleton() {
match parse_string_vec(&["bafybeionly".to_string()]) {
ParsedValue::StringVec(v) => assert_eq!(v, vec!["bafybeionly"]),
other => panic!("expected StringVec, got {:?}", other),
}
}

#[test]
fn parse_string_vec_preserves_commas_in_elements() {
// Repetition contract: an element containing a comma is one element,
// never split. This is the user-facing difference from the previous
// CSV approach.
let parsed = parse_string_vec(&["foo,bar".to_string(), "baz".to_string()]);
match parsed {
ParsedValue::StringVec(v) => assert_eq!(v, vec!["foo,bar", "baz"]),
other => panic!("expected StringVec, got {:?}", other),
}
}

#[test]
fn primitive_bytes32_string_does_not_parse_as_byte_array() {
// This is the bug: the macro emits Primitive("[u8; 32]") which
Expand Down
58 changes: 57 additions & 1 deletion spel-cli/src/serialize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,11 @@ fn to_dynamic_value(ty: &IdlType, val: &ParsedValue) -> Result<DynamicValue, Ser
.collect();
Ok(DynamicValue::Seq(elements?))
},
(IdlType::Vec { vec: elem_ty }, ParsedValue::StringVec(strs)) if matches!(elem_ty.as_ref(), IdlType::Primitive(p) if p == "string" || p == "String") => {
Ok(DynamicValue::Seq(
strs.iter().cloned().map(DynamicValue::Str).collect(),
))
},
(IdlType::Vec { vec }, ParsedValue::Raw(s)) if matches!(vec.as_ref(), IdlType::Primitive(p) if p == "u32") =>
{
// Fallback: parse CSV of u32 values (e.g. "0,200,0,0,0")
Expand Down Expand Up @@ -173,7 +178,7 @@ pub fn serialize_to_risc0(
#[cfg(test)]
mod tests {
use super::*;
use crate::parse::parse_value;
use crate::parse::{parse_string_vec, parse_value};
use risc0_zkvm::serde::Deserializer;
use serde::Deserialize;
use spel_framework_core::idl::IdlType;
Expand Down Expand Up @@ -402,6 +407,57 @@ mod tests {
assert_eq!(words, vec![3, 1, 2, 3]); // length=3, then values
}

#[test]
fn to_dynamic_value_vec_string_emits_seq_of_str() {
let ty = IdlType::Vec {
vec: Box::new(IdlType::Primitive("string".to_string())),
};
let parsed = parse_string_vec(&["foo".to_string(), "bar".to_string(), "baz".to_string()]);
let dv = to_dynamic_value(&ty, &parsed).unwrap();
let words = risc0_zkvm::serde::to_vec(&dv).unwrap();
// Seq of 3 strings: length=3, then each string is its own length + bytes
// (one u32 word per byte, then padded to next word boundary).
// We assert the length prefix here and rely on serde_roundtrip_vec_string
// for the full bit-level contract check.
assert_eq!(words[0], 3, "Seq length prefix");
}

/// The critical contract test for Vec<String>: bytes the CLI emits must
/// deserialize as Vec<String> via the same risc0 Deserializer the guest uses.
#[test]
fn serde_roundtrip_vec_string() {
#[derive(Deserialize, Debug, PartialEq)]
enum TestInstruction {
AnchorBatch { cids: Vec<String> },
}

let ty = IdlType::Vec {
vec: Box::new(IdlType::Primitive("string".to_string())),
};
let parsed = parse_string_vec(&[
"bafy1".to_string(),
"bafy2".to_string(),
"bafy3".to_string(),
]);

let words = serialize_to_risc0(0, &[(&ty, &parsed)]).unwrap();

let instruction: TestInstruction =
TestInstruction::deserialize(&mut Deserializer::new(words.as_ref()))
.expect("guest-side Vec<String> deserialization must succeed");

assert_eq!(
instruction,
TestInstruction::AnchorBatch {
cids: vec![
"bafy1".to_string(),
"bafy2".to_string(),
"bafy3".to_string()
],
}
);
}

#[test]
fn to_dynamic_value_type_mismatch_returns_err() {
let ty = IdlType::Primitive("u8".to_string());
Expand Down
43 changes: 33 additions & 10 deletions spel-cli/src/tx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

use crate::cli::{snake_to_kebab, to_pascal_case};
use crate::hex::{decode_bytes_32, hex_encode, parse_account_id};
use crate::parse::{parse_value, ParsedValue};
use crate::parse::{parse_string_vec, parse_value, ParsedValue};
use crate::pda::compute_pda_from_seeds;
use crate::serialize::serialize_to_risc0;
use common::transaction::NSSATransaction;
Expand All @@ -14,7 +14,7 @@ use nssa_core::account::Nonce;
use nssa_core::program::ProgramId;
use sequencer_service_rpc::RpcClient as _;
use serde_json::{json, Value};
use spel_framework_core::idl::{IdlInstruction, IdlSeed, SpelIdl};
use spel_framework_core::idl::{IdlInstruction, IdlSeed, IdlType, SpelIdl};
use std::collections::HashMap;
use std::fs;
use std::process;
Expand Down Expand Up @@ -48,10 +48,26 @@ const UNRESOLVED: &str = "(unresolved)";
/// - `None` — submit to the sequencer
/// - `Some(DryRunFormat::Text)` — resolve & print a human-readable summary
/// - `Some(DryRunFormat::Json)` — resolve & emit JSON to stdout
/// Pull the most-recently-supplied value for `key`. Scalar flags consume
/// the last `--key value` they see; `Vec<String>` args bypass this helper
/// and consume the whole vec.
fn last_value<'a>(map: &'a HashMap<String, Vec<String>>, key: &str) -> Option<&'a str> {
map.get(key).and_then(|v| v.last().map(|s| s.as_str()))
}

/// True if this IDL type is `Vec<String>` — the one shape that opts in to
/// flag repetition on the CLI.
fn is_vec_string(ty: &IdlType) -> bool {
matches!(
ty,
IdlType::Vec { vec } if matches!(vec.as_ref(), IdlType::Primitive(p) if p == "string" || p == "String"),
)
}

pub async fn execute_instruction(
idl: &SpelIdl,
ix: &IdlInstruction,
args: &HashMap<String, String>,
args: &HashMap<String, Vec<String>>,
program_path: Option<&str>,
program_id_hex: Option<&str>,
dry_run: Option<DryRunFormat>,
Expand All @@ -75,7 +91,7 @@ pub async fn execute_instruction(
let id_str: Vec<String> = id.iter().map(|w| w.to_string()).collect();
let val = id_str.join(",");
say!(" ℹ️ Auto-filled --{} from {}", key, bin_path);
args.insert(key.clone(), val);
args.insert(key.clone(), vec![val]);
}
}
}
Expand Down Expand Up @@ -103,13 +119,20 @@ pub async fn execute_instruction(
process::exit(1);
}

// Parse instruction args
// Parse instruction args.
// Vec<String> is the one shape that consumes every repeated --flag
// value; every other type takes the last occurrence (scalar semantics).
let mut parsed_args: Vec<(&str, &spel_framework_core::idl::IdlType, ParsedValue)> = Vec::new();
let mut has_errors = false;
for arg in &ix.args {
let key = snake_to_kebab(&arg.name);
let raw = args.get(&key).unwrap();
match parse_value(raw, &arg.type_) {
let values = args.get(&key).unwrap();
let result = if is_vec_string(&arg.type_) {
Ok(parse_string_vec(values))
} else {
parse_value(values.last().map(|s| s.as_str()).unwrap_or(""), &arg.type_)
};
match result {
Ok(val) => parsed_args.push((&arg.name, &arg.type_, val)),
Err(e) => {
eprintln!("❌ --{}: {}", key, e);
Expand All @@ -131,7 +154,7 @@ pub async fn execute_instruction(
// variadic: optional, comma-separated list of account IDs (0 entries is valid).
// Failed entries are skipped (not pushed as placeholders) so downstream
// consumers never see a zero-length Vec where a 32-byte AccountId is expected.
let entries: Vec<(Vec<u8>, bool)> = if let Some(raw) = args.get(&key) {
let entries: Vec<(Vec<u8>, bool)> = if let Some(raw) = last_value(&args, &key) {
raw.split(',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
Expand All @@ -149,7 +172,7 @@ pub async fn execute_instruction(
};
rest_accounts.push((&acc.name, entries));
} else {
let raw = args.get(&key).unwrap();
let raw = last_value(&args, &key).unwrap();
match parse_account_id(raw) {
Ok((bytes, is_priv)) => parsed_accounts.push((&acc.name, bytes.to_vec(), is_priv)),
Err(e) => {
Expand Down Expand Up @@ -239,7 +262,7 @@ pub async fn execute_instruction(
if let IdlSeed::Account { path } = seed {
if !account_map.contains_key(path) {
let key = snake_to_kebab(path);
if let Some(raw) = args.get(&key) {
if let Some(raw) = last_value(&args, &key) {
match decode_bytes_32(raw) {
Ok(bytes) => {
say!(" ℹ️ Using --{} for PDA seed '{}'", key, path);
Expand Down