butterfly-bot 0.7.0

Butterfly Bot is an opinionated personal-ops AI assistant built for people who want results, not setup overhead.
Documentation
use serde::{Deserialize, Serialize};
use serde_json::Value;

use crate::error::{ButterflyBotError, Result};
use crate::security::policy::SigningIntent;

pub const DENY_INVALID_X402_INTENT: &str = "DENY_INVALID_X402_INTENT";
pub const DENY_UNAPPROVED_SCHEME: &str = "DENY_UNAPPROVED_SCHEME";

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CanonicalX402Intent {
    pub scheme_id: String,
    pub chain_id: String,
    pub asset_id: String,
    pub amount_atomic: u64,
    pub payee: String,
    pub payment_authority: String,
    pub request_expiry: u64,
    pub idempotency_key: String,
    pub context_requires_approval: bool,
}

fn deny(code: &str, detail: &str) -> ButterflyBotError {
    ButterflyBotError::SecurityPolicy(format!("{code}: {detail}"))
}

fn parse_u64_atomic(value: &str) -> Result<u64> {
    value
        .trim()
        .parse::<u64>()
        .map_err(|_| deny(DENY_INVALID_X402_INTENT, "amount_atomic parse failure"))
}

fn extract_payment_authority(extra: Option<&Value>, merchant_origin: Option<&str>) -> String {
    let from_extra = extra.and_then(|value| {
        value
            .get("paymentAuthority")
            .or_else(|| value.get("payment_authority"))
            .or_else(|| value.get("facilitator"))
            .or_else(|| value.get("facilitatorUrl"))
            .or_else(|| value.get("settlementEndpoint"))
            .and_then(|candidate| candidate.as_str())
            .map(|text| text.trim().to_string())
            .filter(|text| !text.is_empty())
    });

    from_extra
        .or_else(|| {
            merchant_origin
                .map(|value| value.trim().to_string())
                .filter(|value| !value.is_empty())
        })
        .unwrap_or_else(|| "unknown_authority".to_string())
}

pub fn canonicalize_payment_required(
    request_id: &str,
    actor: &str,
    user_id: &str,
    payment_required_json: &Value,
    merchant_origin: Option<&str>,
    context_requires_approval: bool,
    idempotency_key: Option<&str>,
) -> Result<(CanonicalX402Intent, SigningIntent)> {
    let x402_version = payment_required_json
        .get("x402Version")
        .and_then(|value| value.as_u64())
        .ok_or_else(|| deny(DENY_INVALID_X402_INTENT, "missing x402Version"))?;

    let idempotency_key = idempotency_key
        .map(|value| value.trim().to_string())
        .filter(|value| !value.is_empty())
        .unwrap_or_else(|| request_id.to_string());

    if x402_version == 1 {
        let payment_required: x402_rs::proto::v1::PaymentRequired =
            serde_json::from_value(payment_required_json.clone())
                .map_err(|_| deny(DENY_INVALID_X402_INTENT, "invalid v1 payment_required"))?;

        let requirement = payment_required
            .accepts
            .into_iter()
            .find(|entry| entry.scheme == "exact")
            .ok_or_else(|| deny(DENY_UNAPPROVED_SCHEME, "no supported v1 scheme"))?;

        let chain_id = x402_rs::chain::ChainId::from_network_name(&requirement.network)
            .ok_or_else(|| deny(DENY_INVALID_X402_INTENT, "unknown v1 network"))?;
        if chain_id.namespace() != "solana" {
            return Err(deny(DENY_UNAPPROVED_SCHEME, "only Solana chain is allowed"));
        }

        let amount_atomic = parse_u64_atomic(&requirement.max_amount_required)?;
        let request_expiry = now_ts().saturating_add(requirement.max_timeout_seconds);
        let payment_authority =
            extract_payment_authority(requirement.extra.as_ref(), merchant_origin);

        let canonical = CanonicalX402Intent {
            scheme_id: "v1-solana-exact".to_string(),
            chain_id: chain_id.to_string(),
            asset_id: requirement.asset.clone(),
            amount_atomic,
            payee: requirement.pay_to.clone(),
            payment_authority,
            request_expiry,
            idempotency_key,
            context_requires_approval,
        };

        let intent = SigningIntent {
            request_id: request_id.to_string(),
            actor: actor.to_string(),
            user_id: user_id.to_string(),
            action_type: "x402_payment".to_string(),
            amount_atomic,
            payee: requirement.pay_to,
            context_requires_approval,
            scheme_id: Some("v1-solana-exact".to_string()),
            chain_id: Some(chain_id.to_string()),
            payment_authority: Some(canonical.payment_authority.clone()),
            idempotency_key: Some(canonical.idempotency_key.clone()),
        };

        return Ok((canonical, intent));
    }

    if x402_version == 2 {
        let payment_required: x402_rs::proto::v2::PaymentRequired =
            serde_json::from_value(payment_required_json.clone())
                .map_err(|_| deny(DENY_INVALID_X402_INTENT, "invalid v2 payment_required"))?;

        let requirement = payment_required
            .accepts
            .into_iter()
            .find(|entry| entry.scheme == "exact")
            .ok_or_else(|| deny(DENY_UNAPPROVED_SCHEME, "no supported v2 scheme"))?;

        if requirement.network.namespace() != "solana" {
            return Err(deny(DENY_UNAPPROVED_SCHEME, "only Solana chain is allowed"));
        }

        let amount_atomic = parse_u64_atomic(&requirement.amount)?;
        let request_expiry = now_ts().saturating_add(requirement.max_timeout_seconds);
        let payment_authority =
            extract_payment_authority(requirement.extra.as_ref(), merchant_origin);

        let canonical = CanonicalX402Intent {
            scheme_id: "v2-solana-exact".to_string(),
            chain_id: requirement.network.to_string(),
            asset_id: requirement.asset.clone(),
            amount_atomic,
            payee: requirement.pay_to.clone(),
            payment_authority,
            request_expiry,
            idempotency_key,
            context_requires_approval,
        };

        let intent = SigningIntent {
            request_id: request_id.to_string(),
            actor: actor.to_string(),
            user_id: user_id.to_string(),
            action_type: "x402_payment".to_string(),
            amount_atomic,
            payee: requirement.pay_to,
            context_requires_approval,
            scheme_id: Some("v2-solana-exact".to_string()),
            chain_id: Some(requirement.network.to_string()),
            payment_authority: Some(canonical.payment_authority.clone()),
            idempotency_key: Some(canonical.idempotency_key.clone()),
        };

        return Ok((canonical, intent));
    }

    Err(deny(
        DENY_UNAPPROVED_SCHEME,
        "unsupported x402 version for Solana-only mode",
    ))
}

