agentswap 0.12.0

AgentSwap CLI for requesting quotes, inspecting tokens and pools, managing intents, and registering an API key against the AgentSwap service.
// Ethereum owner intent regressions against a local RPC, without private keys or live writes.
// Covers owner envelopes, signing domain, parity rejection, dry runs and self-paid submission.
// Deps: parent owner module, test RPC and subprocess environment isolation.

use super::*;
use alloy::primitives::{keccak256, Signature};
use crate::service::test_rpc::{ok, TestRpc};
use serde_json::{json, Value};
use std::sync::atomic::{AtomicUsize, Ordering};

struct TestSigner(AtomicUsize);

#[async_trait::async_trait]
impl Signer for TestSigner {
    fn address(&self) -> Address { Address::repeat_byte(1) }
    async fn sign_message(&self, _: &[u8]) -> Result<Signature> { panic!("unexpected message signing") }
    async fn sign_hash(&self, _: B256) -> Result<Signature> {
        self.0.fetch_add(1, Ordering::SeqCst);
        Ok(Signature::from_scalars_and_parity(B256::repeat_byte(1), B256::repeat_byte(2), false))
    }
}

#[test]
fn owner_envelope_and_digest_use_the_settler_domain() {
    let config = evm::chain_config("1").unwrap();
    let order = super::super::tests::build_order_for_record();
    let signature = Bytes::from(vec![7; 65]);
    let envelope = owner_envelope(signature.clone());
    let decoded = order_types::IntentAbiCodec::encodeEnvelopeCall::abi_decode_raw(&envelope).unwrap();
    assert_eq!(decoded.kind, 0);
    assert_eq!(Bytes::abi_decode(&decoded.payload).unwrap(), signature);
    let digest = owner_digest(config, &order);
    assert_ne!(digest, owner_digest(evm::chain_config("8453").unwrap(), &order));
    let mut wrong_settler = config;
    wrong_settler.settler = Address::ZERO;
    assert_ne!(digest, owner_digest(wrong_settler, &order));
    assert_eq!(digest, order_types::signing_hash(&order, &eip712_domain! {
        name: "AgentSwap IntentSettler", version: "3", chain_id: 1,
        verifying_contract: config.settler,
    }));
}

#[test]
fn ethereum_owner_place_checks_parity_and_self_submits_to_v7() {
    if std::env::var_os("ETHEREUM_OWNER_TEST_CHILD").is_some() {
        tokio::runtime::Runtime::new().unwrap().block_on(exercise_place());
        return;
    }
    for bad_digest in [false, true] {
        let rpc = TestRpc::start(move |body| Some(reply(body, bad_digest)));
        let output = std::process::Command::new(std::env::current_exe().unwrap())
            .args(["ethereum_owner_place_checks_parity_and_self_submits_to_v7", "--nocapture"])
            .env("ETHEREUM_OWNER_TEST_CHILD", if bad_digest { "bad" } else { "good" })
            .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_sendRawTransaction"), usize::from(!bad_digest));
    }
}

async fn exercise_place() {
    let signer = Arc::new(TestSigner(AtomicUsize::new(0)));
    let relay = Client::new("http://127.0.0.1:1", None);
    let announcer = Announcer { relay: &relay, wait: submit::Wait::MCP, pacer: None };
    let mut input = super::super::tests::input("USDC", "1");
    input.chain_id = "1".into();
    input.to = "WETH".into();
    input.proxy_owner = format!("{:?}", signer.address());
    input.self_submit = true;
    input.duration_secs = Some(600);
    let bad = std::env::var("ETHEREUM_OWNER_TEST_CHILD").unwrap() == "bad";
    let result = super::super::place(announcer, input.clone(), signer.clone(), true).await;
    if bad {
        assert!(result.unwrap_err().to_string().contains("does not match the settler"));
        assert_eq!(signer.0.load(Ordering::SeqCst), 0);
        return;
    }
    let preview = result.unwrap();
    assert!(preview.dry_run && preview.signature.is_none() && preview.authorization.is_none());
    assert_eq!(signer.0.load(Ordering::SeqCst), 0);
    input.dry_run = false;
    let forced = super::super::place(announcer, input.clone(), signer.clone(), false).await.unwrap();
    assert!(forced.dry_run && forced.signature.is_none());
    let outcome = super::super::place(announcer, input.clone(), signer.clone(), true).await.unwrap();
    assert_eq!(outcome.tx_status, Some(submit::TxStatus::Confirmed));
    assert!(outcome.tx_explorer_url.unwrap().starts_with("https://etherscan.io/tx/"));
    assert!(outcome.authorization.is_none() && outcome.relay.is_none());
    assert_eq!(signer.0.load(Ordering::SeqCst), 2);
    input.deadline_secs = Some(120);
    let error = super::super::place(announcer, input.clone(), signer.clone(), true).await.unwrap_err();
    assert!(error.to_string().contains("--deadline-secs applies to agent authorization"));
    input.deadline_secs = None;
    input.proxy_owner = format!("{:?}", Address::ZERO);
    let error = super::super::place(announcer, input, signer.clone(), true).await.unwrap_err();
    assert!(error.to_string().contains("require the owner signer"));
    assert_eq!(signer.0.load(Ordering::SeqCst), 2);
}

