agentswap 0.13.0

AgentSwap CLI for requesting quotes, inspecting tokens and pools, managing intents, and registering an API key against the AgentSwap service.
// Batch-sale report from a mocked request, one mocked multicall grant read, a mocked lens, mocked
// app prices and a mocked intent index: sold, partly sold, expired and not placed rows, discounts
// and next steps.
use super::{build, next, models::GrantState, rows::{self, discount_bps, pct}, text};
use crate::{order_types::{IntentLensV3, Order, UserProxyV6}, service::{intentscan::{Origins, fixture}, portfolio::discovery::BalanceReader, test_http::TestHttp, test_rpc::{TestRpc, failure, ok}, token}};
use crate::order_types::Erc20Metadata;
use alloy::{primitives::{Address, B256, U256}, providers::{MULTICALL3_ADDRESS, bindings::IMulticall3}, sol_types::{SolCall, SolValue}};
use serde_json::{Value, json};

const ID: &str = "abcdefghijklmnopqrstuv";
const NOW: u64 = 1_800_000_000;
const E18: u64 = 1_000_000_000_000_000_000;

fn owner() -> Address { Address::repeat_byte(4) }
fn agent() -> Address { Address::repeat_byte(2) }
fn proxy() -> Address { Address::repeat_byte(6) }
fn usdc() -> Address { token::from_registry("USDC", 8453).unwrap().address.parse().unwrap() }
fn spend() -> [Address; 4] { [0x11, 0x22, 0x33, 0x44].map(Address::repeat_byte) }

#[test]
fn discount_math_is_signed_basis_points_rendered_as_percent() {
    let u = |v: u64| U256::from(v);
    assert_eq!(discount_bps(u(1000), u(990)), Some(100));
    assert_eq!(discount_bps(u(1000), u(1010)), Some(-100));
    assert_eq!(discount_bps(u(3), u(2)), Some(3333));
    assert_eq!(discount_bps(U256::ZERO, u(5)), None);
    assert_eq!(discount_bps(u(1), U256::MAX), None, "an absurd ratio is unreported, not wrapped");
    assert_eq!([pct(100), pct(-100), pct(3333), pct(5), pct(-5), pct(0)], ["1.00", "-1.00", "33.33", "0.05", "-0.05", "0.00"]);
}

fn record() -> crate::service::batch_sell::models::Record {
    let mut tokens: Vec<Value> = spend().iter().zip(["100", "50", "10", "5"]).map(|(a, cap)| json!({"address": a, "cap": cap})).collect();
    tokens.push(json!({"address": usdc(), "cap": "0"}));
    serde_json::from_value(json!({"id": ID, "status": "confirmed", "createdAt": "2027-01-15T07:00:00Z",
        "confirmed": {"proxy": proxy(), "generation": "1", "maxLossBps": 300, "confirmedAt": "2027-01-15T07:10:00Z"},
        "request": {"v": 1, "chainId": 8453, "agent": agent(), "owner": owner(), "purpose": "batch-sell", "maxLossBps": 500, "tokens": tokens}})).unwrap()
}

/// (cap, used, balance) per spend token, in whole 18-decimal units.
const BUDGETS: [(u64, u64, u64); 4] = [(100, 100, 0), (50, 20, 30), (10, 0, 10), (5, 0, 5)];

/// The agent's policy as (expiry, generation): live and of the confirmed generation 1.
const LIVE: (u64, u64) = (NOW + 3600, 1);

