agentswap 0.13.0

AgentSwap CLI for requesting quotes, inspecting tokens and pools, managing intents, and registering an API key against the AgentSwap service.
// Intent status decoding and Ethereum RPC routing regressions.
// Deps: parent read module and the local JSON-RPC test fixture.
use super::*;
use crate::service::test_rpc::{TestRpc, ok};
use serde_json::json;

#[tokio::test]
async fn archive_http_403_refusals_retry_each_chunk_with_strict_limits() {
    let phrase = "Archive requests require a personal token";
    for (chain, http, code, message, failures, attempts) in [
        (1, 403, -32602, phrase, 3, 4),
        (1, 403, -32602, phrase, usize::MAX, 4),
        (8453, 403, -32602, phrase, usize::MAX, 1),
        (1, 400, -32602, phrase, usize::MAX, 1),
        (1, 403, -32000, phrase, usize::MAX, 1),
        (1, 403, -32602, "invalid filter", usize::MAX, 1),
    ] {
        let mut counts = std::collections::BTreeMap::new();
        let rpc = TestRpc::with_status(move |body| {
            if body["method"] == "eth_chainId" {
                return Some((200, ok(body, json!(format!("0x{chain:x}")))));
            }
            let count = counts.entry(body["params"].to_string()).or_insert(0);
            *count += 1;
            Some(if *count <= failures {
                (http, json!({"jsonrpc":"2.0", "id":body["id"], "error":{"code":code,"message":message}}))
            } else { (200, ok(body, json!([]))) })
        });
        let config = evm::chain_config(&chain.to_string()).unwrap();
        let provider = evm::read_provider_checked(&rpc.url, chain).await.unwrap();
        let settler = IntentSettlerV3::new(config.settler, provider);
        for block in [1, 5001] {
            let mut filter = settler.IntentAnnounced_filter();
            filter.filter = filter.filter.from_block(block).to_block(block + 4999);
            let before = rpc.called("eth_getLogs");
            let result = evm::event_query(config, block, block + 4999, || filter.query()).await;
            assert_eq!(result.is_ok(), failures == 3, "{chain}/{http}/{code}: {result:?}");
            assert_eq!(rpc.called("eth_getLogs") - before, attempts);
        }
    }
}

