agentswap 0.13.0

AgentSwap CLI for requesting quotes, inspecting tokens and pools, managing intents, and registering an API key against the AgentSwap service.
// Per-chain protocol configuration and RPC helpers for reads and signed transactions.
// Exports: chain_config, rpc_url, read_provider, sign_transaction.
// Deps: alloy providers/network, crate::signer and crate::tokens.

use crate::signer::Signer;
use alloy::consensus::TxEnvelope;
use alloy::network::{EthereumWallet, TxSigner};
use alloy::primitives::{Address, Signature};
use alloy::rpc::types::TransactionRequest;
use alloy::providers::{DynProvider, Provider, ProviderBuilder};
use alloy::signers::Error as SignerError;
use eyre::{eyre, Result};
use std::sync::Arc;

mod events;
pub use events::query as event_query;

#[derive(Debug, Clone, Copy)]
pub struct ChainConfig {
    pub id: u64,
    pub rpc: &'static str,
    pub factory: Address,
    pub settler: Address,
    pub generation: &'static str,
    pub lens: Address,
}

const EVENT_LOOKBACK_BLOCKS: u64 = 200_000;
const PUBLIC_DEFAULT_EVENT_LOOKBACK_BLOCKS: u64 = 9_000;
pub const EVENT_CHUNK_SIZE: u64 = 5_000;

pub fn chain_config(chain_id: &str) -> Result<ChainConfig> {
    let id = crate::tokens::chain_name_to_id(chain_id)
        .ok_or_else(|| eyre!("{}", crate::tokens::unknown_chain_id(chain_id)))?;
    let rpc = match id {
        1 => "https://ethereum-rpc.publicnode.com",
        8453 => "https://mainnet.base.org",
        42161 => "https://arb1.arbitrum.io/rpc",
        56 => "https://bsc-rpc.publicnode.com",
        4663 => "https://rpc.mainnet.chain.robinhood.com",
        5042 => "https://rpc.mainnet.arc.io",
        5042002 => "https://rpc.testnet.arc.io",
        _ => return Err(eyre!("intent protocol is unavailable on chain id {id}")),
    };
    let (generation, factory, settler, lens) = match id {
        1 => ("v7",
            alloy::primitives::address!("0x121e3622C1a8D26DDa7E84c812607802BfFD23d1"),
            alloy::primitives::address!("0xB522a73B93D0F567bBCa1b53351e38358C648bC6"),
            alloy::primitives::address!("0x120b028Eeb7c8FB5fed2519cEBFa7e8F5A526FB4")),
        8453 | 42161 | 56 | 4663 | 5042 | 5042002 => ("v6",
            alloy::primitives::address!("0xc1660e4BbC825f8367dA92b60dccc17E4E10bc26"),
            alloy::primitives::address!("0x2dd81c4fD1FC38b009Ab10D5C9b1f01Ca51cE462"),
            alloy::primitives::address!("0x3AFfAafAF3Ec0A8A535723CBf0891482680F30C3")),
        _ => return Err(eyre!("intent protocol is unavailable on chain id {id}")),
    };
    Ok(ChainConfig { id, rpc, factory, settler, generation, lens })
}

pub fn agent_chain_config(chain_id: &str) -> Result<ChainConfig> {
    let config = chain_config(chain_id)?;
    if config.id == 1 {
        return Err(eyre!("agent delegation is unavailable on Ethereum (1); trade and policy refuse chain 1; use intents for swaps"));
    }
    Ok(config)
}

pub fn wrapped_native(chain: u64) -> Option<Address> {
    if chain == 56 {
        return Some(alloy::primitives::address!("0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c"));
    }
    crate::tokens::resolve_token("WETH", chain).and_then(|(address, _, _)| address.parse().ok())
}

pub fn rpc_url(config: ChainConfig) -> String {
    let key = format!("AGENTSWAP_RPC_URL_{}", config.id);
    std::env::var(key)
        .or_else(|_| std::env::var("AGENTSWAP_RPC_URL"))
        .unwrap_or_else(|_| config.rpc.to_string())
}

pub fn read_provider(url: &str) -> Result<DynProvider> {
    let url = url.parse().map_err(|e| eyre!("invalid RPC URL: {e}"))?;
    Ok(ProviderBuilder::new().connect_http(url).erased())
}

/// A read provider whose RPC answers `expected_chain_id`; an override URL for another chain is refused.
pub async fn read_provider_checked(url: &str, expected_chain_id: u64) -> Result<DynProvider> {
    let provider = read_provider(url)?;
    let actual = provider.get_chain_id().await
        .map_err(|error| eyre!("failed to read RPC chain id: {error}"))?;
    if actual != expected_chain_id {
        return Err(eyre!("RPC chain id {actual} does not match requested chain id {expected_chain_id}"));
    }
    Ok(provider)
}

