Skip to content
Open
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
158 changes: 151 additions & 7 deletions src/tx/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,22 @@ use anyhow::{bail, Context as _, Result};
use inquire::{Confirm, MultiSelect};
use pallas::ledger::addresses::Address;
use serde_json::{json, Value};
use std::{
collections::{BTreeMap, HashMap},
path::Path,
};
use std::path::Path;

use tx3_sdk::{
core::ArgMap,
tii::{Invocation, ParamMap, ParamType},
tii::{Invocation, ParamType, Protocol},
trp::TxEnvelope,
};

use crate::provider::types::Provider;

const NAMESPACED_BYTES_REF: &str = "https://tx3.land/specs/v1beta0/tii#/$defs/Bytes";
const NAMESPACED_ADDRESS_REF: &str = "https://tx3.land/specs/v1beta0/tii#/$defs/Address";
const NAMESPACED_UTXO_REF: &str = "https://tx3.land/specs/v1beta0/tii#/$defs/UtxoRef";
const CORE_BYTES_REF: &str = "https://tx3.land/specs/v1beta0/core#Bytes";
const CORE_ADDRESS_REF: &str = "https://tx3.land/specs/v1beta0/core#Address";
const CORE_UTXO_REF: &str = "https://tx3.land/specs/v1beta0/core#UtxoRef";

pub fn load_args(
invocation: &mut Invocation,
inline_args: Option<&str>,
Expand Down Expand Up @@ -54,12 +57,50 @@ fn inquire_transaction(protocol: &tx3_sdk::tii::Protocol) -> Result<String> {
Ok(value)
}

fn normalize_tii_ref(reference: &str) -> Option<&'static str> {
match reference {
NAMESPACED_BYTES_REF => Some(CORE_BYTES_REF),
NAMESPACED_ADDRESS_REF => Some(CORE_ADDRESS_REF),
NAMESPACED_UTXO_REF => Some(CORE_UTXO_REF),
_ => None,
}
}

fn normalize_tii_json_refs(value: &mut Value) {
match value {
Value::Object(map) => {
if let Some(reference) = map.get("$ref").and_then(Value::as_str) {
if let Some(normalized) = normalize_tii_ref(reference) {
map.insert("$ref".to_string(), Value::String(normalized.to_string()));
}
}

for nested in map.values_mut() {
normalize_tii_json_refs(nested);
}
}
Value::Array(items) => {
for item in items {
normalize_tii_json_refs(item);
}
}
_ => {}
}
}

fn load_protocol_from_tii_file(tii_file: &Path) -> Result<Protocol> {
let raw = std::fs::read_to_string(tii_file)?;
let mut json: Value = serde_json::from_str(&raw)?;
normalize_tii_json_refs(&mut json);
Protocol::from_json(json).map_err(Into::into)
}

