agentswap 0.13.0

AgentSwap CLI for requesting quotes, inspecting tokens and pools, managing intents, and registering an API key against the AgentSwap service.
// One sweep token: read spend limits and independent prices, then place an intent or trade.
// Signing and submission stay in the existing intent and trade services.
use super::{Context, Row, Via, math, now, intent_sale};
use crate::{order_types::{self, UserProxyV6}, service::{intent::TokenPolicy, portfolio::{amount, discovery, prices::Price}, quote, token, trade}};
use alloy::primitives::{Address, U256};
use eyre::Result;

alloy::sol! {
    #[sol(rpc)]
    contract AllowanceReader {
        function allowance(address owner, address spender) external view returns (uint256);
    }
}

pub(super) async fn sell(context: &Context<'_>, budget: &TokenPolicy, row: &mut Row) -> Result<bool> {
    let address = discovery::erc20(&budget.token)?;
    let receive = discovery::erc20(&context.receive.address)?;
    if address == receive { row.reason = Some("receive_token".into()); return Ok(false); }
    if order_types::parse_u256(&budget.cap)? == U256::ZERO {
        row.reason = Some("zero".into()); return Ok(false);
    }
    let (balance, mut raw) = current_amount(context, address).await?;
    row.amount_raw = raw.to_string();
    if raw == U256::ZERO { row.reason = Some("zero".into()); return Ok(false); }
    let config = discovery::config(&context.input.chain_id)?;
    let from = token::read_metadata(&context.provider, address, config.id).await?;
    if let Some(cap) = context.input.request_caps.get(&address.to_string()) {
        raw = raw.min(crate::service::batch_sell::raw_cap(cap, from.decimals)?);
        row.amount_raw = raw.to_string();
    }
    let input_price = context.prices.get(&address);
    let output_price = context.prices.get(&receive);
    row.value_usd = input_price.map(|p| amount::valuation(raw, p.value, from.decimals, None).0);
    if let Some(reason) = math::price_skip(balance, from.decimals, input_price, output_price, context.max) {
        row.reason = Some(reason.into()); return Ok(false);
    }
    let prices = (input_price.ok_or_else(|| eyre::eyre!("unpriced"))?, output_price.ok_or_else(|| eyre::eyre!("unpriced"))?);
    let floor = math::floor(raw, prices.0, prices.1, from.decimals, context.receive.decimals, context.input.max_loss_bps)?;
    row.floor_raw = Some(floor.to_string());
    if floor == U256::ZERO { row.reason = Some("below_floor".into()); return Ok(false); }
    if context.input.via == Via::Intent {
        intent(context, budget, row, raw, prices, (from.decimals, floor)).await?;
        return Ok(false);
    }
    market(context, budget, row, raw, floor).await
}

/// The start premium moves only the curve's opening price; the fill-cost check keeps the owner's
/// discount budget, market value minus floor.
async fn intent(context: &Context<'_>, budget: &TokenPolicy, row: &mut Row, raw: U256,
    (input, output): (&Price, &Price), (decimals, floor): (u8, U256)) -> Result<()> {
    let market = math::floor(raw, input, output, decimals, context.receive.decimals, 0)?;
    let start = math::start(raw, input, output, decimals, context.receive.decimals, context.input.start_premium_bps)?;
    row.start_out_raw = Some(start.to_string());
    if super::gas_floor::skip(context, row, market - floor).await? {
        row.reason = Some("below_gas_floor".into()); return Ok(());
    }
    intent_sale::place(context, budget, row, raw, start, floor).await
}

async fn market(context: &Context<'_>, budget: &TokenPolicy, row: &mut Row, raw: U256, floor: U256) -> Result<bool> {
    let request = quote::QuoteInput { chain_id: context.input.chain_id.clone(), from: budget.token.clone(), to: context.receive.address.clone(), amount: raw.to_string(), slippage: Some(context.input.max_loss_bps), verify: false, taker: Some(context.input.proxy.clone()) };
    let checked = match quote::quote(context.client, request).await {
        Ok(checked) => checked,
        Err(error) => {
            if crate::client::is_no_route(&error) {
                row.reason = Some("no_route".into()); return Ok(false);
            }
            row.reason = Some("quote_failed".into()); return Err(error);
        }
    };
    let quoted = checked.response["output"].as_str().and_then(|s| order_types::parse_raw_amount("quote output", s).ok());
    row.quote_out_raw = quoted.map(|q| q.to_string());
    if let Some(reason) = math::quote_skip(quoted, floor) { row.reason = Some(reason.into()); return Ok(false); }
    let body = serde_json::json!({"chain_id":checked.request.chain_id, "token_in":checked.request.token_in,
        "token_out":checked.request.token_out, "amount_in":checked.request.amount_in,
        "slippage_bps":context.input.max_loss_bps, "taker":context.input.proxy});
    let client = context.client.clone().with_pinned_quote(body, checked.response);
    let input = trade_input(context, budget, raw, floor);
    Ok(row.record(trade::execute_trade(&client, context.signer.clone(), input, !context.input.dry_run, context.wait).await))
}

async fn current_amount(context: &Context<'_>, token: Address) -> Result<(U256, U256)> {
    let proxy = order_types::parse_address(&context.input.proxy)?;
    let contract = UserProxyV6::new(proxy, context.provider.clone());
    let policy = contract.policyOf(context.signer.address()).call().await?;
    let now = now(&context.provider).await?;
    eyre::ensure!(policy.expiry > now, "agent policy is inactive");
    context.input.via.require_action(policy.actionMask)?;
    eyre::ensure!(policy.generation.to_string() == context.policy.generation, "agent policy changed during batch sale");
    let info = contract.agentTokenInfo(context.signer.address(), token).call().await?;
    if !info.allowed { return Ok((U256::ZERO, U256::ZERO)); }
    let remaining = math::remaining(info.cap, info.used, info.epochStart, u64::from(policy.epochLen), now);
    let balance = discovery::balance(&context.provider, token, context.owner).await?;
    let allowance = AllowanceReader::new(token, context.provider.clone()).allowance(context.owner, proxy).call().await?;
    Ok((balance, math::spendable(balance, remaining, allowance)))
}

pub(super) fn trade_input(context: &Context<'_>, budget: &TokenPolicy, raw: U256, floor: U256) -> trade::TradeInput {
    trade::TradeInput { chain_id: context.input.chain_id.clone(), from: budget.token.clone(), to: context.receive.address.clone(),
        amount: raw.to_string(), min_out: Some(floor.to_string()), max_amount: Some(context.server_cap.map_or(raw, |cap| cap.min(raw)).to_string()),
        slippage: Some(context.input.max_loss_bps), mode: "agent-order".into(), proxy: context.input.proxy.clone(), nonce: None, deadline_secs: None,
        dry_run: context.input.dry_run, self_submit: context.input.self_submit, verify_quote: false }
}