ai-agent-bitcoin-escrow 0.1.0

A Rust library for AI agents to create, manage, and execute Bitcoin escrow contracts using multisig
Documentation
//! External oracle integration for escrow conditions.
//!
//! This module provides interfaces to external oracles that can trigger
//! escrow releases based on real-world data (prices, events, etc.).

use async_trait::async_trait;
use chrono::{DateTime, Utc};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;

use crate::conditions::OracleProvider;
use crate::error::{EscrowError, Result};
use crate::types::{ConditionResult, OracleId};

// Re-export for convenience
pub use crate::types::OracleId as OracleIdType;

/// Oracle response types.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OracleValue {
    String(String),
    Number(f64),
    Boolean(bool),
    Json(serde_json::Value),
}

impl OracleValue {
    /// Convert to string for comparison.
    pub fn to_comparison_string(&self) -> String {
        match self {
            OracleValue::String(s) => s.clone(),
            OracleValue::Number(n) => n.to_string(),
            OracleValue::Boolean(b) => b.to_string(),
            OracleValue::Json(v) => v.to_string(),
        }
    }
}

/// Configuration for an HTTP-based oracle.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HTTPOracleConfig {
    /// Oracle identifier.
    pub id: OracleId,
    /// Base URL for the oracle API.
    pub base_url: String,
    /// API key (if required).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub api_key: Option<String>,
    /// Query endpoint template (use {} for query placeholder).
    pub endpoint_template: String,
    /// JSON path to extract the value from response.
    /// Uses JSONPath syntax (e.g., "$.price" or "$.data.value").
    pub value_path: String,
    /// Request timeout in seconds.
    #[serde(default = "default_timeout")]
    pub timeout_seconds: u64,
}

fn default_timeout() -> u64 {
    30
}

/// HTTP-based oracle implementation.
pub struct HTTPOracle {
    config: HTTPOracleConfig,
    client: Client,
}

impl HTTPOracle {
    /// Create a new HTTP oracle.
    pub fn new(config: HTTPOracleConfig) -> Result<Self> {
        let client = Client::builder()
            .timeout(std::time::Duration::from_secs(config.timeout_seconds))
            .build()
            .map_err(|e| EscrowError::Oracle(format!("Failed to create HTTP client: {}", e)))?;

        Ok(Self { config, client })
    }

    /// Build the full URL for a query.
    fn build_url(&self, query: &str) -> String {
        let endpoint = self.config.endpoint_template.replace("{}", query);
        format!("{}{}", self.config.base_url, endpoint)
    }

    /// Extract value from JSON response using JSONPath.
    fn extract_value(&self, json: &serde_json::Value) -> Result<OracleValue> {
        // Simple JSONPath extraction for common patterns
        let path = &self.config.value_path;
        
        if path == "$" || path.is_empty() {
            return Ok(OracleValue::Json(json.clone()));
        }

        // Handle $.field and $.field.nested patterns
        let path = path.trim_start_matches('$');
        let parts: Vec<&str> = path.trim_start_matches('.')
            .split('.')
            .filter(|p| !p.is_empty())
            .collect();

        let mut current = json;
        for part in &parts {
            current = current
                .get(part)
                .ok_or_else(|| {
                    EscrowError::Oracle(format!("JSONPath '{}' not found in response", path))
                })?;
        }

        // Convert to OracleValue
        if let Some(s) = current.as_str() {
            Ok(OracleValue::String(s.to_string()))
        } else if let Some(n) = current.as_f64() {
            Ok(OracleValue::Number(n))
        } else if let Some(b) = current.as_bool() {
            Ok(OracleValue::Boolean(b))
        } else {
            Ok(OracleValue::Json(current.clone()))
        }
    }
}

#[async_trait]
impl OracleProvider for HTTPOracle {
    async fn query(&self, query: &str, expected_value: &str) -> Result<ConditionResult> {
        let url = self.build_url(query);
        
        let mut request = self.client.get(&url);
        
        if let Some(api_key) = &self.config.api_key {
            request = request.header("Authorization", format!("Bearer {}", api_key));
        }

        let response = request
            .send()
            .await
            .map_err(|e| EscrowError::Oracle(format!("HTTP request failed: {}", e)))?;

        if !response.status().is_success() {
            return Ok(ConditionResult {
                satisfied: false,
                reason: format!("Oracle returned HTTP {}", response.status()),
                evaluated_at: Utc::now(),
                data: Some(serde_json::json!({
                    "error": "http_error",
                    "status": response.status().as_u16(),
                })),
            });
        }

        let json: serde_json::Value = response
            .json()
            .await
            .map_err(|e| EscrowError::Oracle(format!("Failed to parse JSON response: {}", e)))?;

        let value = self.extract_value(&json)?;
        let value_str = value.to_comparison_string();
        let satisfied = &value_str == expected_value;

        Ok(ConditionResult {
            satisfied,
            reason: if satisfied {
                format!("Oracle returned expected value: {}", value_str)
            } else {
                format!("Oracle returned '{}', expected '{}'", value_str, expected_value)
            },
            evaluated_at: Utc::now(),
            data: Some(serde_json::json!({
                "oracle_id": self.config.id.to_string(),
                "query": query,
                "value": value,
                "expected": expected_value,
                "url": url,
            })),
        })
    }
}