pub fn event_lookback_blocks(config: ChainConfig, override_blocks: Option<u64>) -> u64 {
    if let Some(blocks) = override_blocks {
        return blocks;
    }
    if matches!(config.id, 1 | 56) && !has_rpc_override(config.id) {
        return PUBLIC_DEFAULT_EVENT_LOOKBACK_BLOCKS;
    }
    EVENT_LOOKBACK_BLOCKS
}

pub async fn event_start_block(provider: &DynProvider, lookback_blocks: u64) -> Result<u64> {
    let latest = provider.get_block_number().await?;
    Ok(latest.saturating_sub(lookback_blocks))
}

pub fn event_query_error(
    config: ChainConfig,
    from_block: u64,
    to_block: u64,
    error: impl std::fmt::Display,
) -> eyre::Report {
    let message = error.to_string();
    let lower = message.to_ascii_lowercase();
    if lower.contains("limit exceeded") || lower.contains("archive") {
        return eyre!(
            "eth_getLogs refused by the RPC backend on {} while reading blocks {}..={}; access or quota restrictions may apply; set AGENTSWAP_RPC_URL_{} (or AGENTSWAP_RPC_URL) to a keyed RPC endpoint",
            crate::tokens::chain_id_to_name(config.id),
            from_block,
            to_block,
            config.id
        );
    }
    eyre!("{message}")
}

fn has_rpc_override(chain_id: u64) -> bool {
    let chain_key = format!("AGENTSWAP_RPC_URL_{chain_id}");
    std::env::var_os(chain_key).is_some() || std::env::var_os("AGENTSWAP_RPC_URL").is_some()
}

/// Headroom over eth_estimateGas. The estimate runs against the latest block, while the transaction
/// executes in a later one where pool state and timestamps differ; an exact limit reverts on drift.
const GAS_HEADROOM_PERCENT: u64 = 25;

/// Fill nonce, gas, fees and chain id from the RPC and sign with the signer's wallet, without
/// sending: the hash is known before the broadcast. Returns a provider for the same RPC.
pub async fn sign_transaction(
    url: &str,
    signer: Arc<dyn Signer>,
    mut request: TransactionRequest,
) -> Result<(DynProvider, TxEnvelope)> {
    let url = url.parse().map_err(|e| eyre!("invalid RPC URL: {e}"))?;
    let wallet = EthereumWallet::new(WalletSigner(signer));
    let provider = ProviderBuilder::new().wallet(wallet).connect_http(url);
    if request.gas.is_none() {
        let estimate = provider.estimate_gas(request.clone()).await?;
        request.gas = Some(estimate.saturating_add(estimate / 100 * GAS_HEADROOM_PERCENT));
    }
    let envelope = provider
        .fill(request)
        .await?
        .try_into_envelope()
        .map_err(|_| eyre!("the transaction was filled but not signed"))?;
    Ok((provider.erased(), envelope))
}

#[derive(Clone)]
struct WalletSigner(Arc<dyn Signer>);

impl std::fmt::Debug for WalletSigner {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_tuple("WalletSigner").field(&self.0.address()).finish()
    }
}

#[async_trait::async_trait]
impl TxSigner<Signature> for WalletSigner {
    fn address(&self) -> Address {
        self.0.address()
    }

    async fn sign_transaction(
        &self,
        tx: &mut dyn alloy::consensus::SignableTransaction<Signature>,
    ) -> alloy::signers::Result<Signature> {
        self.0
            .sign_hash(tx.signature_hash())
            .await
            .map_err(SignerError::other)
    }
}

// Shared synthetic grant-parser interoperability fixtures.
#[cfg(test)]
pub const GRANT_FIXTURES: [&str; 2] = [
    "https://app.agentswap.co/grant?v=1&chain=8453&agent=0x2000000000000000000000000000000000000002&label=dust+sweep&t=0x1000000000000000000000000000000000000001%3A1.2345&t=0x1000000000000000000000000000000000000002%3A0.000000000000000005&t=0x3000000000000000000000000000000000000003%3A0&epoch=1w&expiry=2026-10-10T12%3A00%3A00Z&actions=market%2Cintent&owner=0x4000000000000000000000000000000000000004",
    "https://app.agentswap.co/grant?v=1&chain=42161&agent=0x2000000000000000000000000000000000000002&t=0x1000000000000000000000000000000000000001%3A250&t=0x1000000000000000000000000000000000000002%3A0.5&t=0x3000000000000000000000000000000000000003%3A0&epoch=1w&expiry=30d&actions=market%2Cintent",
];

