Skip to content
Closed
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
275 changes: 267 additions & 8 deletions cmd/soroban-cli/src/commands/tx/edit.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
use std::{
collections::HashMap,
env,
fs::{self},
io::{stdin, Cursor, IsTerminal},
path::PathBuf,
process::{self},
};

use tempfile::TempDir;

use serde_json::json;
use soroban_spec_tools::contract::Spec;
use stellar_xdr::ScSpecEntry;
use tempfile::TempDir;

use crate::{commands::global, print::Print, utils::XDR_DEPTH_LIMIT};
use crate::{commands::global, print::Print, utils::XDR_DEPTH_LIMIT, wasm};

fn schema_url() -> String {
let ver = stellar_xdr::VERSION.pkg;
Expand All @@ -31,6 +33,9 @@ pub enum Error {
#[error(transparent)]
Base64Decode(#[from] base64::DecodeError),

#[error(transparent)]
Wasm(#[from] wasm::Error),

#[error("Editor returned non-zero status")]
EditorNonZeroStatus,
}
Expand All @@ -39,18 +44,23 @@ pub enum Error {
/// e.g. `stellar tx new manage-data --data-name hello --build-only | stellar tx edit`
#[derive(Debug, clap::Parser, Clone, Default)]
#[group(skip)]
pub struct Cmd {}
pub struct Cmd {
/// Optional path(s) to contract WASM file(s) used to annotate `invoke_contract` arguments with parameter names in JSONC comments
#[arg(long)]
pub wasm: Vec<PathBuf>,
}

impl Cmd {
pub fn run(&self, global_args: &global::Args) -> Result<(), Error> {
let print = Print::new(global_args.quiet);
let fn_params = self.load_fn_param_names()?;
let json: String = if stdin().is_terminal() {
default_json()
} else {
let mut input = String::new();
stdin().read_line(&mut input)?;
let input = input.trim();
xdr_to_json::<stellar_xdr::TransactionEnvelope>(input)?
xdr_to_json_with_specs::<stellar_xdr::TransactionEnvelope>(input, &fn_params)?
};

let (_temp_dir, path) = tmp_file(&json)?;
Expand All @@ -66,6 +76,185 @@ impl Cmd {

Ok(())
}

fn load_fn_param_names(&self) -> Result<HashMap<String, Vec<String>>, Error> {
let mut map = HashMap::new();
for wasm_path in &self.wasm {
let wasm_args = wasm::Args {
wasm: wasm_path.clone(),
};
let Spec { spec, .. } = wasm_args.parse()?;
for (fn_name, params) in extract_fn_param_names(&spec) {
map.insert(fn_name, params);
}
}
Ok(map)
}
}

pub fn extract_fn_param_names(entries: &[ScSpecEntry]) -> HashMap<String, Vec<String>> {
let mut map = HashMap::new();
for entry in entries {
if let ScSpecEntry::FunctionV0(func) = entry {
let fn_name = func.name.to_utf8_string_lossy();
let params = func
.inputs
.iter()
.map(|input| input.name.to_utf8_string_lossy())
.collect::<Vec<_>>();
map.insert(fn_name, params);
}
}
map
}

pub fn strip_jsonc_comments(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let chars: Vec<char> = input.chars().collect();
let len = chars.len();
let mut i = 0;
let mut in_string = false;

while i < len {
let c = chars[i];
if in_string {
out.push(c);
if c == '\\' && i + 1 < len {
out.push(chars[i + 1]);
i += 2;
continue;
}
if c == '"' {
in_string = false;
}
i += 1;
} else if c == '"' {
in_string = true;
out.push(c);
i += 1;
} else if c == '/' && i + 1 < len && chars[i + 1] == '/' {
i += 2;
while i < len && chars[i] != '\n' {
i += 1;
}
} else if c == '/' && i + 1 < len && chars[i + 1] == '*' {
i += 2;
while i + 1 < len && !(chars[i] == '*' && chars[i + 1] == '/') {
i += 1;
}
if i + 1 < len {
i += 2;
}
} else {
out.push(c);
i += 1;
}
}

out
}

pub fn annotate_invoke_contract_args(
json: &str,
fn_params: &HashMap<String, Vec<String>>,
) -> String {
if fn_params.is_empty() {
return json.to_string();
}

let lines: Vec<&str> = json.lines().collect();
let mut out_lines: Vec<String> = Vec::with_capacity(lines.len());
let mut current_fn: Option<String> = None;
let mut in_args = false;
let mut args_depth: i32 = 0;
let mut arg_index: usize = 0;

for line in lines {
let trimmed = line.trim();

if !in_args {
if let Some(rest) = trimmed.strip_prefix("\"function_name\":") {
let val = rest.trim().trim_end_matches(',').trim();
if val.starts_with('"') && val.ends_with('"') && val.len() >= 2 {
current_fn = Some(val[1..val.len() - 1].to_string());
}
} else if trimmed.starts_with("\"args\": [") && current_fn.is_some() {
if !trimmed.ends_with(']') && !trimmed.ends_with("],") {
in_args = true;
args_depth = 0;
arg_index = 0;
} else {
current_fn = None;
}
}
out_lines.push(line.to_string());
continue;
}

// Inside `"args": [`
if args_depth == 0 && (trimmed == "]" || trimmed == "],") {
in_args = false;
current_fn = None;
out_lines.push(line.to_string());
continue;
}

let delta = bracket_delta(trimmed);
let prev_depth = args_depth;
args_depth += delta;

let finished_top_level_arg = prev_depth >= 0 && args_depth == 0;

if finished_top_level_arg {
let comment = current_fn
.as_ref()
.and_then(|fn_name| fn_params.get(fn_name))
.and_then(|params| params.get(arg_index));
arg_index += 1;

if let Some(param_name) = comment {
out_lines.push(format!("{line} // {param_name}"));
} else {
out_lines.push(line.to_string());
}
} else {
out_lines.push(line.to_string());
}
}

let mut result = out_lines.join("\n");
if json.ends_with('\n') {
result.push('\n');
}
result
}

fn bracket_delta(line: &str) -> i32 {
let mut delta = 0i32;
let mut in_string = false;
let chars: Vec<char> = line.chars().collect();
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if in_string {
if c == '\\' {
i += 2;
continue;
}
if c == '"' {
in_string = false;
}
} else {
match c {
'"' => in_string = true,
'{' | '[' => delta += 1,
'}' | ']' => delta -= 1,
_ => {}
}
}
i += 1;
}
delta
}

struct Editor {
Expand Down Expand Up @@ -146,7 +335,10 @@ fn open_editor(print: &Print, editor: &Editor, path: &PathBuf) -> Result<(), Err
}
}

fn xdr_to_json<T>(xdr_string: &str) -> Result<String, Error>
pub fn xdr_to_json_with_specs<T>(
xdr_string: &str,
fn_params: &HashMap<String, Vec<String>>,
) -> Result<String, Error>
where
T: stellar_xdr::ReadXdr + serde::Serialize,
{
Expand All @@ -155,14 +347,15 @@ where
schema["$schema"] = json!(schema_url());
let json = serde_json::to_string_pretty(&schema)?;

Ok(json)
Ok(annotate_invoke_contract_args(&json, fn_params))
}

fn json_to_xdr<T>(json_string: &str) -> Result<String, Error>
where
T: serde::de::DeserializeOwned + stellar_xdr::WriteXdr,
{
let mut schema: serde_json::Value = serde_json::from_str(json_string)?;
let stripped = strip_jsonc_comments(json_string);
let mut schema: serde_json::Value = serde_json::from_str(&stripped)?;

if let Some(obj) = schema.as_object_mut() {
obj.remove("$schema");
Expand Down Expand Up @@ -204,6 +397,7 @@ fn default_json() -> String {
#[cfg(test)]
mod tests {
use super::*;
use stellar_xdr::{ScSpecFunctionInputV0, ScSpecFunctionV0, ScSpecTypeDef, StringM, VecM};

#[test]
fn tmp_file_uses_private_tempdir() {
Expand Down Expand Up @@ -234,4 +428,69 @@ mod tests {
let dir_mode = dir_meta.permissions().mode() & 0o777;
assert_eq!(dir_mode, 0o700, "tempdir permissions should be 0o700");
}

#[test]
fn strip_jsonc_comments_preserves_urls_and_removes_comments() {
let jsonc = r#"{
"$schema": "https://stellar.org/schema/xdr-json/v23/TransactionEnvelope.json", // schema url
/* block comment */
"value": 42 // answer
}"#;
let stripped = strip_jsonc_comments(jsonc);
let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
assert_eq!(
parsed["$schema"],
"https://stellar.org/schema/xdr-json/v23/TransactionEnvelope.json"
);
assert_eq!(parsed["value"], 42);
}

#[test]
fn annotate_invoke_contract_args_injects_param_names() {
let spec_entries = vec![ScSpecEntry::FunctionV0(ScSpecFunctionV0 {
doc: StringM::default(),
name: "add_liquidity".try_into().unwrap(),
inputs: VecM::try_from(vec![
ScSpecFunctionInputV0 {
doc: StringM::default(),
name: "token_a".try_into().unwrap(),
type_: ScSpecTypeDef::Address,
},
ScSpecFunctionInputV0 {
doc: StringM::default(),
name: "amount_a".try_into().unwrap(),
type_: ScSpecTypeDef::I128,
},
])
.unwrap(),
outputs: VecM::default(),
})];

let fn_params = extract_fn_param_names(&spec_entries);
let raw_json = r#"{
"invoke_contract": {
"contract_address": "CAG5LRYQ5JVEUI5TEID72EYOVX44TTUJT5BQR2J6J77FH65PCCFAJDDH",
"function_name": "add_liquidity",
"args": [
{
"address": "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75"
},
{
"i128": "1000000000"
}
]
}
}"#;

let annotated = annotate_invoke_contract_args(raw_json, &fn_params);
assert!(annotated.contains("}, // token_a"));
assert!(annotated.contains("} // amount_a"));

let stripped = strip_jsonc_comments(&annotated);
let reparsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
assert_eq!(
reparsed["invoke_contract"]["function_name"],
"add_liquidity"
);
}
}