#[test]
fn audit_fix_wrong_chain_rpc_fails_before_contract_reads() {
    use crate::service::test_rpc::{ok, TestRpc};
    use serde_json::json;
    if std::env::var_os("WRONG_CHAIN_CHILD").is_some() {
        tokio::runtime::Runtime::new().unwrap().block_on(async {
            let error = list(ListInput {
                chain_id: "1".into(), owner: None, agent: None, lookback_blocks: Some(1),
            }).await.expect_err("wrong-chain RPC must fail");
            assert!(error.to_string().contains("RPC chain id 8453 does not match requested chain id 1"), "{error}");
        });
        return;
    }
    let rpc = TestRpc::start(|body| Some(ok(body, match body["method"].as_str().unwrap() {
        "eth_chainId" => json!("0x2105"),
        "eth_call" => json!(format!("0x{:064x}", 3)),
        "eth_blockNumber" => json!("0x1"),
        _ => json!([]),
    })));
    let output = std::process::Command::new(std::env::current_exe().unwrap())
        .args(["audit_fix_wrong_chain_rpc_fails_before_contract_reads", "--nocapture"])
        .env("WRONG_CHAIN_CHILD", "1").env("AGENTSWAP_RPC_URL_1", &rpc.url).output().unwrap();
    assert!(output.status.success(), "{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr));
    assert_eq!(rpc.called("eth_chainId"), 1);
    assert_eq!(rpc.called("eth_call"), 0);
}

#[test]
fn audit_fix_archive_refusals_retry_each_chunk_and_preserve_search_scope() {
    use crate::service::test_rpc::{ok, TestRpc};
    use serde_json::json;
    if let Ok(case) = std::env::var("ARCHIVE_RETRY_CHILD") {
        tokio::runtime::Runtime::new().unwrap().block_on(check_archive_result(&case));
        return;
    }
    for recover in [true, false] {
        let mut attempts = std::collections::BTreeMap::new();
        let rpc = TestRpc::start(move |body| {
            let result = match body["method"].as_str().unwrap() {
                "eth_chainId" => json!("0x1"),
                "eth_call" => {
                    // filled(id) and cancelled(id) answer false; the lens answers PREVIEW_LAYOUT 3.
                    use alloy::sol_types::SolCall;
                    let tx = &body["params"][0];
                    let data = tx.get("input").or_else(|| tx.get("data")).unwrap().as_str().unwrap();
                    let settled = [IntentSettlerV3::filledCall::SELECTOR, IntentSettlerV3::cancelledCall::SELECTOR]
                        .iter().any(|s| data.starts_with(&format!("0x{}", hex::encode(s))));
                    json!(format!("0x{:064x}", if settled { 0 } else { 3 }))
                }
                "eth_blockNumber" => json!("0x4e20"),
                "eth_getLogs" => {
                    let count = attempts.entry(body["params"].to_string()).or_insert(0);
                    *count += 1;
                    if !recover || *count <= 3 {
                        return Some(json!({"jsonrpc":"2.0", "id":body["id"], "error": {
                            "code": -32602, "message": "Archive requests require a personal token"}}));
                    }
                    json!([])
                }
                method => panic!("unexpected {method}"),
            };
            Some(ok(body, result))
        });
        let output = std::process::Command::new(std::env::current_exe().unwrap())
            .args(["audit_fix_archive_refusals_retry_each_chunk_and_preserve_search_scope", "--nocapture"])
            .env("ARCHIVE_RETRY_CHILD", if recover { "recover" } else { "refuse" })
            .env("AGENTSWAP_RPC_URL_1", &rpc.url).output().unwrap();
        assert!(output.status.success(), "{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr));
        assert_eq!(rpc.called("eth_getLogs"), if recover { 8 } else { 4 });
        assert_eq!(rpc.called("eth_chainId"), 1);
    }
}

#[test]
fn ethereum_reads_use_v7_lens_and_settler_with_bounded_log_queries() {
    use alloy::sol_types::{SolCall, SolValue};
    use crate::service::test_rpc::{ok, TestRpc};
    use serde_json::json;
    if std::env::var_os("ETHEREUM_READ_TEST_CHILD").is_some() {
        tokio::runtime::Runtime::new().unwrap().block_on(async {
            let config = evm::chain_config("ethereum").unwrap();
            let provider = evm::read_provider_checked(&evm::rpc_url(config), config.id).await.unwrap();
            // An unreachable index sends both reads to bounded V7 settler log windows.
            let down = Origins { stream: "http://127.0.0.1:1", data: "http://127.0.0.1:1" };
            let records = list_from(&down, &provider, config, &ListInput {
                chain_id: "ethereum".into(), owner: Some(format!("{:?}", Address::ZERO)),
                agent: None, lookback_blocks: Some(9_000),
            }).await.unwrap();
            assert!(records.is_empty());
            let error = status_from(&down, &provider, config, &StatusInput {
                chain_id: "1".into(), id: format!("{:?}", B256::ZERO), lookback_blocks: Some(9_000),
            }).await.unwrap_err();
            assert!(error.to_string().contains("neither filled nor cancelled"), "{error}");
        });
        return;
    }
    let config = evm::chain_config("1").unwrap();
    let rpc = TestRpc::start(move |body| {
        let result = match body["method"].as_str().unwrap() {
            "eth_chainId" => json!("0x1"),
            "eth_call" => {
                let tx = &body["params"][0];
                let data = tx.get("input").or_else(|| tx.get("data")).unwrap().as_str().unwrap().to_string();
                if tx["to"] == format!("{:?}", config.settler) {
                    // The status fallback asks the V7 settler for filled(id) and cancelled(id): neither.
                    let selectors = [IntentSettlerV3::filledCall::SELECTOR, IntentSettlerV3::cancelledCall::SELECTOR];
                    assert!(selectors.iter().any(|s| data.starts_with(&format!("0x{}", hex::encode(s)))), "{data}");
                    json!(format!("0x{}", hex::encode(false.abi_encode())))
                } else {
                    assert_eq!(tx["to"], format!("{:?}", config.lens));
                    assert_eq!(data, format!("0x{}", hex::encode(IntentLensV3::PREVIEW_LAYOUTCall::SELECTOR)));
                    json!(format!("0x{}", hex::encode(U256::from(3).abi_encode())))
                }
            }
            "eth_blockNumber" => json!("0x4e20"),
            "eth_getLogs" => {
                let filter = &body["params"][0];
                assert_eq!(filter["address"], format!("{:?}", config.settler));
                let start = u64::from_str_radix(filter["fromBlock"].as_str().unwrap().trim_start_matches("0x"), 16).unwrap();
                let end = u64::from_str_radix(filter["toBlock"].as_str().unwrap().trim_start_matches("0x"), 16).unwrap();
                assert!(start >= 11_000 && end <= 20_000 && end - start < 5_000);
                json!([])
            }
            method => panic!("unexpected RPC: {method}"),
        };
        Some(ok(body, result))
    });
    let output = std::process::Command::new(std::env::current_exe().unwrap())
        .args(["ethereum_reads_use_v7_lens_and_settler_with_bounded_log_queries", "--nocapture"])
        .env("ETHEREUM_READ_TEST_CHILD", "1").env("AGENTSWAP_RPC_URL_1", &rpc.url).output().unwrap();
    assert!(output.status.success(), "{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr));
    assert_eq!(rpc.called("eth_getLogs"), 4);
}

#[test]
fn status_word_explains_v3_exclusive_window() {
    let view = IntentLensV3::IntentView {
        id: B256::ZERO,
        cancelled: false,
        filled: false,
        nonceSpent: false,
        killedByOwner: false,
        proxyDeployed: true,
        inWindow: true,
        exclusiveWindow: true,
        decayComplete: false,
        floorNow: U256::from(3_000_000_000u64),
        feeNow: U256::from(900_000u64),
        requiredNow: U256::from(3_000_900_000u64),
        floorForOutsider: U256::from(3_007_500_000u64),
        requiredForOutsider: U256::from(3_008_402_250u64),
        ownerBalance: U256::ZERO,
        ownerProxyAllowance: U256::ZERO,
        proxy: Address::ZERO,
        observedAt: U256::ZERO,
        observedBlock: U256::ZERO,
    };
    assert_eq!(
        status_word(&view, false),
        ("open".to_string(), "exclusive window: only the system solver fills at the floor; an outsider pays the floor plus the exclusivity override".to_string())
    );
}

async fn check_archive_result(case: &str) {
    let config = evm::chain_config("1").unwrap();
    let provider = evm::read_provider_checked(&evm::rpc_url(config), config.id).await.unwrap();
    // An unreachable index leaves the archive-limited log scan, then the settler's verdict.
    let down = Origins { stream: "http://127.0.0.1:1", data: "http://127.0.0.1:1" };
    let result = status_from(&down, &provider, config, &StatusInput {
        chain_id: "1".into(), id: format!("{:?}", B256::ZERO), lookback_blocks: Some(9_000),
    }).await;
    let error = result.expect_err("empty or unavailable logs").to_string();
    if case == "recover" {
        assert!(error.contains("neither filled nor cancelled"), "{error}");
    } else {
        assert!(error.contains("RPC backend") && error.contains("AGENTSWAP_RPC_URL_1"), "{error}");
        assert!(!error.contains("for block span"), "{error}");
    }
}

#[tokio::test]
async fn other_chains_and_other_rpc_errors_are_not_retried() {
    for (chain, code, message) in [
        (8453, -32602, "Archive requests require a personal token"),
        (1, -32000, "Archive requests require a personal token"),
        (1, -32602, "invalid filter"),
    ] {
        let rpc = TestRpc::start(move |body| {
            Some(if body["method"] == "eth_chainId" {
                ok(body, json!(format!("0x{chain:x}")))
            } else {
                json!({"jsonrpc":"2.0", "id":body["id"], "error":{"code":code,"message":message}})
            })
        });
        let config = evm::chain_config(&chain.to_string()).unwrap();
        let provider = evm::read_provider_checked(&rpc.url, chain).await.unwrap();
        let settler = IntentSettlerV3::new(config.settler, provider);
        let filter = settler.IntentAnnounced_filter();
        assert!(evm::event_query(config, 1, 2, || filter.query()).await.is_err());
        assert_eq!(rpc.called("eth_getLogs"), 1);
        assert_eq!(rpc.called("eth_chainId"), 1);
    }
}

#[tokio::test]
async fn unreadable_chain_id_fails_closed() {
    let rpc =
        TestRpc::start(|body| Some(crate::service::test_rpc::failure(body, "unavailable")));
    let error = evm::read_provider_checked(&rpc.url, 1).await.unwrap_err();
    assert!(error.to_string().contains("failed to read RPC chain id"));
    assert_eq!(rpc.called("eth_chainId"), 1);
    assert_eq!(rpc.called("eth_call"), 0);
}