agentswap 0.13.0

AgentSwap CLI for requesting quotes, inspecting tokens and pools, managing intents, and registering an API key against the AgentSwap service.
// Intent tests: raw-unit order construction, early input validation, and the relay request.
// Exports: no production symbols.
// Deps: parent intent service, chain config, and V6 order types.

use super::*;

pub(super) fn input(from: &str, amount: &str) -> PlaceInput {
    PlaceInput {
        chain_id: "base".to_string(),
        proxy_owner: format!("{:?}", Address::ZERO),
        from: from.to_string(),
        to: "USDC".to_string(),
        amount: amount.to_string(),
        start_out: "2".to_string(),
        end_out: "1".to_string(),
        decay_secs: Some(1),
        duration_secs: Some(1),
        deadline_secs: None,
        relay: false,
        self_submit: false,
        broadcast: false,
        dry_run: true,
        max_amount: None,
    }
}

#[tokio::test]
async fn intent_order_keeps_one_raw_unit_for_six_and_eighteen_decimal_tokens() {
    for token in ["USDC", "WETH"] {
        let order = build_order(&input(token, "1"), Address::ZERO, 8453)
            .await
            .expect("intent order");
        assert_eq!(order.amountIn, U256::from(1));
        assert_eq!(order.startAmountOut, U256::from(2));
        assert_eq!(order.endAmountOut, U256::from(1));
    }
}

#[test]
fn intent_amount_validation_rejects_invalid_forms_before_rpc() {
    // `1_000` is the form the previous parser accepted silently: U256::from_str_radix skips
    // underscores, so a cap of `1_0` used to mean ten. Every listed form must fail here.
    for value in ["", " ", "1.5", "1e6", "raw:1", "-1", "0x1", "1_000"] {
        let error = validate_input_amounts(&input("USDC", value))
            .expect_err("invalid amount");
        assert!(format!("{error}").contains("intent amount"), "{value}: {error}");
    }
}

#[test]
fn relay_request_contains_chain_intent_generation() {
    let config = evm::chain_config("base").expect("base chain config");
    let order = Order {
        owner: Address::ZERO,
        recipient: Address::ZERO,
        tokenIn: Address::ZERO,
        amountIn: U256::from(1),
        tokenOut: Address::ZERO,
        startAmountOut: U256::from(2),
        endAmountOut: U256::from(1),
        startTime: U256::from(3),
        decayEndTime: U256::from(4),
        endTime: U256::from(5),
        appData: B256::ZERO,
        nonce: U256::from(6),
    };
    let body = announce_body(config, &order, &Bytes::new());
    let serialized = serde_json::to_string(&body).expect("serialize relay request");

    assert_eq!(body["chainId"], config.id);
    assert_eq!(body["generation"], config.generation);
    assert!(serialized.contains("\"generation\":\"v6\""));
    assert_eq!(body["announce"]["auth"], "0x");
}

#[test]
fn default_deadline_matches_order_end_time() {
    let order_end_time = 1_700_000_600u64;
    let now = 1_700_000_000u64;
    let deadline = resolve_deadline(order_end_time, None, now).expect("default deadline");
    assert_eq!(deadline, order_end_time);
}

#[test]
fn caller_supplied_deadline_earlier_than_end_time_is_refused() {
    let order_end_time = 1_700_000_600u64;
    let now = 1_700_000_000u64;
    let deadline_secs = 120u64;
    let err = resolve_deadline(order_end_time, Some(deadline_secs), now)
        .expect_err("should reject earlier deadline");
    let msg = err.to_string();
    assert!(msg.contains("1700000120"), "expected deadline 1700000120 in error: {msg}");
    assert!(msg.contains("1700000600"), "expected order endTime 1700000600 in error: {msg}");
}

#[test]
fn caller_supplied_deadline_after_end_time_is_accepted() {
    let order_end_time = 1_700_000_600u64;
    let now = 1_700_000_000u64;
    let deadline_secs = 700u64;
    let deadline = resolve_deadline(order_end_time, Some(deadline_secs), now)
        .expect("valid deadline");
    assert_eq!(deadline, 1_700_000_700);
}