#[cfg(test)]
pub const GRANT_TOKENS: [&str; 3] = [
    "0x1000000000000000000000000000000000000001",
    "0x1000000000000000000000000000000000000002",
    "0x3000000000000000000000000000000000000003",
];
#[cfg(test)]
pub const GRANT_AGENT: &str = "0x2000000000000000000000000000000000000002";
#[cfg(test)]
pub const GRANT_OWNER: &str = "0x4000000000000000000000000000000000000004";

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

    #[test]
    fn ethereum_rpc_overrides_and_public_lookback_are_respected() {
        if let Ok(case) = std::env::var("ETHEREUM_RPC_TEST_CASE") {
            let config = chain_config("1").unwrap();
            let (url, blocks) = match case.as_str() {
                "chain" => ("http://127.0.0.1:1", 200_000),
                "global" => ("http://127.0.0.1:2", 200_000),
                _ => ("https://ethereum-rpc.publicnode.com", 9_000),
            };
            assert_eq!(rpc_url(config), url);
            assert_eq!(event_lookback_blocks(config, None), blocks);
            assert_eq!(event_lookback_blocks(config, Some(42)), 42);
            assert_eq!(agent_chain_config("8453").unwrap().generation, "v6");
            assert!(agent_chain_config("1").is_err());
            return;
        }
        for case in ["public", "global", "chain"] {
            let mut command = std::process::Command::new(std::env::current_exe().unwrap());
            command.args(["ethereum_rpc_overrides_and_public_lookback_are_respected", "--nocapture"])
                .env("ETHEREUM_RPC_TEST_CASE", case)
                .env_remove("AGENTSWAP_RPC_URL_1").env_remove("AGENTSWAP_RPC_URL");
            if case != "public" { command.env("AGENTSWAP_RPC_URL", "http://127.0.0.1:2"); }
            if case == "chain" { command.env("AGENTSWAP_RPC_URL_1", "http://127.0.0.1:1"); }
            let output = command.output().unwrap();
            assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stdout));
        }
    }

    #[test]
    fn ethereum_v7_contracts_and_v6_chains_are_pinned() {
        for alias in ["1", "ethereum", "eth", "mainnet"] {
            let config = chain_config(alias).expect("Ethereum V7 config");
            assert_eq!(config.id, 1);
            assert_eq!(config.generation, "v7");
            assert_eq!(config.factory, alloy::primitives::address!("0x121e3622C1a8D26DDa7E84c812607802BfFD23d1"));
            assert_eq!(config.settler, alloy::primitives::address!("0xB522a73B93D0F567bBCa1b53351e38358C648bC6"));
            assert_eq!(config.lens, alloy::primitives::address!("0x120b028Eeb7c8FB5fed2519cEBFa7e8F5A526FB4"));
        }
        for id in ["8453", "42161", "56", "4663", "5042", "5042002"] {
            let config = chain_config(id).unwrap();
            assert_eq!(config.generation, "v6");
            assert_eq!(config.factory, alloy::primitives::address!("0xc1660e4BbC825f8367dA92b60dccc17E4E10bc26"));
            assert_eq!(config.settler, alloy::primitives::address!("0x2dd81c4fD1FC38b009Ab10D5C9b1f01Ca51cE462"));
            assert_eq!(config.lens, alloy::primitives::address!("0x3AFfAafAF3Ec0A8A535723CBf0891482680F30C3"));
        }
    }

    #[test]
    fn arc_rpc_uses_the_shared_v6_contracts() {
        for alias in ["arc", "arc-mainnet", "5042"] {
            let arc = chain_config(alias).expect("Arc config");
            assert_eq!(arc.id, 5042);
            assert_eq!(arc.rpc, "https://rpc.mainnet.arc.io");
            for chain in ["base", "arbitrum", "bsc", "robinhood", "arc-testnet"] {
                let existing = chain_config(chain).unwrap();
                assert_eq!(arc.factory, existing.factory);
                assert_eq!(arc.settler, existing.settler);
                assert_eq!(arc.lens, existing.lens);
                assert_eq!(arc.generation, existing.generation);
            }
            assert_eq!(event_lookback_blocks(arc, None), 200_000);
            assert_eq!(event_lookback_blocks(arc, Some(300_000)), 300_000);
        }
    }

    #[test]
    fn arc_testnet_rpc_uses_the_shared_v6_contracts() {
        for alias in ["arc-testnet", "arct", "5042002"] {
            let arc = chain_config(alias).expect("Arc Testnet config");
            assert_eq!(arc.id, 5042002);
            assert_eq!(arc.rpc, "https://rpc.testnet.arc.io");
            for chain in ["base", "arbitrum", "bsc", "robinhood"] {
                let existing = chain_config(chain).unwrap();
                assert_eq!(arc.factory, existing.factory);
                assert_eq!(arc.settler, existing.settler);
                assert_eq!(arc.lens, existing.lens);
                assert_eq!(arc.generation, existing.generation);
            }
            assert_eq!(event_lookback_blocks(arc, None), 200_000);
            assert_eq!(event_lookback_blocks(arc, Some(300_000)), 300_000);
        }
    }

    #[test]
    fn resolved_chain_without_v6_deployment_reports_unavailability() {
        let error = chain_config("10").expect_err("Optimism resolves but has no deployment");
        assert_eq!(format!("{error}"), "intent protocol is unavailable on chain id 10");
    }
}