use std::time::Duration;
use alloy::{
consensus::TypedTransaction,
network::Ethereum,
primitives::Address,
providers::{ProviderBuilder, RootProvider},
};
use fynd_client::{
ErrorCode, FyndClient, FyndError, Order, OrderSide, QuoteOptions, QuoteParams, RetryConfig,
SigningHints, SwapPayload,
};
use num_bigint::BigUint;
use wiremock::{
matchers::{method, path},
Mock, MockServer, ResponseTemplate,
};
fn make_client(
base_url: String,
retry: RetryConfig,
default_sender: Option<Address>,
) -> (FyndClient<RootProvider<Ethereum>>, alloy::providers::mock::Asserter) {
use alloy::providers::mock::Asserter;
let asserter = Asserter::new();
let provider = ProviderBuilder::default().connect_mocked_client(asserter.clone());
let submit_provider = ProviderBuilder::default().connect_mocked_client(asserter.clone());
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()
.expect("reqwest client");
let client = FyndClient::new_with_providers(
http,
base_url,
retry,
1, default_sender,
provider,
submit_provider,
);
(client, asserter)
}
fn make_quote_params() -> QuoteParams {
let token_in = bytes::Bytes::copy_from_slice(&[0xaa; 20]);
let token_out = bytes::Bytes::copy_from_slice(&[0xbb; 20]);
let sender = bytes::Bytes::copy_from_slice(&[0xcc; 20]);
let order =
Order::new(token_in, token_out, BigUint::from(1_000_000u64), OrderSide::Sell, sender, None);
QuoteParams::new(order, QuoteOptions::default())
}
fn minimal_quote_json(order_id: &str) -> serde_json::Value {
serde_json::json!({
"orders": [{
"order_id": order_id,
"status": "success",
"amount_in": "1000000",
"amount_out": "990000",
"gas_estimate": "50000",
"amount_out_net_gas": "940000",
"price_impact_bps": 10,
"block": {
"number": 1234567,
"hash": "0xabcdef",
"timestamp": 1700000000
},
"transaction": {
"to": "0x0101010101010101010101010101010101010101",
"value": "0",
"data": "0x1234"
}
}],
"total_gas_estimate": "50000",
"solve_time_ms": 42
})
}
#[tokio::test]
async fn full_quote_roundtrip() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/quote"))
.respond_with(ResponseTemplate::new(200).set_body_json(minimal_quote_json("order-1")))
.expect(1)
.mount(&server)
.await;
let (client, _asserter) = make_client(server.uri(), RetryConfig::default(), None);
let quote = client
.quote(make_quote_params())
.await
.expect("quote should succeed");
assert_eq!(quote.order_id(), "order-1");
assert_eq!(quote.amount_out(), &BigUint::from(990_000u64));
assert_eq!(quote.amount_in(), &BigUint::from(1_000_000u64));
assert_eq!(quote.gas_estimate(), &BigUint::from(50_000u64));
assert_eq!(quote.price_impact_bps(), Some(10));
assert_eq!(quote.token_out(), &bytes::Bytes::copy_from_slice(&[0xbb; 20]));
assert_eq!(quote.receiver(), &bytes::Bytes::copy_from_slice(&[0xcc; 20]));
server.verify().await;
}
#[tokio::test]
async fn health_roundtrip() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/health"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"healthy": true,
"last_update_ms": 250,
"num_solver_pools": 3
})))
.expect(1)
.mount(&server)
.await;
let (client, _asserter) = make_client(server.uri(), RetryConfig::default(), None);
let health = client
.health()
.await
.expect("health should succeed");
assert!(health.healthy());
assert_eq!(health.last_update_ms(), 250);
assert_eq!(health.num_solver_pools(), 3);
server.verify().await;
}
#[tokio::test]
async fn quote_retries_once_then_succeeds() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/quote"))
.respond_with(ResponseTemplate::new(503).set_body_json(serde_json::json!({
"error": "service overloaded",
"code": "SERVICE_OVERLOADED"
})))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/v1/quote"))
.respond_with(ResponseTemplate::new(200).set_body_json(minimal_quote_json("retry-ok")))
.up_to_n_times(1)
.mount(&server)
.await;
let retry = RetryConfig::new(3, Duration::from_millis(1), Duration::from_millis(5));
let (client, _asserter) = make_client(server.uri(), retry, None);
let quote = client
.quote(make_quote_params())
.await
.expect("quote should succeed after one retry");
assert_eq!(quote.order_id(), "retry-ok");
}
#[tokio::test]
async fn quote_with_multi_hop_route_deserializes_all_swaps() {
let server = MockServer::start().await;
let body = serde_json::json!({
"orders": [{
"order_id": "multihop-1",
"status": "success",
"route": {
"swaps": [
{
"component_id": "0xpool1111111111111111111111111111111111111111",
"protocol": "uniswap_v3",
"token_in": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"token_out": "0xcccccccccccccccccccccccccccccccccccccccc",
"amount_in": "1000000",
"amount_out": "500000",
"gas_estimate": "30000",
"split": "0.0"
},
{
"component_id": "0xpool2222222222222222222222222222222222222222",
"protocol": "uniswap_v2",
"token_in": "0xcccccccccccccccccccccccccccccccccccccccc",
"token_out": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"amount_in": "500000",
"amount_out": "990000",
"gas_estimate": "20000",
"split": "0.0"
}
]
},
"amount_in": "1000000",
"amount_out": "990000",
"gas_estimate": "50000",
"amount_out_net_gas": "940000",
"price_impact_bps": 15,
"block": {
"number": 9999999,
"hash": "0x1234",
"timestamp": 1700001000
}
}],
"total_gas_estimate": "50000",
"solve_time_ms": 15
});
Mock::given(method("POST"))
.and(path("/v1/quote"))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.expect(1)
.mount(&server)
.await;
let (client, _asserter) = make_client(server.uri(), RetryConfig::default(), None);
let quote = client
.quote(make_quote_params())
.await
.expect("multi-hop quote should succeed");
let route = quote
.route()
.expect("route should be present");
assert_eq!(route.swaps().len(), 2);
let first = &route.swaps()[0];
assert_eq!(first.component_id(), "0xpool1111111111111111111111111111111111111111");
assert_eq!(first.protocol(), "uniswap_v3");
assert_eq!(first.amount_in(), &BigUint::from(1_000_000u64));
assert_eq!(first.amount_out(), &BigUint::from(500_000u64));
let second = &route.swaps()[1];
assert_eq!(second.component_id(), "0xpool2222222222222222222222222222222222222222");
assert_eq!(second.protocol(), "uniswap_v2");
assert_eq!(second.amount_in(), &BigUint::from(500_000u64));
assert_eq!(second.amount_out(), &BigUint::from(990_000u64));
server.verify().await;
}
#[tokio::test]
async fn quote_bad_request_not_retried() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/quote"))
.respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
"error": "no orders provided",
"code": "BAD_REQUEST"
})))
.expect(1) .mount(&server)
.await;
let (client, _asserter) = make_client(server.uri(), RetryConfig::default(), None);
let err = client
.quote(make_quote_params())
.await
.unwrap_err();
assert!(
matches!(err, FyndError::Api { code: ErrorCode::BadRequest, .. }),
"expected BadRequest, got {err:?}"
);
server.verify().await;
}
#[tokio::test]
async fn quote_populates_token_out_and_receiver_from_order() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/quote"))
.respond_with(
ResponseTemplate::new(200).set_body_json(minimal_quote_json("populated-order")),
)
.expect(1)
.mount(&server)
.await;
let token_in = bytes::Bytes::copy_from_slice(&[0x11; 20]);
let token_out = bytes::Bytes::copy_from_slice(&[0x22; 20]);
let sender = bytes::Bytes::copy_from_slice(&[0x33; 20]);
let receiver = bytes::Bytes::copy_from_slice(&[0x44; 20]);
let order = Order::new(
token_in,
token_out.clone(),
BigUint::from(1_000u64),
OrderSide::Sell,
sender,
Some(receiver.clone()),
);
let params = QuoteParams::new(order, QuoteOptions::default());
let (client, _asserter) = make_client(server.uri(), RetryConfig::default(), None);
let quote = client
.quote(params)
.await
.expect("quote should succeed");
assert_eq!(quote.token_out(), &token_out);
assert_eq!(quote.receiver(), &receiver);
server.verify().await;
}
#[tokio::test]
async fn quote_receiver_defaults_to_sender_when_none() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/quote"))
.respond_with(ResponseTemplate::new(200).set_body_json(minimal_quote_json("recv-default")))
.expect(1)
.mount(&server)
.await;
let sender = bytes::Bytes::copy_from_slice(&[0x77; 20]);
let order = Order::new(
bytes::Bytes::copy_from_slice(&[0xaa; 20]),
bytes::Bytes::copy_from_slice(&[0xbb; 20]),
BigUint::from(1u64),
OrderSide::Sell,
sender.clone(),
None, );
let params = QuoteParams::new(order, QuoteOptions::default());
let (client, _asserter) = make_client(server.uri(), RetryConfig::default(), None);
let quote = client
.quote(params)
.await
.expect("quote should succeed");
assert_eq!(quote.receiver(), &sender, "receiver should default to sender");
server.verify().await;
}
#[tokio::test]
async fn full_quote_then_swap_payload_with_all_hints() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/quote"))
.respond_with(ResponseTemplate::new(200).set_body_json(minimal_quote_json("flow-order")))
.expect(1)
.mount(&server)
.await;
let sender = Address::with_last_byte(0xab);
let (client, _asserter) = make_client(server.uri(), RetryConfig::default(), None);
let quote = client
.quote(make_quote_params())
.await
.expect("quote should succeed");
let hints = SigningHints::default()
.with_sender(sender)
.with_nonce(7)
.with_max_fee_per_gas(3_000_000_000)
.with_max_priority_fee_per_gas(1_000_000)
.with_gas_limit(80_000);
let payload = client
.swap_payload(quote, &hints)
.await
.expect("swap_payload should succeed");
let SwapPayload::Fynd(fynd) = payload else {
panic!("expected Fynd payload");
};
let TypedTransaction::Eip1559(tx) = fynd.tx() else {
panic!("expected EIP-1559 tx");
};
assert_eq!(tx.nonce, 7);
assert_eq!(tx.max_fee_per_gas, 3_000_000_000);
assert_eq!(tx.max_priority_fee_per_gas, 1_000_000);
assert_eq!(tx.gas_limit, 80_000);
assert_eq!(tx.chain_id, 1);
server.verify().await;
}
#[tokio::test]
async fn full_quote_then_swap_payload_resolves_from_provider() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/quote"))
.respond_with(ResponseTemplate::new(200).set_body_json(minimal_quote_json("provider-flow")))
.expect(1)
.mount(&server)
.await;
let sender = Address::with_last_byte(0xde);
let (client, asserter) = make_client(server.uri(), RetryConfig::default(), Some(sender));
let quote = client
.quote(make_quote_params())
.await
.expect("quote should succeed");
asserter.push_success(&99u64); let fee_history = serde_json::json!({
"oldestBlock": "0x1",
"baseFeePerGas": ["0x3b9aca00", "0x3b9aca00"],
"gasUsedRatio": [0.5],
"reward": [["0xf4240", "0x1e8480"]]
});
asserter.push_success(&fee_history); asserter.push_success(&180_000u64);
let hints = SigningHints::default();
let payload = client
.swap_payload(quote, &hints)
.await
.expect("swap_payload should succeed");
let SwapPayload::Fynd(fynd) = payload else {
panic!("expected Fynd payload");
};
let TypedTransaction::Eip1559(tx) = fynd.tx() else {
panic!("expected EIP-1559 tx");
};
assert_eq!(tx.nonce, 99, "nonce should come from mock provider");
server.verify().await;
}