fn answer(call: &IMulticall3::Call3, (expiry, generation): (u64, u64)) -> Vec<u8> {
    let data = &call.callData;
    let token = spend().iter().position(|a| *a == call.target);
    let e18 = |v: u64| U256::from(v) * U256::from(E18);
    match &data[..4] {
        s if s == UserProxyV6::ownerCall::SELECTOR => UserProxyV6::ownerCall::abi_encode_returns(&owner()),
        s if s == UserProxyV6::policyOfCall::SELECTOR => UserProxyV6::policyOfCall::abi_encode_returns(&UserProxyV6::policyOfReturn { expiry, epochLen: 604_800, actionMask: 5, generation }),
        s if s == IMulticall3::getCurrentBlockTimestampCall::SELECTOR => IMulticall3::getCurrentBlockTimestampCall::abi_encode_returns(&U256::from(NOW)),
        s if s == UserProxyV6::agentTokenInfoCall::SELECTOR => {
            let asked = UserProxyV6::agentTokenInfoCall::abi_decode(data).unwrap().token;
            let (cap, used, _) = spend().iter().position(|a| *a == asked).map_or((0, 0, 0), |i| BUDGETS[i]);
            UserProxyV6::agentTokenInfoCall::abi_encode_returns(&UserProxyV6::agentTokenInfoReturn { allowed: true, cap: e18(cap), used: e18(used), epochStart: 1 })
        }
        s if s == Erc20Metadata::decimalsCall::SELECTOR => Erc20Metadata::decimalsCall::abi_encode_returns(&if token.is_some() { 18 } else { 6 }),
        s if s == Erc20Metadata::symbolCall::SELECTOR => Erc20Metadata::symbolCall::abi_encode_returns(&["AAA", "BBB", "CCC", "DDD", "USDC"][token.unwrap_or(4)].to_string()),
        s if s == BalanceReader::balanceOfCall::SELECTOR => BalanceReader::balanceOfCall::abi_encode_returns(&token.map_or(U256::ZERO, |i| e18(BUDGETS[i].2))),
        other => panic!("unexpected selector {}", hex::encode(other)),
    }
}

/// The lens's view of the test orders: nonces 1 and 2 filled, the rest past their window.
fn settled(order: &Order) -> IntentLensV3::IntentView { fixture::view(order.nonce <= U256::from(2), false, false) }

fn chain() -> TestRpc { chain_with(settled, LIVE) }

fn chain_with(lens: fn(&Order) -> IntentLensV3::IntentView, policy: (u64, u64)) -> TestRpc { rpc(Some(lens), policy) }

/// The grant multicall with `policy`, and the lens answering each order with `lens`, or HTTP 429
/// to every lens call without one.
fn rpc(lens: Option<fn(&Order) -> IntentLensV3::IntentView>, policy: (u64, u64)) -> TestRpc {
    TestRpc::with_status(move |body| {
        assert_eq!(body["method"], "eth_call", "the report reads the chain only through eth_call");
        let tx = &body["params"][0];
        let data = hex::decode(tx["input"].as_str().or(tx["data"].as_str()).unwrap().trim_start_matches("0x")).unwrap();
        let to = tx["to"].as_str().unwrap().parse::<Address>().unwrap();
        if to == crate::evm::chain_config("8453").unwrap().lens {
            let Some(lens) = lens else { return Some((429, failure(body, "rate limited"))) };
            let reply = match &data[..4] {
                s if s == IntentLensV3::PREVIEW_LAYOUTCall::SELECTOR => U256::from(3).abi_encode(),
                s if s == IntentLensV3::previewManyCall::SELECTOR => IntentLensV3::previewManyCall::abi_encode_returns(
                    &IntentLensV3::previewManyCall::abi_decode(&data).unwrap().o.iter().map(lens).collect::<Vec<_>>()),
                other => panic!("unexpected lens selector {}", hex::encode(other)),
            };
            return Some((200, ok(body, json!(format!("0x{}", hex::encode(reply))))));
        }
        assert_eq!(to, MULTICALL3_ADDRESS);
        let calls = IMulticall3::aggregate3Call::abi_decode(&data).unwrap().calls;
        let results: Vec<_> = calls.iter().map(|c| IMulticall3::Result { success: true, returnData: answer(c, policy).into() }).collect();
        Some((200, ok(body, json!(format!("0x{}", hex::encode(IMulticall3::aggregate3Call::abi_encode_returns(&results)))))))
    })
}

fn app() -> TestHttp {
    let [a, b, c, _] = spend();
    let prices = json!({"prices": {a.to_string(): {"priceUsd": 2, "source": "defillama"}, b.to_string(): {"priceUsd": 0.5, "source": "defillama"},
        c.to_string(): {"priceUsd": 1, "source": "defillama"}, usdc().to_string(): {"priceUsd": 1, "source": "defillama"}}}).to_string();
    TestHttp::start(move |target| { assert!(target.starts_with("/api/prices?chainId=8453"), "{target}"); (200, prices.clone()) })
}

const CREATED: u64 = 1_799_997_600_000;

