o402 0.1.5

OpenAI-compatible gateway, paid with x402.
//! Per-namespace `PriceTag` builders.

mod algorand;
mod aptos;
mod concordium;
mod eip155;
mod hedera;
mod keeta;
mod near;
mod solana;
mod stellar;
mod tron;
mod tvm;
mod xrpl;

use r402_protocol::network::{ChainId, ChainIdPattern};
use r402_protocol::payment::PriceTag;

use crate::config::{AcceptConfig, ConfigError};

/// Builds one advertised tag for `amount` atomic units.
pub(super) fn build_tag(
    accept: &AcceptConfig,
    amount: u128,
    pay_to: &indexmap::IndexMap<String, String>,
) -> Result<PriceTag, ConfigError> {
    let network = parse_chain(&accept.network)?;
    let pay_to = pay_to_for(&network, pay_to)?;
    match network.namespace() {
        "eip155" => eip155::build(accept, &network, amount, pay_to),
        "solana" => solana::build(accept, &network, amount, pay_to),
        "near" => near::build(accept, &network, amount, pay_to),
        "xrpl" => xrpl::build(accept, &network, amount, pay_to),
        "hedera" => hedera::build(accept, &network, amount, pay_to),
        "algorand" => algorand::build(accept, &network, amount, pay_to),
        "aptos" => aptos::build(accept, &network, amount, pay_to),
        "keeta" => keeta::build(accept, &network, amount, pay_to),
        "tvm" => tvm::build(accept, &network, amount, pay_to),
        "stellar" => stellar::build(accept, &network, amount, pay_to),
        "ccd" => concordium::build(accept, &network, amount, pay_to),
        "tron" => tron::build(accept, &network, amount, pay_to),
        other => Err(unsupported_namespace(other)),
    }
}

/// Token decimals for one accept (named helper or custom `decimals`).
pub(super) fn accept_decimals(accept: &AcceptConfig) -> Result<u8, ConfigError> {
    if let Some(decimals) = accept.decimals {
        return u8::try_from(decimals).map_err(|_| {
            ConfigError::Validation(format!(
                "payment.accepts decimals {decimals} is out of range"
            ))
        });
    }
    let network = parse_chain(&accept.network)?;
    match network.namespace() {
        "eip155" => eip155::decimals(accept, &network),
        "solana" => solana::decimals(accept, &network),
        "near" => near::decimals(accept, &network),
        "xrpl" => xrpl::decimals(accept, &network),
        "hedera" => hedera::decimals(accept, &network),
        "algorand" => algorand::decimals(accept, &network),
        "aptos" => aptos::decimals(accept, &network),
        "keeta" => keeta::decimals(accept, &network),
        "tvm" => tvm::decimals(accept, &network),
        "stellar" => stellar::decimals(accept, &network),
        "ccd" => concordium::decimals(accept, &network),
        "tron" => tron::decimals(accept, &network),
        other => Err(unsupported_namespace(other)),
    }
}

fn pay_to_for<'a>(
    network: &ChainId,
    pay_to: &'a indexmap::IndexMap<String, String>,
) -> Result<&'a str, ConfigError> {
    for (pattern, address) in pay_to {
        let parsed: ChainIdPattern = pattern.parse().map_err(|error| {
            ConfigError::Validation(format!(
                "invalid payment.pay_to pattern '{pattern}': {error}"
            ))
        })?;
        if parsed.matches(network) {
            return Ok(address.as_str());
        }
    }
    Err(ConfigError::Validation(format!(
        "payment.pay_to has no address for {network}"
    )))
}

fn parse_chain(raw: &str) -> Result<ChainId, ConfigError> {
    raw.parse().map_err(|error| {
        ConfigError::Validation(format!("invalid accept network '{raw}': {error}"))
    })
}

fn unsupported_namespace(namespace: &str) -> ConfigError {
    ConfigError::Validation(format!("network namespace '{namespace}' is not supported"))
}

