agentswap 0.12.0

AgentSwap CLI for requesting quotes, inspecting tokens and pools, managing intents, and registering an API key against the AgentSwap service.
// Intent submission selection: sponsored relay, off-chain broadcast, or self-paid announce.
// Exports: validate and announce to the intent service; uses a credential-free broadcast client.
// Deps: parent intent models/ABI, reqwest, alloy ABI encoding.

use super::*;
use alloy::sol_types::SolType;

mod response;

const BROADCAST_URL: &str = "https://broadcast.intentscan.net/v1/stream/publish";
const BROADCAST_ORIGIN: &str = "https://broadcast.intentscan.net";

pub(super) fn validate(config: ChainConfig, input: &PlaceInput, allow_trade: bool) -> Result<()> {
    let modes = u8::from(input.relay) + u8::from(input.self_submit) + u8::from(input.broadcast);
    if modes > 1 || (allow_trade && !input.dry_run && modes != 1) {
        return Err(eyre!("choose exactly one of --relay, --self-submit or --broadcast"));
    }
    if config.id == 1 && input.relay {
        return Err(eyre!("Ethereum has no sponsored relay; use --self-submit or --broadcast"));
    }
    Ok(())
}

pub(super) async fn announce(
    announcer: Announcer<'_>, input: &PlaceInput, signer: Arc<dyn Signer>,
    config: ChainConfig, order: &Order, envelope: &Bytes, outcome: &mut PlaceOutcome,
) -> Result<()> {
    if input.broadcast {
        outcome.broadcast = Some(publish(BROADCAST_URL, &broadcast_body(config, order, envelope)).await?);
    } else if input.relay {
        let body = announce_body(config, order, envelope);
        outcome.relay = Some(match announcer.pacer {
            Some(pacer) => pacer.publish(announcer.relay, &body).await?,
            None => announcer.relay.announce_intent(&body).await?,
        });
    } else {
        let call = IntentSettlerV3::announceCall { o: order.clone(), auth: envelope.clone() };
        let result = submit::send(&evm::rpc_url(config), signer, config.settler,
            call.abi_encode().into(), announcer.wait).await?;
        outcome.record(result);
    }
    Ok(())
}

fn broadcast_body(config: ChainConfig, order: &Order, envelope: &Bytes) -> serde_json::Value {
    serde_json::json!({
        "chainId": config.id, "standard": "agentswap-v6", "settler": config.settler,
        "order": hex_bytes(&<Order as SolType>::abi_encode(order)), "auth": hex_bytes(envelope),
    })
}

async fn publish(url: &str, body: &serde_json::Value) -> Result<serde_json::Value> {
    let identity = publication_identity(url, body);
    if let Some(identity) = &identity { eprintln!("publishing {identity}"); }
    publish_request(url, body).await.map_err(|error| {
        let recovery = identity.unwrap_or_else(|| "broadcast stream".into());
        eyre!("{}; {recovery}; admission is unknown; re-running after an error signs a new intent and may duplicate a still-active intent; a missing history record does not prove rejection; intent status and intent list --owner read the intent index first; intent list --agent reads only IntentAnnounced logs", crate::redact::urls(&error.to_string()))
    })
}

fn publication_identity(url: &str, body: &serde_json::Value) -> Option<String> {
    let encoded = hex::decode(body["order"].as_str()?.strip_prefix("0x")?).ok()?;
    let order = <Order as SolType>::abi_decode(&encoded).ok()?;
    let chain = body["chainId"].as_u64()?;
    let settler: Address = body["settler"].as_str()?.parse().ok()?;
    let stream = if url == BROADCAST_URL { BROADCAST_ORIGIN.to_string() } else { crate::redact::urls(url) };
    let lookup = if url == BROADCAST_URL {
        format!("; lookup: GET /v1/intents with owner={:?} and chain_ids={chain}; match order_hash to this intent id and verify standard/settler; paginate using next_before_ms as before_ms until null", order.owner)
    } else { String::new() };
    Some(format!("intent {:?}; chain {chain}; standard agentswap-v6; settler {settler:?}; stream {stream}{lookup}",
        order_types::order_id(&order)))
}

async fn publish_request(url: &str, body: &serde_json::Value) -> Result<serde_json::Value> {
    let response = reqwest::Client::builder()
        .redirect(reqwest::redirect::Policy::none())
        .timeout(std::time::Duration::from_secs(20)).build()?
        .post(url).json(body).send().await?;
    let status = response.status();
    if !status.is_success() {
        return Err(eyre!("broadcast returned HTTP {status}: {}", response::diagnostic(response).await));
    }
    let bytes = response.bytes().await?;
    if bytes.is_empty() { return Ok(serde_json::json!({"accepted": true})); }
    Ok(serde_json::from_slice(&bytes)?)
}

#[cfg(test)]
mod tests;