pub fn prepare_invocation(
tii_file: &Path,
tx: Option<&str>,
profile: Option<&str>,
) -> Result<Invocation> {
let protocol = tx3_sdk::tii::Protocol::from_file(tii_file).context("parsing tii file")?;
let protocol = load_protocol_from_tii_file(tii_file).context("parsing tii file")?;

let tx = match tx {
Some(x) => x.to_string(),
Expand Down Expand Up @@ -262,3 +303,106 @@ pub async fn sign_tx(

Ok(cbor)
}

#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::{
fs,
path::PathBuf,
time::{SystemTime, UNIX_EPOCH},
};

fn write_temp_tii_file(name: &str, content: serde_json::Value) -> PathBuf {
let suffix = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = std::env::temp_dir().join(format!("cshell-{name}-{suffix}.tii"));
fs::write(&path, serde_json::to_vec_pretty(&content).unwrap()).unwrap();
path
}

fn sample_tii(bytes_ref: &str, address_ref: &str, utxo_ref: &str) -> serde_json::Value {
json!({
"tii": { "version": "v1beta0" },
"protocol": {
"name": "repro",
"scope": "eryxcoop",
"version": "1.0.0"
},
"parties": {
"user": {}
},
"transactions": {
"demo": {
"params": {
"type": "object",
"properties": {
"payload": { "$ref": bytes_ref },
"recipient": { "$ref": address_ref },
"source_utxo": { "$ref": utxo_ref },
"quantity": { "type": "integer" }
},
"required": ["payload", "recipient", "source_utxo", "quantity"]
},
"tir": {
"content": "",
"encoding": "hex",
"version": "v1beta0"
}
}
},
"profiles": {}
})
}

#[test]
fn prepare_invocation_accepts_legacy_core_refs() {
let path = write_temp_tii_file(
"legacy-core",
sample_tii(CORE_BYTES_REF, CORE_ADDRESS_REF, CORE_UTXO_REF),
);

let result = prepare_invocation(&path, Some("demo"), None);

let _ = fs::remove_file(path);
assert!(result.is_ok());
}

#[test]
fn prepare_invocation_accepts_namespaced_tii_refs() {
let path = write_temp_tii_file(
"namespaced",
sample_tii(
NAMESPACED_BYTES_REF,
NAMESPACED_ADDRESS_REF,
NAMESPACED_UTXO_REF,
),
);

let result = prepare_invocation(&path, Some("demo"), None);

let _ = fs::remove_file(path);
assert!(result.is_ok());
}

#[test]
fn prepare_invocation_still_rejects_unknown_refs() {
let path = write_temp_tii_file(
"unknown-ref",
sample_tii(
CORE_BYTES_REF,
CORE_ADDRESS_REF,
"https://tx3.land/specs/v1beta0/tii#/$defs/Unknown",
),
);

let result = prepare_invocation(&path, Some("demo"), None);

let _ = fs::remove_file(path);
let error = result.expect_err("unknown refs should still fail");
assert!(error.to_string().contains("invalid param type"));
Comment on lines +405 to +406

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does anyhow Error Display (to_string) include source/context chain or only the top-level message, and what does the alternate {:#} format print?

💡 Result:

When using the anyhow crate, the standard Display format (typically triggered by the {} format specifier or to_string) displays only the outermost error message or context [1][2][3]. It does not include the lower-level source or cause chain, behaving identically to the Display implementation of the underlying error type from which the anyhow::Error was constructed [1][3][4]. The alternate format specifier {:#} is used to print the error along with its full cause chain [5][3][4]. This format utilizes anyhow's built-in formatting logic to display the entire chain of underlying errors [6][5]. If you need to access or manually render the source chain without using the {:#} format, you can use the Error::chain method, which provides an iterator over the full sequence of source errors [3][4][7].

Citations:


Fix the error assertion to check anyhow’s full cause chain

anyhow’s to_string() / default {error} formatting prints only the outermost context message (e.g., "parsing tii file"), not the inner "invalid param type" from the source error chain. Use alternate formatting ({error:#}) or iterate error.chain().

🐛 Proposed fix
-        let error = result.expect_err("unknown refs should still fail");
-        assert!(error.to_string().contains("invalid param type"));
+        let error = result.expect_err("unknown refs should still fail");
+        assert!(
+            format!("{error:#}").contains("invalid param type"),
+            "unexpected error: {error:#}"
+        );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let error = result.expect_err("unknown refs should still fail");
assert!(error.to_string().contains("invalid param type"));
let error = result.expect_err("unknown refs should still fail");
assert!(
format!("{error:#}").contains("invalid param type"),
"unexpected error: {error:#}"
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/tx/common.rs` around lines 405 - 406, The assertion uses
error.to_string() which only shows the outermost anyhow context; change the
check to inspect the full error chain (e.g., use alternate formatting
format!("{error:#}") or iterate error.chain() and assert any chain element
contains "invalid param type") so the assertion on the variable `error`
(obtained from result.expect_err) reliably finds the inner cause; update the
assertion around `let error = result.expect_err("unknown refs should still
fail");` to use one of these approaches.

}
}