agentswap 0.13.0

AgentSwap CLI for requesting quotes, inspecting tokens and pools, managing intents, and registering an API key against the AgentSwap service.
// Owner-facing batch-sale report: confirmed request, one batched grant read, independent prices,
// the intent index's orders and fill records, and one batched lens read for each intent's status.
// Needs no key and scans no logs.
mod budget;
mod models;
mod render;
mod rows;
#[cfg(test)]
mod tests;

pub use models::{ReportInput, ReportOutput};
pub use render::text;
use super::{models::Record, request, run};
use crate::{evm::{self, ChainConfig}, order_types::UserProxyV6, service::{intent, intentscan::{self, Fill, Intent, Origins}, portfolio::{discovery, prices}, token}};
use alloy::{primitives::{Address, U256}, providers::DynProvider};
use eyre::{Result, ensure};
use models::{GrantState, ReportToken};

/// History reaches this far before the confirmation time, for clock differences.
const WINDOW_SLACK_MS: u64 = 600_000;

pub async fn report(input: &ReportInput) -> Result<ReportOutput> {
    let record = request::get(&input.request).await?;
    let config = evm::chain_config(&record.request.chain_id.to_string())?;
    let provider = evm::read_provider(&evm::rpc_url(config))?;
    build(record, &provider, crate::routes::app_origin(), &Origins::public()).await
}

pub(super) async fn build(record: Record, provider: &DynProvider, app: &str, index: &Origins<'_>) -> Result<ReportOutput> {
    ensure!(matches!(record.status.as_str(), "confirmed" | "expired") && record.confirmed.is_some(), "grant request is not confirmed; nothing was sold under it");
    let basket = run::basket(&record)?;
    let (agent, proxy, receive) = (discovery::erc20(&record.request.agent)?, discovery::erc20(&basket.proxy)?, discovery::erc20(&basket.receive)?);
    let owner = match &record.request.owner {
        Some(owner) => discovery::erc20(owner)?,
        None => UserProxyV6::new(proxy, provider.clone()).owner().call().await?,
    };
    let spend = basket.tokens.iter().map(|t| discovery::erc20(t)).collect::<Result<Vec<_>>>()?;
    let all: Vec<Address> = spend.iter().copied().chain([receive]).collect();
    let budget = budget::read(provider, proxy, agent, owner, &all).await?;
    let config = evm::chain_config(&basket.chain_id.to_string())?;
    let generation_ok = budget.generation.to_string() == basket.generation;
    let live = generation_ok && budget.expiry > budget.now;
    let price_map = prices::prices(basket.chain_id, &all, app).await;
    let mut warnings = Vec::new();
    if !generation_ok { warnings.push("the grant changed after confirmation; sold amounts come from intent records only".into()); }
    let generation: u64 = basket.generation.parse()?;
    let since = window_start(&record).saturating_sub(WINDOW_SLACK_MS);
    let intents = matched(index, (provider, config), (owner, agent, generation, live), since, (receive, &spend), &mut warnings).await;
    let receive_state = budget.tokens.last().ok_or_else(|| eyre::eyre!("receive token missing from the budget read"))?;
    let receive_decimals = decimals(receive, basket.chain_id, receive_state.decimals)?;
    let prices = rows::Prices { map: &price_map, receive, receive_decimals };
    let mut tally = rows::Tally::default();
    tally.unlisted = intents.is_none();
    let intents = intents.unwrap_or_default();
    let mut tokens = Vec::new();
    for (index, address) in spend.iter().enumerate() {
        let state = &budget.tokens[index];
        let token = spend_token(*address, basket.chain_id, state, basket.caps.get(&address.to_string()), generation_ok)?;
        let mine: Vec<_> = intents.iter().filter(|(i, _)| i.order.tokenIn == *address).collect();
        tokens.push(rows::row(&token, &mine, &prices, &mut tally, &mut warnings));
    }
    let grant = GrantState { expiry: budget.expiry, expires_at: rows::time(budget.expiry.saturating_mul(1000)),
        active: live, generation_matches: generation_ok };
    let next = next(&record.id, &grant, &tally);
    Ok(ReportOutput { request: record.id, chain_id: basket.chain_id, owner: format!("{owner:?}"), agent: format!("{agent:?}"),
        receive: ReportToken { address: receive.to_string(), symbol: token::label(receive, basket.chain_id, receive_state.symbol.as_deref()), decimals: receive_decimals },
        max_loss_bps: basket.max_loss_bps, grant, as_of: rows::time(budget.now * 1000), summary: rows::summary(tally, spend.len(), &prices),
        tokens, next, warnings })
}

fn window_start(record: &Record) -> u64 {
    let stamp = record.confirmed.as_ref().and_then(|c| c.confirmed_at.as_deref()).or(record.created_at.as_deref());
    stamp.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()).and_then(|t| u64::try_from(t.timestamp_millis()).ok()).unwrap_or(0)
}