#[cfg(not(all(
    feature = "evm",
    feature = "svm",
    feature = "near",
    feature = "xrpl",
    feature = "hedera",
    feature = "avm",
    feature = "aptos",
    feature = "keeta",
    feature = "tvm",
    feature = "stellar",
    feature = "concordium",
    feature = "experimental-tron",
)))]
fn feature_missing(namespace: &str, feature: &str) -> ConfigError {
    ConfigError::Validation(format!(
        "network namespace '{namespace}' is not enabled in this build (cargo feature '{feature}')"
    ))
}

fn require_exact(accept: &AcceptConfig) -> Result<(), ConfigError> {
    if accept.scheme == "exact" {
        Ok(())
    } else {
        Err(ConfigError::Validation(format!(
            "scheme = \"upto\" is only supported on eip155 networks (got {})",
            accept.network
        )))
    }
}

fn reject_evm_fields(accept: &AcceptConfig, namespace: &str) -> Result<(), ConfigError> {
    if accept.transfer_method.is_some() {
        return Err(ConfigError::Validation(format!(
            "transfer_method is not valid on {namespace} accepts"
        )));
    }
    if accept.eip712_name.is_some() {
        return Err(ConfigError::Validation(format!(
            "eip712_name is not valid on {namespace} accepts"
        )));
    }
    if accept.eip712_version.is_some() {
        return Err(ConfigError::Validation(format!(
            "eip712_version is not valid on {namespace} accepts"
        )));
    }
    Ok(())
}

fn named_ticker<'a>(
    accept: &'a AcceptConfig,
    allowed: &[&str],
) -> Result<Option<&'a str>, ConfigError> {
    match accept.asset.as_deref() {
        None => Ok(None),
        Some(ticker) if allowed.contains(&ticker) => Ok(Some(ticker)),
        Some(other) => Err(unknown_asset(other, allowed)),
    }
}

fn unknown_asset(got: &str, allowed: &[&str]) -> ConfigError {
    ConfigError::Validation(format!(
        "unknown payment.accepts asset '{got}'; use {}",
        tickers_hint(allowed)
    ))
}

fn tickers_hint(allowed: &[&str]) -> String {
    match allowed {
        [one] => format!("asset = \"{one}\""),
        [first, second] => format!("asset = \"{first}\" or \"{second}\""),
        _ => "a named asset".to_owned(),
    }
}

fn custom_asset_address(accept: &AcceptConfig) -> Result<&str, ConfigError> {
    accept.asset_address.as_deref().ok_or_else(|| {
        ConfigError::Validation("custom payment.accepts entries need asset_address".to_owned())
    })
}

fn accept_custom_decimals(accept: &AcceptConfig) -> Result<u8, ConfigError> {
    let decimals = accept.decimals.ok_or_else(|| {
        ConfigError::Validation("custom payment.accepts entries need decimals".to_owned())
    })?;
    u8::try_from(decimals).map_err(|_| {
        ConfigError::Validation(format!(
            "payment.accepts decimals {decimals} is out of range"
        ))
    })
}

fn amount_u64(amount: u128, namespace: &str) -> Result<u64, ConfigError> {
    u64::try_from(amount).map_err(|_| {
        ConfigError::Validation(format!("exact amount exceeds u64 on {namespace} accepts"))
    })
}

#[cfg(feature = "stellar")]
fn amount_i128(amount: u128) -> Result<i128, ConfigError> {
    i128::try_from(amount).map_err(|_| {
        ConfigError::Validation("exact amount exceeds i128 on stellar accepts".to_owned())
    })
}

fn no_deployment(symbol: &str, network: &str) -> ConfigError {
    ConfigError::Validation(format!("no {symbol} deployment for {network}"))
}

fn invalid_network(kind: &str, network: &str, error: impl std::fmt::Display) -> ConfigError {
    ConfigError::Validation(format!("invalid {kind} network '{network}': {error}"))
}

fn invalid_pay_to(kind: &str, pay_to: &str, error: impl std::fmt::Display) -> ConfigError {
    ConfigError::Validation(format!("invalid {kind} pay_to '{pay_to}': {error}"))
}

fn invalid_asset_address(address: &str, error: impl std::fmt::Display) -> ConfigError {
    ConfigError::Validation(format!("invalid asset_address '{address}': {error}"))
}

#[cfg(test)]
mod tests;