fn now_ts() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs()
}

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

    #[test]
    fn canonicalize_v2_solana_exact_success() {
        let challenge = serde_json::json!({
            "x402Version": 2,
            "resource": {
                "description": "pay",
                "mimeType": "application/json",
                "url": "https://merchant.local/pay"
            },
            "accepts": [{
                "scheme": "exact",
                "network": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
                "amount": "1000",
                "payTo": "merchant.local",
                "maxTimeoutSeconds": 300,
                "asset": "USDC",
                "extra": {"paymentAuthority": "https://facilitator.local"}
            }]
        });

        let (canonical, intent) = canonicalize_payment_required(
            "req-1",
            "agent",
            "user",
            &challenge,
            Some("https://merchant.local"),
            false,
            Some("idem-1"),
        )
        .unwrap();

        assert_eq!(canonical.scheme_id, "v2-solana-exact");
        assert_eq!(
            canonical.chain_id,
            "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"
        );
        assert_eq!(canonical.amount_atomic, 1000);
        assert_eq!(canonical.idempotency_key, "idem-1");
        assert_eq!(intent.amount_atomic, 1000);
        assert_eq!(intent.payee, "merchant.local");
    }

    #[test]
    fn canonicalize_rejects_non_solana_chain() {
        let challenge = serde_json::json!({
            "x402Version": 2,
            "resource": {
                "description": "pay",
                "mimeType": "application/json",
                "url": "https://merchant.local/pay"
            },
            "accepts": [{
                "scheme": "exact",
                "network": "eip155:8453",
                "amount": "1000",
                "payTo": "merchant.local",
                "maxTimeoutSeconds": 300,
                "asset": "USDC"
            }]
        });

        let err = canonicalize_payment_required(
            "req-2",
            "agent",
            "user",
            &challenge,
            Some("https://merchant.local"),
            false,
            None,
        )
        .unwrap_err();

        assert!(format!("{err}").contains(DENY_UNAPPROVED_SCHEME));
    }

    #[test]
    fn canonicalize_rejects_unsupported_scheme() {
        let challenge = serde_json::json!({
            "x402Version": 2,
            "resource": {
                "description": "pay",
                "mimeType": "application/json",
                "url": "https://merchant.local/pay"
            },
            "accepts": [{
                "scheme": "streaming",
                "network": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
                "amount": "1000",
                "payTo": "merchant.local",
                "maxTimeoutSeconds": 300,
                "asset": "USDC"
            }]
        });

        let err =
            canonicalize_payment_required("req-3", "agent", "user", &challenge, None, false, None)
                .unwrap_err();

        assert!(format!("{err}").contains(DENY_UNAPPROVED_SCHEME));
    }

    #[test]
    fn canonicalize_v1_solana_exact_success() {
        let challenge = serde_json::json!({
            "x402Version": 1,
            "accepts": [{
                "scheme": "exact",
                "network": "solana",
                "maxAmountRequired": "2000",
                "resource": "https://merchant.local/pay",
                "description": "pay",
                "mimeType": "application/json",
                "payTo": "merchant.local",
                "maxTimeoutSeconds": 60,
                "asset": "USDC"
            }]
        });

        let (canonical, _intent) = canonicalize_payment_required(
            "req-4",
            "agent",
            "user",
            &challenge,
            Some("https://merchant.local"),
            true,
            None,
        )
        .unwrap();

        assert_eq!(canonical.scheme_id, "v1-solana-exact");
        assert_eq!(canonical.amount_atomic, 2000);
        assert!(canonical.context_requires_approval);
    }
}