use async_trait::async_trait;
use serde_json::{json, Value};
use tokio::process::Command;
use super::caatinga::{require_caatinga_project, run_caatinga, validate_source, validate_target};
use super::Tool;
pub struct CaatingaInvokeTool;
#[async_trait]
impl Tool for CaatingaInvokeTool {
fn name(&self) -> &str {
"caatinga_invoke"
}
fn capability(&self) -> crate::risk::Capability {
crate::risk::Capability::Signing
}
fn description(&self) -> &str {
"Invoke a function on a contract deployed by Caatinga, addressed as <contract>.<method> \
using the contract's name from caatinga.config.ts — Caatinga resolves the id from its \
artifacts, so no contract id is passed here. This signs and submits; use read for a \
read-only call. Only works in a Caatinga project."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"target": {
"type": "string",
"description": "The call, as <contract>.<method> — e.g. 'token.transfer'. The contract name comes from caatinga.config.ts, not a contract id."
},
"args": {
"type": "array",
"items": {
"type": "string"
},
"description": "Arguments forwarded to the Stellar CLI after the method name, in order (e.g. [\"--to\", \"alice\", \"--amount\", \"100\"])"
},
"network": {
"type": "string",
"description": "Network name as configured in caatinga.config.ts (e.g. testnet). Required: this signs and submits, so it must not rely on a default. Mainnet is refused unless the operator enabled it."
},
"source": {
"type": "string",
"description": "Stellar CLI identity alias that signs, e.g. 'alice'. Never a secret key, seed phrase or raw address."
}
},
"required": ["target", "network"]
})
}
async fn execute(&self, input: Value) -> Result<String, String> {
require_caatinga_project().await?;
let target = input
.get("target")
.and_then(|v| v.as_str())
.ok_or("Missing 'target' parameter (expected <contract>.<method>)")?;
validate_target(target)?;
let mut args = vec!["invoke".to_string(), target.to_string()];
let network =
super::mainnet::resolve_signing_network(input.get("network").and_then(|v| v.as_str()))?;
args.push("--network".to_string());
args.push(network);
if let Some(source) = input.get("source").and_then(|v| v.as_str()) {
validate_source(source)?;
args.push("--source".to_string());
args.push(source.to_string());
}
if let Some(fn_args) = input.get("args").and_then(|v| v.as_array()) {
for arg in fn_args {
if let Some(arg_str) = arg.as_str() {
args.push(arg_str.to_string());
}
}
}
let stdout = run_caatinga(&args).await?;
Ok(format!("Invocation successful.\n\n{}", stdout))
}
}
pub struct CaatingaReadTool;
#[async_trait]
impl Tool for CaatingaReadTool {
fn name(&self) -> &str {
"caatinga_read"
}
fn capability(&self) -> crate::risk::Capability {
crate::risk::Capability::ReadOnly
}
fn description(&self) -> &str {
"Simulate a read-only function on a contract Caatinga deployed, addressed as \
<contract>.<method>. Nothing is signed and nothing is submitted, so it costs no fees and \
changes no state — prefer this over caatinga_invoke whenever you only need to read a \
value. Only works in a Caatinga project."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"target": {
"type": "string",
"description": "The call, as <contract>.<method> — e.g. 'token.balance'. The contract name comes from caatinga.config.ts, not a contract id."
},
"args": {
"type": "array",
"items": {
"type": "string"
},
"description": "Arguments forwarded to the Stellar CLI after the method name, in order"
},
"network": {
"type": "string",
"description": "Network name as configured in caatinga.config.ts (e.g. testnet)"
},
"source": {
"type": "string",
"description": "Stellar CLI identity alias used only as simulation context, e.g. 'alice'. Nothing is signed. Never a secret key, seed phrase or raw address."
},
"summary": {
"type": "boolean",
"description": "Print a compact summary instead of a large array payload in full"
}
},
"required": ["target"]
})
}
async fn execute(&self, input: Value) -> Result<String, String> {
require_caatinga_project().await?;
let target = input
.get("target")
.and_then(|v| v.as_str())
.ok_or("Missing 'target' parameter (expected <contract>.<method>)")?;
validate_target(target)?;
let mut args = vec!["read".to_string(), target.to_string()];
if let Some(network) = input.get("network").and_then(|v| v.as_str()) {
args.push("--network".to_string());
args.push(network.to_string());
}
if let Some(source) = input.get("source").and_then(|v| v.as_str()) {
validate_source(source)?;
args.push("--source".to_string());
args.push(source.to_string());
}
if input.get("summary").and_then(|v| v.as_bool()) == Some(true) {
args.push("--summary".to_string());
}
if let Some(fn_args) = input.get("args").and_then(|v| v.as_array()) {
for arg in fn_args {
if let Some(arg_str) = arg.as_str() {
args.push(arg_str.to_string());
}
}
}
let stdout = run_caatinga(&args).await?;
Ok(format!(
"Read (simulated, nothing submitted).\n\n{}",
stdout
))
}
}
pub struct StellarCliInvokeTool;
#[async_trait]
impl Tool for StellarCliInvokeTool {
fn name(&self) -> &str {
"stellar_invoke"
}
fn capability(&self) -> crate::risk::Capability {
crate::risk::Capability::Signing
}
fn description(&self) -> &str {
"Invoke a contract by raw id using stellar-cli. For a contract Caatinga deployed, prefer \
caatinga_invoke: this path takes an id rather than a name, so it neither reads nor \
updates caatinga.artifacts.json. Use it for a contract Caatinga does not manage, or a \
feature it does not support."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"contract_id": {
"type": "string",
"description": "The contract ID to invoke"
},
"fn_name": {
"type": "string",
"description": "The function name to call"
},
"args": {
"type": "array",
"items": {
"type": "string"
},
"description": "Arguments to pass (format: --arg value)"
},
"network": {
"type": "string",
"enum": ["local", "testnet", "mainnet"],
"description": "Network to use. Required: this signs and submits. Mainnet is refused unless the operator enabled it."
},
"source": {
"type": "string",
"description": "Stellar CLI identity alias that signs, e.g. 'alice'. Never a secret key, seed phrase or raw address."
}
},
"required": ["contract_id", "fn_name", "network"]
})
}
async fn execute(&self, input: Value) -> Result<String, String> {
let stellar_available = Command::new("stellar")
.arg("--version")
.output()
.await
.map(|o| o.status.success())
.unwrap_or(false);
if !stellar_available {
return Err("stellar-cli is not installed".to_string());
}
let contract_id = input
.get("contract_id")
.and_then(|v| v.as_str())
.ok_or("Missing 'contract_id' parameter")?;
let fn_name = input
.get("fn_name")
.and_then(|v| v.as_str())
.ok_or("Missing 'fn_name' parameter")?;
let source = input
.get("source")
.and_then(|v| v.as_str())
.unwrap_or("default");
validate_source(source)?;
let network =
super::mainnet::resolve_signing_network(input.get("network").and_then(|v| v.as_str()))?;
let mut args = vec![
"contract".to_string(),
"invoke".to_string(),
"--id".to_string(),
contract_id.to_string(),
"--network".to_string(),
network.to_string(),
"--source".to_string(),
source.to_string(),
"--".to_string(),
fn_name.to_string(),
];
if let Some(fn_args) = input.get("args").and_then(|v| v.as_array()) {
for arg in fn_args {
if let Some(arg_str) = arg.as_str() {
args.push(arg_str.to_string());
}
}
}
let output = Command::new("stellar")
.args(&args)
.output()
.await
.map_err(|e| format!("Failed to execute stellar invoke: {}", e))?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
if output.status.success() {
let mut result = "Invocation successful!\n".to_string();
if !stdout.is_empty() {
result.push_str(&format!("\nResult:\n{}", stdout));
}
Ok(result)
} else {
Err(format!(
"Invocation failed (exit code: {})\n\nstdout:\n{}\n\nstderr:\n{}",
output.status.code().unwrap_or(-1),
stdout,
stderr
))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::ToolRegistry;
#[test]
fn read_advertises_itself_as_costing_nothing() {
let description = CaatingaReadTool.description();
assert!(description.contains("Nothing is signed"), "{}", description);
assert!(description.contains("no fees"), "{}", description);
}
#[test]
fn neither_call_tool_accepts_a_contract_id() {
for schema in [
CaatingaInvokeTool.input_schema(),
CaatingaReadTool.input_schema(),
] {
let props = schema["properties"].as_object().unwrap();
assert!(props.contains_key("target"));
assert!(
!props.contains_key("contract_id"),
"a contract id must come from the artifacts, not the caller"
);
}
}
#[tokio::test]
async fn read_refuses_a_project_without_a_caatinga_config() {
let err = CaatingaReadTool
.execute(json!({"target": "token.balance"}))
.await
.expect_err("read must refuse a non-Caatinga project");
assert!(err.contains("not a Caatinga project"), "{}", err);
}
#[tokio::test]
async fn read_refuses_a_target_that_is_not_contract_dot_method() {
let err = CaatingaReadTool
.execute(json!({"target": "balance"}))
.await
.expect_err("read must refuse a bare method");
assert!(
err.contains("<contract>.<method>") || err.contains("not a Caatinga project"),
"got {}",
err
);
}
#[tokio::test]
async fn the_stellar_fallback_also_refuses_a_secret_source() {
let secret = "SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEXAMPLE";
let err = StellarCliInvokeTool
.execute(json!({
"contract_id": "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC",
"fn_name": "balance",
"network": "testnet",
"source": secret
}))
.await
.expect_err("a secret key must be refused on any path");
assert!(
err.contains("secret key") || err.contains("not installed"),
"got {}",
err
);
assert!(!err.contains(secret), "the error must not echo the secret");
}
#[test]
fn every_signing_tool_requires_its_network() {
for schema in [
CaatingaInvokeTool.input_schema(),
StellarCliInvokeTool.input_schema(),
] {
let required = schema["required"].as_array().unwrap();
assert!(
required.contains(&json!("network")),
"a tool that submits must not fall back to a default network: {}",
schema
);
}
}
#[test]
fn read_does_not_require_a_network() {
let schema = CaatingaReadTool.input_schema();
let required = schema["required"].as_array().unwrap();
assert!(!required.contains(&json!("network")));
}
#[tokio::test]
async fn the_new_tools_register() {
let mut registry = ToolRegistry::new();
registry.register(Box::new(CaatingaReadTool));
registry.register(Box::new(CaatingaInvokeTool));
assert!(registry.get_tool("caatinga_read").is_some());
assert!(registry.get_tool("caatinga_invoke").is_some());
}
}