use async_trait::async_trait;
use serde_json::{json, Value};
use std::str::FromStr;
use super::{is_contract_id, Tool};
use crate::account::get_network_rpc_url;
use crate::project::Network;
const LEDGER_WINDOW: u64 = 8000;
fn network_rpc_url(network: &str) -> Result<&'static str, String> {
let network = Network::from_str(network)?;
get_network_rpc_url(&network).map_err(|e| e.to_string())
}
fn read_limit(input: &Value) -> u32 {
input
.get("limit")
.and_then(|v| v.as_u64())
.unwrap_or(10)
.clamp(1, 200) as u32
}
async fn rpc_call(rpc_url: &str, method: &str, params: Value) -> Result<Value, String> {
let response = reqwest::Client::new()
.post(rpc_url)
.header("content-type", "application/json")
.json(&json!({"jsonrpc": "2.0", "id": 1, "method": method, "params": params}))
.send()
.await
.map_err(|e| format!("Failed to reach {}: {}", rpc_url, e))?;
let body: Value = response
.json()
.await
.map_err(|e| format!("Failed to parse {} response: {}", method, e))?;
if let Some(error) = body.get("error") {
return Err(format!("RPC error on {}: {}", method, error));
}
Ok(body)
}
async fn fetch_contract_events(
rpc_url: &str,
contract_id: &str,
limit: u32,
) -> Result<Vec<Value>, String> {
let latest = rpc_call(rpc_url, "getLatestLedger", json!({})).await?["result"]["sequence"]
.as_u64()
.ok_or("RPC did not report a latest ledger")?;
let body = rpc_call(
rpc_url,
"getEvents",
json!({
"startLedger": latest.saturating_sub(LEDGER_WINDOW).max(1),
"filters": [{"type": "contract", "contractIds": [contract_id]}],
"pagination": {"limit": limit},
"xdrFormat": "json"
}),
)
.await?;
Ok(body["result"]["events"]
.as_array()
.cloned()
.unwrap_or_default())
}
fn short_hash(event: &Value) -> String {
event["txHash"]
.as_str()
.unwrap_or("unknown")
.chars()
.take(16)
.collect()
}
fn topic_text(event: &Value) -> String {
event["topicJson"]
.as_array()
.map(|topics| {
topics
.iter()
.map(|t| t.to_string())
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_default()
}
fn format_event(index: usize, event: &Value) -> String {
format!(
"{}. Ledger: {} | Tx: {}\n Topics: [{}]\n Value: {}\n\n",
index + 1,
event["ledger"].as_u64().unwrap_or(0),
short_hash(event),
topic_text(event),
event["valueJson"]
)
}
pub struct SubscribeEventsTool;
#[async_trait]
impl Tool for SubscribeEventsTool {
fn name(&self) -> &str {
"get_contract_events"
}
fn capability(&self) -> crate::risk::Capability {
crate::risk::Capability::ReadOnly
}
fn description(&self) -> &str {
"Fetch recent events emitted by a contract from the most recent ledger window"
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"contract_id": {
"type": "string",
"description": "Contract ID to fetch events for"
},
"limit": {
"type": "integer",
"description": "Maximum number of events to return (1-200, default: 10)"
},
"network": {
"type": "string",
"enum": ["local", "testnet", "mainnet"],
"description": "Network to query (default: testnet)"
}
},
"required": ["contract_id"]
})
}
async fn execute(&self, input: Value) -> Result<String, String> {
let contract_id = input
.get("contract_id")
.and_then(|v| v.as_str())
.ok_or("Missing 'contract_id' parameter")?;
if !is_contract_id(contract_id) {
return Err(format!("Not a valid contract id: {}", contract_id));
}
let network = input
.get("network")
.and_then(|v| v.as_str())
.unwrap_or("testnet");
let rpc_url = network_rpc_url(network)?;
let events = fetch_contract_events(rpc_url, contract_id, read_limit(&input)).await?;
if events.is_empty() {
return Ok(format!(
"No events found for contract {} in the last {} ledgers on {}",
contract_id, LEDGER_WINDOW, network
));
}
let mut output = format!("Events for {} ({}):\n\n", contract_id, network);
for (i, event) in events.iter().enumerate() {
output.push_str(&format_event(i, event));
}
Ok(output)
}
}
pub struct FilterEventsTool;
#[async_trait]
impl Tool for FilterEventsTool {
fn name(&self) -> &str {
"filter_contract_events"
}
fn capability(&self) -> crate::risk::Capability {
crate::risk::Capability::ReadOnly
}
fn description(&self) -> &str {
"Fetch a contract's recent events and keep only those whose topics match a given string"
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"contract_id": {
"type": "string",
"description": "Contract ID to fetch events for"
},
"topic_filter": {
"type": "string",
"description": "Case-insensitive text matched against event topics (e.g. 'transfer', 'mint')"
},
"limit": {
"type": "integer",
"description": "Maximum number of events to scan (1-200, default: 10)"
},
"network": {
"type": "string",
"enum": ["local", "testnet", "mainnet"],
"description": "Network to query (default: testnet)"
}
},
"required": ["contract_id", "topic_filter"]
})
}
async fn execute(&self, input: Value) -> Result<String, String> {
let contract_id = input
.get("contract_id")
.and_then(|v| v.as_str())
.ok_or("Missing 'contract_id' parameter")?;
if !is_contract_id(contract_id) {
return Err(format!("Not a valid contract id: {}", contract_id));
}
let topic_filter = input
.get("topic_filter")
.and_then(|v| v.as_str())
.ok_or("Missing 'topic_filter' parameter")?;
let network = input
.get("network")
.and_then(|v| v.as_str())
.unwrap_or("testnet");
let rpc_url = network_rpc_url(network)?;
let scanned = read_limit(&input);
let events = fetch_contract_events(rpc_url, contract_id, scanned).await?;
let needle = topic_filter.to_lowercase();
let matched: Vec<_> = events
.iter()
.filter(|e| topic_text(e).to_lowercase().contains(&needle))
.collect();
if matched.is_empty() {
return Ok(format!(
"No '{}' events among the {} most recent events of {} on {}",
topic_filter,
events.len(),
contract_id,
network
));
}
let mut output = format!(
"'{}' events for {} ({}), {} of {} scanned:\n\n",
topic_filter,
contract_id,
network,
matched.len(),
events.len()
);
for (i, event) in matched.iter().enumerate() {
output.push_str(&format_event(i, event));
}
Ok(output)
}
}