/// Represents an oracle event/log entry.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OracleEvent {
    /// Oracle ID.
    pub oracle_id: OracleId,
    /// Query that was made.
    pub query: String,
    /// Value that was returned.
    pub value: OracleValue,
    /// Timestamp of the query.
    pub timestamp: DateTime<Utc>,
    /// Whether this satisfied an expected condition.
    pub satisfied: bool,
}

/// Registry of known oracles.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OracleRegistry {
    /// Known oracle configurations.
    oracles: HashMap<String, HTTPOracleConfig>,
}

impl OracleRegistry {
    /// Create an empty registry.
    pub fn new() -> Self {
        Self {
            oracles: HashMap::new(),
        }
    }

    /// Register an oracle.
    pub fn register(&mut self, config: HTTPOracleConfig) {
        self.oracles.insert(config.id.0.clone(), config);
    }

    /// Get an oracle configuration.
    pub fn get(&self, id: &str) -> Option<&HTTPOracleConfig> {
        self.oracles.get(id)
    }

    /// List all registered oracles.
    pub fn list(&self) -> Vec<&HTTPOracleConfig> {
        self.oracles.values().collect()
    }

    /// Create HTTP oracle instances for all registered oracles.
    pub fn create_oracles(&self) -> Result<HashMap<String, HTTPOracle>> {
        let mut oracles = HashMap::new();
        for (id, config) in &self.oracles {
            oracles.insert(id.clone(), HTTPOracle::new(config.clone())?);
        }
        Ok(oracles)
    }
}

impl Default for OracleRegistry {
    fn default() -> Self {
        Self::new()
    }
}

/// Predefined oracle configurations for common use cases.
pub mod predefined {
    use super::*;
    
    /// Create a Bitcoin price oracle configuration.
    pub fn bitcoin_price_oracle() -> HTTPOracleConfig {
        HTTPOracleConfig {
            id: OracleId::new("btc_price"),
            base_url: "https://api.coingecko.com".to_string(),
            api_key: None,
            endpoint_template: "/api/v3/simple/price?ids=bitcoin&vs_currencies=usd".to_string(),
            value_path: "$.bitcoin.usd".to_string(),
            timeout_seconds: 10,
        }
    }

    /// Create a mock oracle for testing.
    pub fn mock_oracle(id: &str) -> HTTPOracleConfig {
        HTTPOracleConfig {
            id: OracleId::new(id),
            base_url: "http://localhost:9999".to_string(),
            api_key: None,
            endpoint_template: "/query/{}".to_string(),
            value_path: "$.value".to_string(),
            timeout_seconds: 5,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_oracle_value_conversion() {
        assert_eq!(OracleValue::String("test".to_string()).to_comparison_string(), "test");
        assert_eq!(OracleValue::Number(42.5).to_comparison_string(), "42.5");
        assert_eq!(OracleValue::Boolean(true).to_comparison_string(), "true");
    }

    #[test]
    fn test_oracle_registry() {
        let mut registry = OracleRegistry::new();
        registry.register(predefined::bitcoin_price_oracle());
        
        assert!(registry.get("btc_price").is_some());
        assert_eq!(registry.list().len(), 1);
    }

    #[test]
    fn test_extract_value() {
        let config = HTTPOracleConfig {
            id: OracleId::new("test"),
            base_url: "http://test".to_string(),
            api_key: None,
            endpoint_template: "/test".to_string(),
            value_path: "$.data.price".to_string(),
            timeout_seconds: 10,
        };
        let oracle = HTTPOracle::new(config).unwrap();
        
        let json = serde_json::json!({
            "data": {
                "price": "50000"
            }
        });
        
        let value = oracle.extract_value(&json).unwrap();
        assert_eq!(value.to_comparison_string(), "50000");
    }
}