/// The intent index with the first `keep` history items: sold, part, lapsed, expired, then three
/// that do not match this sale.
fn index(keep: usize) -> TestHttp {
    let [a, b, c, _] = spend();
    let e = |v: u64| U256::from(v) * U256::from(E18);
    let order = |token, amount, out, nonce| { let mut o = fixture::order(owner(), token, 1, out, 1, nonce); o.amountIn = amount; o };
    let (sold, part, lapsed, expired) = (order(a, e(100), usdc(), 1), order(b, e(20), usdc(), 2), order(b, e(30), usdc(), 3), order(c, e(10), usdc(), 4));
    let (other_agent, other_generation, other_out) = (order(c, e(10), usdc(), 5), order(c, e(10), usdc(), 6), order(c, e(10), Address::repeat_byte(9), 7));
    let item = |o, who, generation, status, deadline| fixture::item(8453, o, who, generation, status, CREATED, deadline);
    let items = vec![item(&sold, agent(), 1, "filled", CREATED + 600_000), item(&part, agent(), 1, "filled", CREATED + 600_000),
        item(&lapsed, agent(), 1, "open", CREATED + 600_000), item(&expired, agent(), 1, "expired", CREATED + 600_000),
        item(&other_agent, Address::repeat_byte(8), 1, "filled", CREATED), item(&other_generation, agent(), 2, "filled", CREATED),
        item(&other_out, agent(), 1, "filled", CREATED)];
    let id = |o: &Order| crate::order_types::order_id(o);
    index_with(items.into_iter().take(keep).collect(), vec![(id(&sold), fixture::fill(id(&sold), "196000000", B256::repeat_byte(0xa1), 1_799_998_000_000)),
        (id(&part), fixture::fill(id(&part), "10100000", B256::repeat_byte(0xb1), 1_799_998_100_000))])
}

/// One history page of `items`, and `fills` by intent id.
fn index_with(items: Vec<Value>, fills: Vec<(B256, String)>) -> TestHttp {
    let page = fixture::page(items, None);
    TestHttp::start(move |target| {
        if target.starts_with("/v1/intents?") { return (200, page.clone()); }
        fills.iter().find(|(id, _)| target == format!("/v1/intent/{id:?}")).map_or((404, "{}".into()), |(_, body)| (200, body.clone()))
    })
}

#[tokio::test]
async fn report_rows_summary_and_next_steps_from_mocked_sources() {
    let (rpc, app, index) = (chain(), app(), index(7));
    let origins = Origins { stream: &index.url, data: &index.url };
    let report = build(record(), &crate::evm::read_provider(&rpc.url).unwrap(), &app.url, &origins).await.unwrap();
    assert_eq!(rpc.called("eth_call"), 3, "one grant multicall, the lens layout and one batched lens read");
    assert_eq!(rpc.called("eth_getLogs"), 0);
    let targets = index.targets();
    assert_eq!(targets.len(), 3, "one history page and one fill record per matching fill: {targets:?}");
    assert_eq!(targets[0], format!("/v1/intents?owner={:?}&chain_ids=8453&limit=500", owner()));
    let value = serde_json::to_value(&report).unwrap();
    let expected: Value = serde_json::from_str(&include_str!("tests_expected.json").replace("{USDC}", &usdc().to_string())).unwrap();
    assert_eq!(value, expected, "{}", serde_json::to_string_pretty(&value).unwrap());
    assert_eq!(text(&report), include_str!("tests_expected.txt"), "{}", text(&report));
}

#[tokio::test]
async fn report_without_the_index_states_only_budget_facts_and_asks_for_a_retry() {
    let (rpc, app) = (chain(), app());
    let down = Origins { stream: "http://127.0.0.1:1", data: "http://127.0.0.1:1" };
    let report = build(record(), &crate::evm::read_provider(&rpc.url).unwrap(), &app.url, &down).await.unwrap();
    let e = |v: u64| (U256::from(v) * U256::from(E18)).to_string();
    let statuses: Vec<_> = report.tokens.iter().map(|r| (r.status.clone(), r.sold_raw.clone(), r.received_raw.is_none())).collect();
    assert_eq!(statuses, vec![("sold".into(), e(100), true), ("unknown".into(), e(20), true), ("unknown".into(), "0".into(), true), ("unknown".into(), "0".into(), true)]);
    assert!(report.warnings[0].starts_with("intent records unavailable"), "{:?}", report.warnings);
    assert_eq!(serde_json::to_value(&report).unwrap()["summary"]["open_intents"], Value::Null, "open intents are unknown, not zero");
    assert_eq!(report.next, vec![retry()]);
    let s = &report.summary;
    assert_eq!((s.received_raw.as_str(), s.known_proceeds_tokens, s.unknown_proceeds_tokens, s.unknown_proceeds_value_usd.as_str()), ("0", 0, 2, "210"));
    assert_eq!(report.summary.average_discount_pct, None);
    let text = text(&report);
    assert!(!text.lines().any(|l| l.starts_with("Received")) && text.contains("Proceeds unknown for 2 tokens ($210.00 sold): no fill record."), "{text}");
}

