use crate::client::Client;
use crate::evm;
use crate::order_types::{self, AgentOrderDto, UserProxyV6};
use crate::service::quote::{self, QuoteInput, QuoteOutput};
use crate::service::submit::{NotConfirmed, Wait};
use crate::signer::Signer;
use crate::tokens::CHAIN_ID_HELP;
use alloy::primitives::{Address, U256};
use eyre::{eyre, Result};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
mod self_submit;
pub use self_submit::SelfSubmitPreview;
#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
pub struct TradeInput {
#[schemars(description = CHAIN_ID_HELP)]
pub chain_id: String,
pub from: String,
pub to: String,
pub amount: String,
pub slippage: Option<u16>,
pub min_out: Option<String>,
#[serde(default)]
pub max_amount: Option<String>,
#[serde(default = "agent_order_mode")]
pub mode: String,
pub proxy: String,
pub nonce: Option<String>,
pub deadline_secs: Option<u64>,
#[serde(default = "default_true")]
pub dry_run: bool,
#[serde(default)]
pub self_submit: bool,
#[serde(skip, default = "default_true")]
#[schemars(skip)]
pub(crate) verify_quote: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
pub struct TradeOutcome {
pub dry_run: bool,
pub mode: String,
pub quote: serde_json::Value,
pub order: AgentOrderDto,
pub digest: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub signature: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub self_submit: Option<SelfSubmitPreview>,
}
impl TradeOutcome {
pub fn not_confirmed(&self) -> Option<NotConfirmed> {
self.self_submit.as_ref().and_then(SelfSubmitPreview::not_confirmed)
}
}
pub async fn execute_trade(
client: &Client,
signer: Arc<dyn Signer>,
input: TradeInput,
allow_trade: bool,
wait: Wait,
) -> Result<TradeOutcome> {
let mut input = input;
validate_input_amounts(&input)?;
if input.mode != "agent-order" {
return Err(eyre!("only agent-order mode is implemented in this release"));
}
input.dry_run |= !allow_trade;
let quote_out = quote::quote(client, quote_input(&input)).await?;
let config = evm::chain_config(&input.chain_id)?;
let provider = evm::read_provider(&evm::rpc_url(config))?;
let proxy_address = order_types::parse_address(&input.proxy)?;
let proxy = UserProxyV6::new(proxy_address, provider.clone());
let policy = proxy.policyOf(signer.address()).call().await?;
let generation = policy.generation;
let order = build_order(&input, signer.address(), generation, "e_out)?;
enforce_notional_cap(&order, input.max_amount.as_deref())?;
let digest = order_types::signing_hash(
&order,
&order_types::proxy_domain(quote_out.request.chain_id, proxy_address),
);
let chain_digest = proxy.hashAgentOrder(order.clone()).call().await?;
if digest != chain_digest {
return Err(eyre!("local AgentOrder digest does not match proxy.hashAgentOrder"));
}
let (signature, self_submit) = if input.dry_run {
(None, None)
} else {
let (signature, preview) =
self_submit::sign_and_submit(signer, &input, &order, digest, "e_out, wait).await?;
(Some(signature), Some(preview))
};
Ok(TradeOutcome {
dry_run: input.dry_run, mode: input.mode, quote: quote_out.response,
order: order_types::dto_from_agent_order(&order), digest: format!("{digest:?}"),
signature, self_submit,
})
}
fn quote_input(input: &TradeInput) -> QuoteInput {
QuoteInput {
chain_id: input.chain_id.clone(),
from: input.from.clone(),
to: input.to.clone(),
amount: input.amount.clone(),
slippage: input.slippage,
verify: input.verify_quote,
taker: Some(input.proxy.clone()),
}
}
fn build_order(input: &TradeInput, agent: Address, generation: u64, quote: &QuoteOutput) -> Result<UserProxyV6::AgentOrder> {
let router = field("e.response, &["execution", "target"])
.or_else(|| field("e.response, &["router"]))
.ok_or_else(|| eyre!("quote response missing execution target/router"))?;
let min_out = match &input.min_out {
Some(value) => order_types::parse_raw_amount("trade min-out", value)?,
None => {
if !input.dry_run {
return Err(eyre!(
"refusing to trade without an explicit --min-out floor; the quote server's output cannot be trusted as the protection floor"
));
}
slippage_min_out("e.response, input.slippage.unwrap_or(50))?
}
};
Ok(UserProxyV6::AgentOrder {
agent,
generation,
router: order_types::parse_address(router)?,
tokenIn: order_types::parse_address("e.request.token_in)?,
amountIn: order_types::parse_raw_amount("quoted trade amount", "e.request.amount_in)?,
tokenOut: order_types::parse_address("e.request.token_out)?,
minOut: min_out,
nonce: next_nonce(input.nonce.as_deref())?,
deadline: deadline(input.deadline_secs.unwrap_or(120))?,
})
}
fn slippage_min_out(response: &serde_json::Value, bps: u16) -> Result<U256> {
if bps > 10_000 {
return Err(eyre!("slippage {bps} bps exceeds 100% (max 10000)"));
}
let output = response["output"]
.as_str()
.ok_or_else(|| eyre!("quote response missing output"))?;
let quoted = order_types::parse_u256(output)?;
Ok((quoted * U256::from(10_000u64 - u64::from(bps))) / U256::from(10_000u64))
}
fn enforce_notional_cap(order: &UserProxyV6::AgentOrder, cap: Option<&str>) -> Result<()> {
let Some(cap) = cap else {
return Ok(());
};
let cap = order_types::parse_raw_amount("trade max-amount", cap)?;
if order.amountIn > cap {
return Err(eyre!(
"order amountIn {} exceeds trade max-amount cap {}; refusing to sign/self-submit",
order.amountIn,
cap
));
}
Ok(())
}
fn next_nonce(explicit: Option<&str>) -> Result<U256> {
if let Some(value) = explicit {
return order_types::parse_u256(value);
}
let mut bytes = [0u8; 8];
getrandom::fill(&mut bytes).map_err(|e| eyre!("failed to generate nonce: {e}"))?;
Ok(U256::from(u64::from_be_bytes(bytes)))
}
fn validate_input_amounts(input: &TradeInput) -> Result<()> {
order_types::parse_raw_amount("trade amount", &input.amount)?;
if let Some(min_out) = &input.min_out {
order_types::parse_raw_amount("trade min-out", min_out)?;
}
if let Some(max_amount) = &input.max_amount {
order_types::parse_raw_amount("trade max-amount", max_amount)?;
}
Ok(())
}
fn deadline(secs: u64) -> Result<U256> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|e| eyre!("clock before unix epoch: {e}"))?
.as_secs();
Ok(U256::from(now + secs))
}
fn field<'a>(value: &'a serde_json::Value, path: &[&str]) -> Option<&'a str> {
let mut current = value;
for key in path {
current = current.get(*key)?;
}
current.as_str()
}
fn agent_order_mode() -> String {
"agent-order".to_string()
}
fn default_true() -> bool {
true
}
#[cfg(test)]
mod tests;