agentswap 0.13.0

AgentSwap CLI for requesting quotes, inspecting tokens and pools, managing intents, and registering an API key against the AgentSwap service.
// Fork-only setup helpers: fresh identities, local transactions and captured CLI output.
// Every write targets anvil; the ignored fork test exercises these helpers together.
use alloy::{primitives::{Address, U256, keccak256}, sol_types::{SolCall, SolValue}};
use serde_json::{Value, json};
use std::process::{Child, Command};
use std::time::{Duration, Instant};
use crate::{evm, service::token, signer::{Signer, local::LocalKey}};

alloy::sol! {
    function deploy(address owner);
    function deposit();
    function approve(address spender, uint256 amount);
    function grantAgent(address agent, uint64 expiry, uint32 epochLen, uint8 actions, address[] tokens, uint256[] caps);
    event ExecutedAsAgent(address indexed agent, address indexed router, address indexed relayer,
        address tokenIn, address tokenOut, uint256 amountIn, uint256 amountOut);
}

pub struct Fork {
    pub rpc: String,
    pub chain: u64,
    pub owner: Address,
    pub agent: Address,
    pub key: std::path::PathBuf,
    pub tokens: [Address; 3],
    pub cap: U256,
    child: Child,
}

impl Fork {
    pub fn start(url: &str) -> Self {
        let chain = std::env::var("CHAIN").unwrap_or_else(|_| "8453".into()).parse().unwrap();
        assert!(matches!(chain, 8453 | 56), "CHAIN must be 8453 or 56");
        let tokens = if chain == 56 {
            ["BSC_WBNB", "BSC_ETH", "BSC_USDT"].map(|name| std::env::var(name).expect(name).parse().unwrap())
        } else { ["WETH", "cbETH", "USDC"].map(|s| token::from_registry(s, chain).unwrap().address.parse().unwrap()) };
        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
        let port = listener.local_addr().unwrap().port();
        drop(listener);
        let child = Command::new("anvil").args(["--fork-url", url, "--port", &port.to_string(), "--silent"])
            .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).spawn().expect("anvil is required");
        let mut secret = [0u8; 32];
        getrandom::fill(&mut secret).unwrap();
        let owner = LocalKey::from_private_key(&hex::encode(secret)).unwrap().address();
        getrandom::fill(&mut secret).unwrap();
        let agent = LocalKey::from_private_key(&hex::encode(secret)).unwrap().address();
        let key = std::env::temp_dir().join(format!("batch-fork-{}.key", std::process::id()));
        use std::os::unix::fs::OpenOptionsExt;
        use std::io::Write;
        let mut file = std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).open(&key).unwrap();
        file.write_all(hex::encode(secret).as_bytes()).unwrap();
        // 0.1 of each token: anvil suggests a gas price of at least 1 gwei, far above Base's, so
        // smaller sales fall under the gas floor on a fork.
        let mut fork = Self { rpc:format!("http://127.0.0.1:{port}"), chain, owner, agent, key, tokens,
            cap:U256::from(100_000_000_000_000_000u64), child };
        // Poll eth_chainId every second, for at most 120 s, until the local node answers.
        let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
        let request = json!({"jsonrpc":"2.0","id":1,"method":"eth_chainId","params":[]});
        let deadline = Instant::now() + Duration::from_secs(120);
        let answer = loop {
            let reply = runtime.block_on(async { reqwest::Client::new().post(&fork.rpc).json(&request)
                .timeout(Duration::from_secs(5)).send().await.ok()?.json::<Value>().await.ok() });
            if let Some(result) = reply.and_then(|body| body["result"].as_str().map(String::from)) { break result; }
            assert!(fork.child.try_wait().unwrap().is_none(), "anvil exited before answering eth_chainId");
            assert!(Instant::now() < deadline, "anvil did not answer eth_chainId within 120 s");
            std::thread::sleep(Duration::from_secs(1));
        };
        assert_eq!(answer, format!("0x{chain:x}"), "AGENTSWAP_FORK_URL must serve CHAIN");
        fork
    }

    pub async fn setup(&self) -> Address {
        assert_eq!(rpc(&self.rpc, "eth_chainId", json!([])).await, format!("0x{:x}",self.chain));
        for who in [self.owner, self.agent] {
            rpc(&self.rpc, "anvil_setBalance", json!([who,format!("0x{:x}",U256::from(100u64)*U256::from(10).pow(U256::from(18)))])).await;
        }
        rpc(&self.rpc, "anvil_impersonateAccount", json!([self.owner])).await;
        let config = evm::chain_config(&self.chain.to_string()).unwrap();
        self.send(config.factory, deployCall {owner:self.owner}.abi_encode(), U256::ZERO).await;
        let provider = evm::read_provider(&self.rpc).unwrap();
        let proxy = crate::order_types::UserProxyFactoryV6::new(config.factory, provider).proxyOf(self.owner).call().await.unwrap();
        self.send(self.tokens[0], depositCall {}.abi_encode(), self.cap).await;
        self.balance_second(self.cap).await;
        proxy
    }

    pub async fn balance_second(&self, balance: U256) {
        let slot = if self.chain == 56 { 1 } else { 51 };
        let index = keccak256((self.owner, U256::from(slot)).abi_encode());
        rpc(&self.rpc, "anvil_setStorageAt", json!([self.tokens[1],index,format!("0x{:064x}",balance)])).await;
        let provider = evm::read_provider(&self.rpc).unwrap();
        assert_eq!(crate::service::portfolio::discovery::balance(&provider,self.tokens[1],self.owner).await.unwrap(),balance);
    }

    pub async fn grant(&self, proxy: Address) {
        let block = rpc(&self.rpc, "eth_getBlockByNumber", json!(["latest",false])).await;
        let now = u64::from_str_radix(block["timestamp"].as_str().unwrap().trim_start_matches("0x"),16).unwrap();
        self.send(proxy, grantAgentCall {agent:self.agent,expiry:now+86400,epochLen:604800,actions:5,
            tokens:self.tokens.to_vec(),caps:vec![self.cap,self.cap,U256::ZERO]}.abi_encode(),U256::ZERO).await;
        for address in &self.tokens[..2] {
            self.send(*address, approveCall {spender:proxy,amount:self.cap*U256::from(2)}.abi_encode(),U256::ZERO).await;
        }
    }

    pub async fn send(&self, to: Address, data: Vec<u8>, value: U256) {
        let hash = rpc(&self.rpc, "eth_sendTransaction", json!([{"from":self.owner,"to":to,
            "data":format!("0x{}",hex::encode(data)),"value":format!("0x{value:x}"),"gas":"0x989680"}])).await;
        // Anvil may answer with the hash before the block is mined: poll the receipt for at most 60 s.
        let deadline = Instant::now() + Duration::from_secs(60);
        let receipt = loop {
            let receipt = rpc(&self.rpc,"eth_getTransactionReceipt",json!([hash])).await;
            if !receipt.is_null() { break receipt; }
            assert!(Instant::now() < deadline, "no receipt for setup transaction {hash} within 60 s");
            tokio::time::sleep(Duration::from_millis(250)).await;
        };
        assert_eq!(receipt["status"],"0x1","setup transaction {hash} reverted");
    }

    pub fn cli(&self, app: &str, rpc: &str, args: &[String]) -> Result<Value, String> {
        let output = Command::new(std::env::current_exe().unwrap())
            .args(["--exact",super::NAME,"--ignored","--nocapture"])
            .env("BATCH_FORK_CHILD",app).env("BATCH_FORK_ARGS",serde_json::to_string(args).unwrap())
            .env("AGENTSWAP_RPC_URL",rpc).env(format!("AGENTSWAP_RPC_URL_{}",self.chain),rpc).output().unwrap();
        let text = String::from_utf8(output.stdout).unwrap();
        if !output.status.success() { return Err(format!("{text} {}",String::from_utf8_lossy(&output.stderr))); }
        let start = text.find("{\n").expect("CLI JSON output");
        serde_json::Deserializer::from_str(&text[start..]).into_iter::<Value>().next().unwrap().map_err(|e|e.to_string())
    }
}

impl Drop for Fork {
    fn drop(&mut self) {
        let _ = self.child.kill();
        let _ = self.child.wait();
        let _ = std::fs::remove_file(&self.key);
    }
}

pub async fn rpc(url: &str, method: &str, params: Value) -> Value {
    let body: Value = reqwest::Client::new().post(url).json(&json!({"jsonrpc":"2.0","id":1,"method":method,"params":params}))
        .send().await.unwrap().json().await.unwrap();
    assert!(body.get("error").is_none(),"local RPC {method} failed: {body}");
    body["result"].clone()
}