#[test]
fn a_recorded_announce_keeps_its_hash_and_only_a_confirmed_one_succeeds() {
    let order = build_order_for_record();
    let auth = IntentAuthorization {
        orderHash: order_types::order_id(&order), agent: Address::ZERO, generation: 1,
        nonce: U256::from(1), deadline: 5,
    };
    let mut outcome = PlaceOutcome {
        dry_run: false, chain_id: 8453, order: order_types::dto_from_order(&order), id: String::new(),
        authorization: Some(order_types::dto_from_authorization(&auth)), envelope: None, digest: String::new(),
        signature: None, relay: None, broadcast: None, tx_hash: None, tx_status: None, tx_error: None,
        tx_explorer_url: None,
    };
    assert!(outcome.not_confirmed().is_none());
    let hash = B256::repeat_byte(9);
    outcome.record(Submission { hash, status: submit::TxStatus::Unknown, error: Some("no receipt before the wait ended".into()) });
    let value = serde_json::to_value(&outcome).unwrap();
    assert_eq!(value["tx_hash"], format!("{hash:?}"));
    assert_eq!(value["tx_status"], "unknown");
    assert_eq!(value["tx_error"], "no receipt before the wait ended");
    assert!(value.get("tx_explorer_url").is_none(), "no explorer is known for chain 8453");
    assert_eq!(outcome.not_confirmed().expect("not confirmed").exit_code(), submit::EXIT_UNKNOWN);
    outcome.record(Submission { hash, status: submit::TxStatus::Confirmed, error: None });
    assert!(outcome.not_confirmed().is_none());
    assert!(serde_json::to_value(&outcome).unwrap().get("tx_error").is_none());
}

pub(super) fn build_order_for_record() -> Order {
    Order {
        owner: Address::ZERO, recipient: Address::ZERO, tokenIn: Address::ZERO, amountIn: U256::from(1),
        tokenOut: Address::ZERO, startAmountOut: U256::from(2), endAmountOut: U256::from(1),
        startTime: U256::from(3), decayEndTime: U256::from(4), endTime: U256::from(5),
        appData: B256::ZERO, nonce: U256::from(6),
    }
}

#[test]
fn only_relay_answers_that_cannot_rule_out_placement_are_unknown() {
    let order = build_order_for_record();
    let outcome = PlaceOutcome {
        dry_run: false, chain_id: 8453, order: order_types::dto_from_order(&order), id: "0x01".into(), authorization: None,
        envelope: None, digest: String::new(), signature: None, relay: None, broadcast: None, tx_hash: None, tx_status: None,
        tx_error: None, tx_explorer_url: None,
    };
    let unknown = |text: &str, relay: bool| PublishUnknown::classify(eyre!("{text}"), relay, &outcome).downcast_ref::<PublishUnknown>().is_some();
    for text in [r#"HTTP 504 Gateway Timeout: {"error":"stream_publish_unknown"}"#, r#"HTTP 502 Bad Gateway: {"error":"broadcast failed: nonce too low"}"#] {
        assert!(unknown(text, true), "{text}");
        assert!(!unknown(text, false), "only a relay answer: {text}");
    }
    for text in [r#"HTTP 503 Service Unavailable: {"error":"relay RPC unavailable on this chain"}"#,
        r#"HTTP 503 Service Unavailable: {"error":"relay broadcast is busy on this chain; retry shortly"}"#,
        r#"HTTP 503 Service Unavailable: {"error":"relay is not funded on this chain"}"#,
        r#"HTTP 503 Service Unavailable: {"error":"relay fee ceiling exceeded on this chain right now"}"#,
        r#"HTTP 503 Service Unavailable: {"error":"relay admission guard is unavailable"}"#,
        r#"HTTP 500 Internal Server Error: {"error":"internal error"}"#, r#"HTTP 400 Bad Request: {"error":"simulation reverted: x"}"#] {
        assert!(!unknown(text, true), "a failure: {text}");
    }
}