procyon 0.3.0

Terminal development harness for Stellar and Soroban smart contracts, driven by a language model
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;

// getEvents requires a start ledger, and the RPC only retains a recent window. Anchoring the
// query this far behind the tip keeps it inside retention on testnet.
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` is an array of filter objects; passing a bare object is rejected.
            "filters": [{"type": "contract", "contractIds": [contract_id]}],
            "pagination": {"limit": limit},
            // Asking for decoded JSON avoids having to parse base64 XDR ScVals locally.
            "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?;

        // The RPC's own topic filter expects base64-XDR ScVal segments, so matching happens here
        // against the decoded topics instead.
        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)
    }
}