fn reply(body: &Value, bad_digest: bool) -> Value {
    let config = evm::chain_config("1").unwrap();
    let value = match body["method"].as_str().unwrap() {
        "eth_call" => return contract_reply(body, bad_digest),
        "eth_chainId" => json!("0x1"),
        "eth_getTransactionCount" => json!("0x0"),
        "eth_estimateGas" => {
            assert_eq!(body["params"][0]["to"], format!("{:?}", config.settler));
            json!("0x30000")
        }
        "eth_feeHistory" => json!({"oldestBlock": "0x1", "baseFeePerGas": ["0x3b9aca00", "0x3b9aca00"],
            "gasUsedRatio": [0.5], "reward": [["0x3b9aca00"]]}),
        "eth_sendRawTransaction" => {
            let raw = hex::decode(body["params"][0].as_str().unwrap().trim_start_matches("0x")).unwrap();
            json!(keccak256(raw))
        }
        "eth_getTransactionReceipt" => json!({
            "type": "0x2", "status": "0x1", "cumulativeGasUsed": "0x5208", "logs": [],
            "logsBloom": format!("0x{}", "0".repeat(512)), "transactionHash": body["params"][0],
            "transactionIndex": "0x0", "blockHash": format!("0x{}", "11".repeat(32)), "blockNumber": "0x1",
            "gasUsed": "0x5208", "effectiveGasPrice": "0x3b9aca00", "from": Address::repeat_byte(1),
            "to": config.settler, "contractAddress": null,
        }),
        method => panic!("unexpected method: {method}"),
    };
    ok(body, value)
}

fn contract_reply(body: &Value, bad_digest: bool) -> Value {
    let config = evm::chain_config("1").unwrap();
    let tx = &body["params"][0];
    let data = tx.get("input").or_else(|| tx.get("data")).unwrap().as_str().unwrap();
    let bytes = hex::decode(data.trim_start_matches("0x")).unwrap();
    let result = match &bytes[..4] {
        s if s == UserProxyFactoryV6::proxyOfCall::SELECTOR => {
            assert_eq!(tx["to"], format!("{:?}", config.factory));
            Address::repeat_byte(2).abi_encode()
        }
        s if s == IntentSettlerV3::orderHashCall::SELECTOR => {
            assert_eq!(tx["to"], format!("{:?}", config.settler));
            let call = IntentSettlerV3::orderHashCall::abi_decode(&bytes).unwrap();
            order_types::order_id(&call.o).abi_encode()
        }
        s if s == IntentSettlerV3::orderDigestCall::SELECTOR => {
            assert_eq!(tx["to"], format!("{:?}", config.settler));
            let call = IntentSettlerV3::orderDigestCall::abi_decode(&bytes).unwrap();
            if bad_digest { B256::ZERO.abi_encode() } else { owner_digest(config, &call.o).abi_encode() }
        }
        s if s == UserProxyV6::isIntentAuthorizedCall::SELECTOR => {
            let call = UserProxyV6::isIntentAuthorizedCall::abi_decode(&bytes).unwrap();
            let envelope = order_types::IntentAbiCodec::encodeEnvelopeCall::abi_decode_raw(&call.auth).unwrap();
            assert_eq!(envelope.kind, 0);
            assert_eq!(tx["from"], format!("{:?}", config.settler));
            true.abi_encode()
        }
        _ => panic!("unexpected call: {data}"),
    };
    ok(body, json!(hex_bytes(&result)))
}