#[tokio::test]
async fn market_sold_and_index_filled_rows_split_known_and_unknown_proceeds() {
    let (rpc, app, index) = (chain(), app(), index(1));
    let origins = Origins { stream: &index.url, data: &index.url };
    let report = build(record(), &crate::evm::read_provider(&rpc.url).unwrap(), &app.url, &origins).await.unwrap();
    let s = &report.summary;
    assert_eq!((s.tokens_sold, s.received_raw.as_str(), s.sold_value_usd.as_str()), (2, "196000000", "210"));
    assert_eq!((s.known_proceeds_tokens, s.known_proceeds_value_usd.as_str()), (1, "200"));
    assert_eq!((s.unknown_proceeds_tokens, s.unknown_proceeds_value_usd.as_str()), (1, "10"));
    let text = text(&report);
    assert!(text.contains("Sold 2/4 tokens worth $210.00 at independent prices.\nReceived 196 USDC ($196.00) for $200.00 sold with fill records.\n\
        Proceeds unknown for 1 token ($10.00 sold): no fill record.\n"), "{text}");
}

#[test]
fn zero_unsold_from_an_empty_balance_is_labelled() {
    let token = rows::Token { address: spend()[0], symbol: "AAA".into(), decimals: 0, cap: U256::from(10), sold: U256::from(4), balance: U256::ZERO };
    let map = std::collections::BTreeMap::new();
    let prices = rows::Prices { map: &map, receive: usdc(), receive_decimals: 6 };
    let (mut tally, mut warnings) = (rows::Tally::default(), Vec::new());
    let row = rows::row(&token, &[], &prices, &mut tally, &mut warnings);
    assert_eq!((row.status.as_str(), row.unsold_raw.as_str()), ("sold", "0"));
    assert!(warnings.contains(&"AAA: 6 of the cap was not sold; the owner holds none of this token, so none is left to sell".to_string()), "{warnings:?}");
}

#[tokio::test]
async fn report_refuses_an_unconfirmed_request_before_any_read() {
    let mut pending = record();
    pending.status = "pending".into();
    pending.confirmed = None;
    let provider = crate::evm::read_provider("http://127.0.0.1:1").unwrap();
    let error = build(pending, &provider, "http://127.0.0.1:1", &Origins::public()).await.unwrap_err();
    assert!(error.to_string().contains("not confirmed"), "{error}");
}

#[test]
fn open_intents_keep_the_row_open_and_ask_for_a_later_report() {
    let order = fixture::order(owner(), spend()[0], 7, usdc(), 1, 1);
    let open = (crate::service::intentscan::Intent { id: crate::order_types::order_id(&order), order, agent: Some((agent(), 1)),
        status: "open".into(), deadline_ms: NOW * 1000 + 300_000 }, None);
    let token = rows::Token { address: spend()[0], symbol: "AAA".into(), decimals: 18, cap: U256::from(7), sold: U256::ZERO, balance: U256::from(7) };
    let map = std::collections::BTreeMap::new();
    let prices = rows::Prices { map: &map, receive: usdc(), receive_decimals: 6 };
    let (mut tally, mut warnings) = (rows::Tally::default(), Vec::new());
    let row = rows::row(&token, &[&open], &prices, &mut tally, &mut warnings);
    assert_eq!((row.status.as_str(), row.open_until.as_deref(), row.unsold_raw.as_str()), ("open", Some("2027-01-15T08:05:00Z"), "7"));
    let grant = GrantState { expiry: NOW + 60, expires_at: "2027-01-15T08:01:00Z".into(), active: true, generation_matches: true };
    assert_eq!(next(ID, &grant, &tally), vec![
        format!("1 intent is open until 2027-01-15T08:05:00Z; send the owner `batch-sell report --request {ID}` again after that."),
        format!("After the open intents close, run `batch-sell run --request {ID}` (MCP `batch_sell_run`) before the grant expires at 2027-01-15T08:01:00Z to offer the unsold amount again.")]);
    let ended = GrantState { active: false, ..grant };
    assert!(next(ID, &ended, &tally)[1].starts_with("The grant expired at 2027-01-15T08:01:00Z; selling the rest needs a new `batch-sell plan`"));
}