fn decimals(address: Address, chain: u64, read: Option<u8>) -> Result<u8> {
    read.or_else(|| token::from_registry(&address.to_string(), chain).map(|t| t.decimals))
        .ok_or_else(|| eyre::eyre!("token {address} does not answer decimals()"))
}

fn spend_token(address: Address, chain: u64, state: &budget::TokenState, cap: Option<&String>, generation_ok: bool) -> Result<rows::Token> {
    let decimals = decimals(address, chain, state.decimals)?;
    let cap = match (generation_ok && state.allowed, cap) {
        (true, _) => state.cap,
        (false, Some(cap)) => super::raw_cap(cap, decimals)?,
        (false, None) => U256::ZERO,
    };
    Ok(rows::Token { address, symbol: token::label(address, chain, state.symbol.as_deref()), decimals, cap,
        sold: if generation_ok { state.used } else { U256::ZERO }, balance: state.balance.unwrap_or_default() })
}

/// This sale's intents: placed by the agent under the confirmed generation, selling a request
/// token for the receive token. The index supplies each signed order and the lens its status, as
/// `intent status` reads it; filled ones carry their fill record when the index has it. None when
/// the index does not answer; each status is `unknown` when the lens read fails. The lens cannot see
/// the agent's policy, so while the grant is not `live` (another generation, or an expired policy)
/// an intent it calls open is dead: the proxy refuses its fill.
async fn matched(index: &Origins<'_>, (provider, config): (&DynProvider, ChainConfig), (owner, agent, generation, live): (Address, Address, u64, bool),
    since: u64, (receive, spend): (Address, &[Address]), warnings: &mut Vec<String>) -> Option<Vec<(Intent, Option<Fill>)>> {
    let history = match intentscan::history(index, owner, config.id, since).await {
        Ok(history) => history,
        Err(error) => {
            warnings.push(format!("intent records unavailable ({}); sold amounts come from the grant budget, proceeds are unknown, and intents may be open", crate::redact::urls(&error.to_string())));
            return None;
        }
    };
    let mine: Vec<Intent> = history.into_iter().filter(|i| i.agent == Some((agent, generation)) && i.order.tokenOut == receive && spend.contains(&i.order.tokenIn)).collect();
    if mine.is_empty() { return Some(Vec::new()); }
    let statuses = lens_statuses(provider, config, &mine, warnings).await;
    let mut out = Vec::new();
    for (mut intent, status) in mine.into_iter().zip(statuses) {
        intent.status = if status == "open" && !live { "dead".into() } else { status };
        let fill = if intent.status == "filled" { intentscan::fill(index, intent.id).await.unwrap_or_else(|error| {
            warnings.push(format!("fill record for {:?} unavailable: {}", intent.id, crate::redact::urls(&error.to_string()))); None
        }) } else { None };
        out.push((intent, fill));
    }
    Some(out)
}

/// The lens's status of each intent, or `unknown` for every one, with a warning, when the read fails.
async fn lens_statuses(provider: &DynProvider, config: ChainConfig, intents: &[Intent], warnings: &mut Vec<String>) -> Vec<String> {
    let read = intent::preview_statuses(provider, config, intents.iter().map(|i| i.order.clone()).collect()).await.and_then(|statuses| {
        ensure!(statuses.len() == intents.len(), "lens returned {} views for {} intents", statuses.len(), intents.len());
        Ok(statuses)
    });
    read.unwrap_or_else(|error| {
        warnings.push(format!("intent statuses unavailable ({}); tokens with intents in the index are reported unknown unless the grant budget shows them fully sold, and intents may be open", crate::redact::urls(&error.to_string())));
        vec!["unknown".into(); intents.len()]
    })
}

fn next(id: &str, grant: &GrantState, tally: &rows::Tally) -> Vec<String> {
    let mut next = Vec::new();
    if tally.open > 0 {
        next.push(format!("{} intent{} open until {}; send the owner `batch-sell report --request {id}` again after that.",
            tally.open, if tally.open == 1 { " is" } else { "s are" }, rows::time(tally.open_until)));
    }
    if !tally.unsold {
        if tally.open == 0 { next.push("Nothing in this request is left to sell.".into()); }
    } else if grant.active && tally.unknown {
        next.push(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`."));
    } else if grant.active {
        let lead = if tally.open > 0 { "After the open intents close, run" } else { "Run" };
        next.push(format!("{lead} `batch-sell run --request {id}` (MCP `batch_sell_run`) before the grant expires at {} to offer the unsold amount again.", grant.expires_at));
    } else if !grant.generation_matches {
        next.push("The owner changed the grant after confirming this request; selling the rest needs a new `batch-sell plan` and owner confirmation.".into());
    } else {
        next.push(format!("The grant expired at {}; selling the rest needs a new `batch-sell plan` and owner confirmation.", grant.expires_at));
    }
    next
}