/// The report for one CCC intent the index lists as open with `deadline_ms`, and its fill record.
async fn one_open_intent(lens: fn(&Order) -> IntentLensV3::IntentView, deadline_ms: u64, policy: (u64, u64)) -> (super::ReportOutput, Value) {
    let mut order = fixture::order(owner(), spend()[2], 1, usdc(), 1, 8);
    order.amountIn = U256::from(10) * U256::from(E18);
    let id = crate::order_types::order_id(&order);
    let index = index_with(vec![fixture::item(8453, &order, agent(), 1, "open", CREATED, deadline_ms)],
        vec![(id, fixture::fill(id, "9900000", B256::repeat_byte(0xc1), CREATED + 100_000))]);
    let (rpc, app) = (chain_with(lens, policy), app());
    let report = build(record(), &crate::evm::read_provider(&rpc.url).unwrap(), &app.url, &Origins { stream: &index.url, data: &index.url }).await.unwrap();
    let value = serde_json::to_value(&report).unwrap();
    (report, value)
}

#[tokio::test]
async fn an_intent_the_index_still_lists_as_open_is_sold_when_the_lens_finds_it_filled() {
    let (report, value) = one_open_intent(|_| fixture::view(true, false, false), CREATED + 600_000, LIVE).await;
    let row = &value["tokens"][2];
    assert_eq!((row["status"].as_str(), row["sold_raw"].as_str(), row["received_raw"].as_str()), (Some("sold"), Some("10000000000000000000"), Some("9900000")), "{row}");
    assert_eq!(row["fills"][0]["tx_hash"], format!("{:?}", B256::repeat_byte(0xc1)));
    assert!(!report.warnings.iter().any(|w| w.contains("no fill recorded")), "{:?}", report.warnings);
}

#[tokio::test]
async fn an_intent_the_index_lists_as_open_is_not_open_when_the_lens_finds_it_dead() {
    let dead = |_: &Order| IntentLensV3::IntentView { killedByOwner: true, ..fixture::view(false, false, true) };
    let (report, value) = one_open_intent(dead, NOW * 1000 + 300_000, LIVE).await;
    assert_eq!((value["tokens"][2]["status"].as_str(), value["tokens"][2]["open_until"].as_str()), (Some("unsold"), None));
    assert_eq!(value["summary"]["open_intents"], 0);
    assert!(!report.next.iter().any(|line| line.contains("open until")), "{:?}", report.next);
}

#[tokio::test]
async fn an_intent_the_lens_finds_open_is_not_open_after_a_revoke_or_policy_expiry() {
    for (policy, generation_matches) in [((0, 2), false), ((NOW, 1), true)] {
        let (report, value) = one_open_intent(|_| fixture::view(false, false, true), NOW * 1000 + 300_000, policy).await;
        assert_eq!((report.grant.active, report.grant.generation_matches), (false, generation_matches), "{policy:?}");
        assert_eq!((value["tokens"][2]["status"].as_str(), value["tokens"][2]["open_until"].as_str()), (Some("unsold"), None), "{policy:?}");
        assert_eq!(value["summary"]["open_intents"], 0, "{policy:?}");
        assert!(!report.next.iter().any(|line| line.contains("open until")), "{:?}", report.next);
    }
}

fn retry() -> String {
    format!("The intent index or the lens did not answer, so intents may be open; run `batch-sell report --request {ID}` (MCP `batch_sell_report`) again once they answer, before offering the unsold amount with `batch-sell run`.")
}

#[tokio::test]
async fn a_failed_lens_read_reports_tokens_with_intents_unknown_and_asks_for_a_retry() {
    let (rpc, app, index) = (rpc(None, LIVE), app(), index(7));
    let report = build(record(), &crate::evm::read_provider(&rpc.url).unwrap(), &app.url, &Origins { stream: &index.url, data: &index.url }).await.unwrap();
    let statuses: Vec<_> = report.tokens.iter().map(|r| r.status.as_str()).collect();
    assert_eq!(statuses, ["sold", "unknown", "unknown", "not_placed"], "{:?}", report.warnings);
    assert!(report.warnings.iter().any(|w| w.starts_with("intent statuses unavailable")), "{:?}", report.warnings);
    assert_eq!(serde_json::to_value(&report).unwrap()["summary"]["open_intents"], Value::Null);
    assert_eq!(report.next, vec![retry()]);
}