diff --git a/clients/CLAUDE.md b/clients/CLAUDE.md index acc858b7e..bfc3203fa 100644 --- a/clients/CLAUDE.md +++ b/clients/CLAUDE.md @@ -45,6 +45,21 @@ FyndClientBuilder::new(fynd_url) FyndClientBuilder::new(fynd_url).build_quote_only()? ``` +Against the hosted gateway (`fynd-api.propellerheads.xyz`), add auth and per-chain routing: + +```rust +FyndClientBuilder::new("https://fynd-api.propellerheads.xyz") + .with_api_key(tycho_api_key) // sent as the raw `Authorization` header value (no `Bearer` prefix) + .with_chain("base") // routes to /v1/base/quote instead of /v1/quote + .build_quote_only()? +``` + +Both are opt-in; omitting them keeps the unauthenticated, unprefixed paths that self-hosted +Fynd serves. `with_chain` accepts `ethereum`, `base`, `arbitrum`, `bsc`, `polygon`, `unichain` +and rejects anything else at build time. It also determines the `chain_id` used for signing +when building via `build_quote_only` (no RPC node to ask); `build` cross-checks the slug +against the chain the RPC node reports and errors on a mismatch. + **`FyndClient`** - `quote(params: QuoteParams) -> Result` — request a swap quote - `batch_quote(params: BatchQuoteParams) -> Result, FyndError>` — request multiple quotes in one call @@ -81,7 +96,14 @@ For full details, see [`.claude/knowledge/typescript.md`](../.claude/knowledge/t pnpm workspace at `clients/typescript/` with two packages: `client` and `examples/tutorial`. - **`@kayibal/fynd-client`** (`client/`) — Typed HTTP client: `FyndClient`, signing, Permit2, error types. - Contains `autogen.ts` and `schema.d.ts` (generated from `openapi-typescript`; do not edit manually). + Contains `schema.d.ts` (generated by `scripts/update-openapi.sh`; do not edit manually) and + `autogen.ts` (a hand-maintained wrapper over `openapi-fetch`). + +Hosted-gateway equivalents of the Rust builder options are `FyndClientOptions.apiKey` and +`.chain`, plus `.headers` for arbitrary headers. Chain routing is implemented as an +`openapi-fetch` middleware that rewrites `/v1/…` to `/v1/{chain}/…`, so call sites stay typed +against the generated schema literals. `createFyndClient` and the `Middleware` type are +re-exported from the package root for callers that want to add their own middleware. ### Build & Test diff --git a/clients/rust/src/client.rs b/clients/rust/src/client.rs index e2cc70ea1..c15451ca2 100644 --- a/clients/rust/src/client.rs +++ b/clients/rust/src/client.rs @@ -358,6 +358,52 @@ mod erc20 { } } +// ============================================================================ +// HOSTED GATEWAY CONFIG +// ============================================================================ + +/// Chain slugs accepted by the hosted Fynd gateway, paired with their EVM chain ID. +const SUPPORTED_CHAINS: [(&str, u64); 6] = [ + ("ethereum", 1), + ("base", 8453), + ("arbitrum", 42161), + ("bsc", 56), + ("polygon", 137), + ("unichain", 130), +]; + +/// Resolve a chain slug to its EVM chain ID. +/// +/// Returns [`FyndError::Config`] listing the supported slugs if `chain` is not recognised. +fn chain_id_for_slug(chain: &str) -> Result { + for (slug, id) in SUPPORTED_CHAINS { + if slug == chain { + return Ok(id); + } + } + let supported: Vec<&str> = SUPPORTED_CHAINS + .iter() + .map(|(slug, _)| *slug) + .collect(); + Err(FyndError::Config(format!( + "unsupported chain '{chain}'; expected one of: {}", + supported.join(", ") + ))) +} + +/// Settings for talking to the hosted Fynd gateway at `fynd-api.propellerheads.xyz`. +/// +/// Both fields are opt-in. Leaving them unset keeps the legacy self-hosted behaviour: +/// unauthenticated requests against `{base_url}/v1/…`. +#[derive(Clone, Default)] +pub struct HostedConfig { + /// API key sent as the raw `Authorization` header value (no `Bearer ` prefix) on every Fynd + /// API request. + pub api_key: Option, + /// Chain slug that scopes the request path to `{base_url}/v1/{chain}/…`. + pub chain: Option, +} + // ============================================================================ // CLIENT BUILDER // ============================================================================ @@ -376,6 +422,7 @@ pub struct FyndClientBuilder { rpc_url: Option, submit_url: Option, sender: Option
, + hosted: HostedConfig, } impl FyndClientBuilder { @@ -395,9 +442,31 @@ impl FyndClientBuilder { rpc_url: None, submit_url: None, sender: None, + hosted: HostedConfig::default(), } } + /// Authenticate against the hosted Fynd gateway. + /// + /// The key is sent as the raw `Authorization` header value (no `Bearer ` prefix) on every + /// Fynd API request, matching what the deployed gateway expects. The Tycho API key issued by + /// the keygen bot also authenticates Fynd. Not needed for self-hosted instances. + pub fn with_api_key(mut self, api_key: impl Into) -> Self { + self.hosted.api_key = Some(api_key.into()); + self + } + + /// Route requests through the hosted gateway's per-chain paths, e.g. `/v1/base/quote`. + /// + /// Accepts `ethereum`, `base`, `arbitrum`, `bsc`, `polygon`, or `unichain`; an unknown + /// slug fails at [`build`](Self::build) / [`build_quote_only`](Self::build_quote_only) + /// time. When unset, requests go to `{base_url}/v1/…` without a chain segment, which is + /// what self-hosted Fynd expects. + pub fn with_chain(mut self, chain: impl Into) -> Self { + self.hosted.chain = Some(chain.into()); + self + } + /// Set the Ethereum JSON-RPC endpoint for nonce/fee queries and receipt polling. /// /// Required before calling [`build`](Self::build). Not needed for @@ -439,7 +508,10 @@ impl FyndClientBuilder { /// [`FyndClient::swap_payload`] and [`FyndClient::execute_swap`] require a live RPC URL and /// will fail if called on a client built this way. /// - /// Returns [`FyndError::Config`] if `base_url` is invalid. + /// The chain ID used to sign transactions is derived from + /// [`with_chain`](Self::with_chain); without it, it defaults to Ethereum mainnet (1). + /// + /// Returns [`FyndError::Config`] if `base_url` is invalid or the chain slug is unknown. pub fn build_quote_only(self) -> Result { let parsed_base = self .base_url @@ -452,6 +524,14 @@ impl FyndClientBuilder { ))); } + // Without an RPC node to ask, the chain ID has to come from the configured chain slug. + // Signing a transaction with the wrong chain ID would make it invalid on the target + // chain, so this must not silently fall back to mainnet when a chain is configured. + let chain_id = match &self.hosted.chain { + Some(chain) => chain_id_for_slug(chain)?, + None => 1, + }; + // Use dummy providers pointing at the base URL. // These are never invoked for quote/health operations. let provider = ProviderBuilder::default().connect_http(parsed_base.clone()); @@ -466,10 +546,11 @@ impl FyndClientBuilder { http, base_url: self.base_url, retry: self.retry, - chain_id: 1, + chain_id, default_sender: self.sender, provider, submit_provider, + hosted: self.hosted, info_cache: tokio::sync::OnceCell::new(), }) } @@ -478,8 +559,17 @@ impl FyndClientBuilder { /// /// Requires [`with_rpc_url`](Self::with_rpc_url) to have been called. /// Validates the URLs and fetches the chain ID. Returns [`FyndError::Config`] if any URL is - /// invalid, `rpc_url` was not set, or the chain ID cannot be fetched. + /// invalid, `rpc_url` was not set, the chain ID cannot be fetched, or the chain set via + /// [`with_chain`](Self::with_chain) disagrees with the chain the RPC node is on. pub async fn build(self) -> Result { + // Reject an unknown chain slug before doing any network work. + let expected_chain_id = self + .hosted + .chain + .as_deref() + .map(chain_id_for_slug) + .transpose()?; + // Validate base_url scheme. let parsed_base = self .base_url @@ -516,6 +606,17 @@ impl FyndClientBuilder { .await .map_err(|e| FyndError::Config(format!("failed to fetch chain_id from RPC: {e}")))?; + // A gateway chain that disagrees with the RPC node means quotes and the transactions + // signed against them would target different chains. + if let Some(expected) = expected_chain_id { + if expected != chain_id { + return Err(FyndError::Config(format!( + "chain mismatch: with_chain() implies chain_id {expected}, but the RPC node \ + reports {chain_id}" + ))); + } + } + // Build HTTP client. let http = HttpClient::builder() .timeout(self.timeout) @@ -530,6 +631,7 @@ impl FyndClientBuilder { default_sender: self.sender, provider, submit_provider, + hosted: self.hosted, info_cache: tokio::sync::OnceCell::new(), }) } @@ -556,6 +658,7 @@ where default_sender: Option
, provider: P, submit_provider: P, + hosted: HostedConfig, info_cache: tokio::sync::OnceCell, } @@ -576,6 +679,7 @@ where default_sender: Option
, provider: P, submit_provider: P, + hosted: HostedConfig, ) -> Self { Self { http, @@ -585,10 +689,32 @@ where default_sender, provider, submit_provider, + hosted, info_cache: tokio::sync::OnceCell::new(), } } + /// Build the URL for a Fynd API endpoint, inserting the chain segment when configured. + fn endpoint(&self, path: &str) -> String { + match &self.hosted.chain { + Some(chain) => format!("{}/v1/{chain}/{path}", self.base_url), + None => format!("{}/v1/{path}", self.base_url), + } + } + + /// Attach the hosted-gateway API key when configured. + /// + /// The key is sent as the raw `Authorization` header value, with no `Bearer ` prefix: the + /// deployed gateway (`ph-nginx-auth`) matches the entire header value against its key store, + /// so a `Bearer ` prefix produces a 401 (verified against the live gateway: raw key → 200, + /// `Bearer ` → 401). + fn authorized(&self, request: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + match &self.hosted.api_key { + Some(api_key) => request.header(reqwest::header::AUTHORIZATION, api_key), + None => request, + } + } + /// Request a quote for one or more swap orders. /// /// The returned `Quote` has `token_out` and `receiver` populated on each @@ -628,10 +754,9 @@ where token_out: Bytes, receiver: Bytes, ) -> Result { - let url = format!("{}/v1/quote", self.base_url); + let url = self.endpoint("quote"); let response = self - .http - .post(&url) + .authorized(self.http.post(&url)) .json(dto_request) .send() .await?; @@ -679,10 +804,9 @@ where dto_request: &fynd_rpc_types::QuoteRequest, order_meta: Vec<(Bytes, Bytes)>, ) -> Result, FyndError> { - let url = format!("{}/v1/quote", self.base_url); + let url = self.endpoint("quote"); let response = self - .http - .post(&url) + .authorized(self.http.post(&url)) .json(dto_request) .send() .await?; @@ -696,8 +820,11 @@ where /// Get the health status of the Fynd RPC server. pub async fn health(&self) -> Result { - let url = format!("{}/v1/health", self.base_url); - let response = self.http.get(&url).send().await?; + let url = self.endpoint("health"); + let response = self + .authorized(self.http.get(&url)) + .send() + .await?; let status = response.status(); let body = response.text().await?; // The server returns HealthStatus JSON for both 200 and 503 (not-ready). @@ -954,8 +1081,11 @@ where } async fn fetch_info(&self) -> Result { - let url = format!("{}/v1/info", self.base_url); - let response = self.http.get(&url).send().await?; + let url = self.endpoint("info"); + let response = self + .authorized(self.http.get(&url)) + .send() + .await?; if !response.status().is_success() { let dto_err: fynd_rpc_types::ErrorResponse = response.json().await?; return Err(mapping::dto_error_to_fynd(dto_err)); @@ -1286,6 +1416,18 @@ mod tests { retry: RetryConfig, default_sender: Option
, ) -> (FyndClient>, alloy::providers::mock::Asserter) + { + make_hosted_test_client(base_url, retry, default_sender, HostedConfig::default()) + } + + /// Same as [`make_test_client`], but with an explicit [`HostedConfig`] so tests can exercise + /// the API key and per-chain routing paths. + fn make_hosted_test_client( + base_url: String, + retry: RetryConfig, + default_sender: Option
, + hosted: HostedConfig, + ) -> (FyndClient>, alloy::providers::mock::Asserter) { use alloy::providers::{mock::Asserter, ProviderBuilder}; @@ -1306,6 +1448,7 @@ mod tests { default_sender, provider, submit_provider, + hosted, ); (client, asserter) @@ -2246,4 +2389,269 @@ mod tests { num_bigint::BigUint::from(45_000u64) * num_bigint::BigUint::from(1_500_000_000u64); assert_eq!(mined.gas_cost(), &expected_cost); } + + // ======================================================================== + // Hosted gateway: API key + per-chain routing + // ======================================================================== + + fn hosted(api_key: Option<&str>, chain: Option<&str>) -> HostedConfig { + HostedConfig { api_key: api_key.map(str::to_owned), chain: chain.map(str::to_owned) } + } + + #[test] + fn chain_id_for_slug_resolves_supported_chains() { + assert_eq!(chain_id_for_slug("ethereum").unwrap(), 1); + assert_eq!(chain_id_for_slug("base").unwrap(), 8453); + assert_eq!(chain_id_for_slug("arbitrum").unwrap(), 42161); + assert_eq!(chain_id_for_slug("bsc").unwrap(), 56); + assert_eq!(chain_id_for_slug("polygon").unwrap(), 137); + assert_eq!(chain_id_for_slug("unichain").unwrap(), 130); + } + + #[test] + fn chain_id_for_slug_rejects_unknown_chain() { + let err = chain_id_for_slug("sepolia").unwrap_err(); + let FyndError::Config(msg) = err else { + panic!("expected Config error, got {err:?}"); + }; + assert!(msg.contains("sepolia"), "error should name the bad slug: {msg}"); + assert!(msg.contains("ethereum"), "error should list supported slugs: {msg}"); + } + + #[test] + fn build_quote_only_derives_chain_id_from_chain() { + let client = FyndClientBuilder::new("http://localhost:8080") + .with_chain("base") + .build_quote_only() + .expect("build_quote_only should succeed"); + assert_eq!(client.chain_id, 8453); + } + + #[test] + fn build_quote_only_defaults_to_mainnet_without_chain() { + let client = FyndClientBuilder::new("http://localhost:8080") + .build_quote_only() + .expect("build_quote_only should succeed"); + assert_eq!(client.chain_id, 1); + } + + #[test] + fn build_quote_only_rejects_unknown_chain() { + let result = FyndClientBuilder::new("http://localhost:8080") + .with_chain("not-a-chain") + .build_quote_only(); + let Err(err) = result else { + panic!("expected an unknown chain slug to be rejected"); + }; + assert!(matches!(err, FyndError::Config(_)), "expected Config error, got {err:?}"); + } + + #[test] + fn endpoint_inserts_chain_segment_only_when_configured() { + let legacy = FyndClientBuilder::new("http://localhost:8080") + .build_quote_only() + .expect("build"); + assert_eq!(legacy.endpoint("quote"), "http://localhost:8080/v1/quote"); + + let scoped = FyndClientBuilder::new("http://localhost:8080") + .with_chain("base") + .build_quote_only() + .expect("build"); + assert_eq!(scoped.endpoint("quote"), "http://localhost:8080/v1/base/quote"); + } + + #[tokio::test] + async fn quote_sends_api_key_to_chain_scoped_path() { + use wiremock::{ + matchers::{header, method, path}, + Mock, MockServer, ResponseTemplate, + }; + + let server = MockServer::start().await; + let body = serde_json::json!({ + "orders": [{ + "order_id": "hosted-1", + "status": "success", + "amount_in": "1000000", + "amount_out": "990000", + "gas_estimate": "50000", + "amount_out_net_gas": "940000", + "price_impact_bps": null, + "block": { "number": 1, "hash": "0xabc", "timestamp": 1 } + }], + "total_gas_estimate": "50000", + "solve_time_ms": 1 + }); + + Mock::given(method("POST")) + .and(path("/v1/base/quote")) + .and(header("authorization", "secret-key")) + .respond_with(ResponseTemplate::new(200).set_body_json(body)) + .expect(1) + .mount(&server) + .await; + + let (client, _asserter) = make_hosted_test_client( + server.uri(), + RetryConfig::default(), + None, + hosted(Some("secret-key"), Some("base")), + ); + + let quote = client + .quote(make_quote_params()) + .await + .expect("quote should succeed"); + assert_eq!(quote.order_id(), "hosted-1"); + } + + #[tokio::test] + async fn quote_omits_authorization_header_without_api_key() { + use wiremock::{ + matchers::{method, path}, + Mock, MockServer, ResponseTemplate, + }; + + let server = MockServer::start().await; + let body = serde_json::json!({ + "orders": [{ + "order_id": "legacy-1", + "status": "success", + "amount_in": "1000000", + "amount_out": "990000", + "gas_estimate": "50000", + "amount_out_net_gas": "940000", + "price_impact_bps": null, + "block": { "number": 1, "hash": "0xabc", "timestamp": 1 } + }], + "total_gas_estimate": "50000", + "solve_time_ms": 1 + }); + + // Legacy path: no chain segment. + 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_test_client(server.uri(), RetryConfig::default(), None); + client + .quote(make_quote_params()) + .await + .expect("quote should succeed"); + + let requests = server + .received_requests() + .await + .expect("recorded requests"); + let request = requests.first().expect("one request"); + assert!( + !request + .headers + .contains_key("authorization"), + "no API key configured, so no Authorization header should be sent" + ); + } + + #[tokio::test] + async fn health_uses_chain_scoped_path_with_api_key() { + use wiremock::{ + matchers::{header, method, path}, + Mock, MockServer, ResponseTemplate, + }; + + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/v1/arbitrum/health")) + .and(header("authorization", "secret-key")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "healthy": true, + "last_update_ms": 100, + "num_solver_pools": 5 + }))) + .expect(1) + .mount(&server) + .await; + + let (client, _asserter) = make_hosted_test_client( + server.uri(), + RetryConfig::default(), + None, + hosted(Some("secret-key"), Some("arbitrum")), + ); + + let status = client + .health() + .await + .expect("health should succeed"); + assert!(status.healthy()); + } + + #[tokio::test] + async fn info_uses_chain_scoped_path_with_api_key() { + use wiremock::{ + matchers::{header, method, path}, + Mock, MockServer, ResponseTemplate, + }; + + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/v1/unichain/info")) + .and(header("authorization", "secret-key")) + .respond_with(ResponseTemplate::new(200).set_body_json(make_info_body())) + .expect(1) + .mount(&server) + .await; + + let (client, _asserter) = make_hosted_test_client( + server.uri(), + RetryConfig::default(), + None, + hosted(Some("secret-key"), Some("unichain")), + ); + + let info = client + .info() + .await + .expect("info should succeed"); + assert_eq!(info.chain_id(), 1, "chain_id comes from the server payload"); + } + + #[tokio::test] + async fn api_key_without_chain_keeps_legacy_paths() { + use wiremock::{ + matchers::{header, method, path}, + Mock, MockServer, ResponseTemplate, + }; + + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/v1/health")) + .and(header("authorization", "secret-key")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "healthy": true, + "last_update_ms": 1, + "num_solver_pools": 1 + }))) + .expect(1) + .mount(&server) + .await; + + let (client, _asserter) = make_hosted_test_client( + server.uri(), + RetryConfig::default(), + None, + hosted(Some("secret-key"), None), + ); + + client + .health() + .await + .expect("health should succeed"); + } } diff --git a/clients/rust/src/lib.rs b/clients/rust/src/lib.rs index c9f1e72a6..66c300214 100644 --- a/clients/rust/src/lib.rs +++ b/clients/rust/src/lib.rs @@ -72,8 +72,8 @@ //! ``` pub use client::{ - AllowanceCheck, ApprovalParams, ExecutionOptions, FyndClient, FyndClientBuilder, RetryConfig, - SigningHints, StorageOverrides, + AllowanceCheck, ApprovalParams, ExecutionOptions, FyndClient, FyndClientBuilder, HostedConfig, + RetryConfig, SigningHints, StorageOverrides, }; pub use error::{ErrorCode, FyndError}; pub use signing::{ diff --git a/clients/rust/tests/integration.rs b/clients/rust/tests/integration.rs index cfe12a52b..55a8175a8 100644 --- a/clients/rust/tests/integration.rs +++ b/clients/rust/tests/integration.rs @@ -15,8 +15,8 @@ use alloy::{ providers::{ProviderBuilder, RootProvider}, }; use fynd_client::{ - ErrorCode, FyndClient, FyndError, Order, OrderSide, QuoteOptions, QuoteParams, RetryConfig, - SigningHints, SwapPayload, + ErrorCode, FyndClient, FyndError, HostedConfig, Order, OrderSide, QuoteOptions, QuoteParams, + RetryConfig, SigningHints, SwapPayload, }; use num_bigint::BigUint; use wiremock::{ @@ -54,6 +54,7 @@ fn make_client( default_sender, provider, submit_provider, + HostedConfig::default(), ); (client, asserter) } diff --git a/clients/typescript/client/src/autogen.ts b/clients/typescript/client/src/autogen.ts index 960d5a631..d1ffb3be8 100644 --- a/clients/typescript/client/src/autogen.ts +++ b/clients/typescript/client/src/autogen.ts @@ -1,23 +1,41 @@ /** - * Auto-generated TypeScript client for the fynd-rpc API. - * Do not make direct changes to this file. - * Re-generate by running: cargo run -- openapi > clients/openapi.json - * then: openapi-typescript clients/openapi.json -o clients/typescript/client/src/schema.d.ts + * Thin wrapper around openapi-fetch, bound to the fynd-rpc OpenAPI schema. + * + * The schema types live in `./schema.js`, which *is* auto-generated — re-generate it by running: + * cargo run -- openapi > clients/openapi.json + * openapi-typescript clients/openapi.json -o clients/typescript/client/src/schema.d.ts + * This file is hand-maintained and is not touched by that pipeline. */ import createClient from "openapi-fetch"; +import type { ClientOptions } from "openapi-fetch"; import type { paths } from "./schema.js"; export type { components, operations, paths } from "./schema.js"; +export type { Middleware } from "openapi-fetch"; + +/** Optional transport settings for {@link createFyndClient}. */ +export interface CreateFyndClientOptions { + /** Headers sent with every request (e.g. `Authorization`). */ + headers?: Record; + /** Custom fetch implementation; defaults to `globalThis.fetch`. */ + fetch?: ClientOptions["fetch"]; +} /** * Create a typed fynd-rpc API client. * * @param baseUrl - Base URL of the fynd-rpc server (e.g. "http://localhost:8080") + * @param options - Optional headers and fetch override * @returns A typed fetch client bound to the fynd-rpc OpenAPI schema */ -export function createFyndClient(baseUrl: string) { - return createClient({ baseUrl }); +export function createFyndClient(baseUrl: string, options?: CreateFyndClientOptions) { + // exactOptionalPropertyTypes: only forward keys that are actually set. + return createClient({ + baseUrl, + ...(options?.headers !== undefined ? { headers: options.headers } : {}), + ...(options?.fetch !== undefined ? { fetch: options.fetch } : {}), + }); } export type FyndClient = ReturnType; diff --git a/clients/typescript/client/src/client.test.ts b/clients/typescript/client/src/client.test.ts index 288e77256..5b600b91a 100644 --- a/clients/typescript/client/src/client.test.ts +++ b/clients/typescript/client/src/client.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; import { FyndClient } from './client.js'; import { FyndError } from './error.js'; import type { EthProvider, MinimalReceipt, FyndClientOptions } from './client.js'; @@ -993,3 +993,115 @@ function makeSignedSwap( const sig = `0x${'ab'.repeat(32)}${'cd'.repeat(32)}00` as `0x${string}`; return { payload, signature: sig }; } + +// --------------------------------------------------------------------------- +// Hosted gateway: API key + per-chain routing +// +// These go through the real openapi-fetch transport (unlike makeClientWithHttpMock, +// which replaces the whole http client) so that headers and URL rewriting are observable. +// --------------------------------------------------------------------------- + +describe('hosted gateway auth and routing', () => { + const WIRE_HEALTH = { healthy: true, last_update_ms: 1, num_solver_pools: 2 }; + + /** Stub globalThis.fetch. Must run before the client is constructed. */ + function stubFetch(body: unknown = WIRE_HEALTH) { + const fetchMock = vi.fn(() => Promise.resolve(new Response( + JSON.stringify(body), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ))); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; + } + + /** The Request that openapi-fetch handed to fetch. */ + function sentRequest(fetchMock: ReturnType): Request { + const [request] = fetchMock.mock.calls[0] as unknown as [Request]; + return request; + } + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('sends the api key as the raw Authorization header', async () => { + const fetchMock = stubFetch(); + const client = new FyndClient({ baseUrl: 'http://localhost:8080', apiKey: 'secret-key' }); + + await client.health(); + + expect(sentRequest(fetchMock).headers.get('authorization')).toBe('secret-key'); + }); + + it('routes to the chain-scoped path when chain is set', async () => { + const fetchMock = stubFetch(); + const client = new FyndClient({ baseUrl: 'http://localhost:8080', chain: 'base' }); + + await client.health(); + + expect(new URL(sentRequest(fetchMock).url).pathname).toBe('/v1/base/health'); + }); + + it('rewrites the quote path too', async () => { + const fetchMock = stubFetch(wireSolution); + const client = new FyndClient({ + baseUrl: 'http://localhost:8080', + chain: 'unichain', + apiKey: 'secret-key', + sender: SENDER, + }); + + await client.quote({ + order: { tokenIn: TOKEN_IN, tokenOut: TOKEN_OUT, amount: 1000n, side: 'sell', sender: SENDER }, + }); + + const request = sentRequest(fetchMock); + expect(new URL(request.url).pathname).toBe('/v1/unichain/quote'); + expect(request.headers.get('authorization')).toBe('secret-key'); + }); + + it('keeps legacy paths and sends no auth header when neither option is set', async () => { + const fetchMock = stubFetch(); + const client = new FyndClient({ baseUrl: 'http://localhost:8080' }); + + await client.health(); + + const request = sentRequest(fetchMock); + expect(new URL(request.url).pathname).toBe('/v1/health'); + expect(request.headers.get('authorization')).toBeNull(); + }); + + it('preserves a base URL path prefix when inserting the chain segment', async () => { + const fetchMock = stubFetch(); + const client = new FyndClient({ baseUrl: 'http://localhost:8080/gateway', chain: 'base' }); + + await client.health(); + + expect(new URL(sentRequest(fetchMock).url).pathname).toBe('/gateway/v1/base/health'); + }); + + it('passes through arbitrary headers', async () => { + const fetchMock = stubFetch(); + const client = new FyndClient({ + baseUrl: 'http://localhost:8080', + headers: { 'X-Trace-Id': 'abc123' }, + }); + + await client.health(); + + expect(sentRequest(fetchMock).headers.get('x-trace-id')).toBe('abc123'); + }); + + it('lets apiKey win over an Authorization header passed via headers', async () => { + const fetchMock = stubFetch(); + const client = new FyndClient({ + baseUrl: 'http://localhost:8080', + apiKey: 'secret-key', + headers: { Authorization: 'Bearer stale' }, + }); + + await client.health(); + + expect(sentRequest(fetchMock).headers.get('authorization')).toBe('secret-key'); + }); +}); diff --git a/clients/typescript/client/src/client.ts b/clients/typescript/client/src/client.ts index 74aa7302f..d89fd2279 100644 --- a/clients/typescript/client/src/client.ts +++ b/clients/typescript/client/src/client.ts @@ -1,6 +1,6 @@ import { decodeAbiParameters, encodeFunctionData, keccak256, serializeTransaction, toHex } from 'viem'; import { createFyndClient, type FyndClient as AutogenClient } from "./autogen.js"; -import type { components } from "./autogen.js"; +import type { components, Middleware } from "./autogen.js"; import { FyndError } from "./error.js"; import * as mapping from "./mapping.js"; import { @@ -95,6 +95,23 @@ export interface ExecutionOptions { export interface FyndClientOptions { /** Base URL of the Fynd API (e.g. `"https://api.fynd.exchange"`). */ baseUrl: string; + /** + * API key for the hosted Fynd gateway, sent as the raw `Authorization` header value (no + * `Bearer` prefix) on every request, matching what the deployed gateway expects. The Tycho + * API key also authenticates Fynd. Omit for self-hosted instances. + */ + apiKey?: string; + /** + * Chain slug for the hosted gateway's per-chain routing, e.g. `"ethereum"` or `"base"`. + * When set, requests go to `/v1/{chain}/quote` instead of `/v1/quote`. Omit for self-hosted + * instances, which serve the unprefixed paths. + */ + chain?: string; + /** + * Extra headers sent with every request. `Authorization` set here is overridden by + * {@link apiKey} when both are provided. + */ + headers?: Record; /** Default sender address, used when {@link SigningHints.sender} is not set. */ sender?: Address; /** HTTP request timeout in milliseconds (default: 30000). */ @@ -127,7 +144,20 @@ export class FyndClient { private infoPromise: Promise | undefined = undefined; constructor(options: FyndClientOptions) { - this.http = createFyndClient(options.baseUrl); + const headers: Record = { ...options.headers }; + if (options.apiKey !== undefined) { + headers['Authorization'] = options.apiKey; + } + + this.http = createFyndClient(options.baseUrl, { headers }); + + // Per-chain routing is applied as middleware rather than at the call sites: the OpenAPI + // schema only knows the unprefixed literals ("/v1/quote", ...), so rewriting the URL here + // keeps every call site typed against the generated schema instead of casting each one. + if (options.chain !== undefined) { + this.http.use(chainRoutingMiddleware(options.chain)); + } + this.options = options; } @@ -616,6 +646,22 @@ export class FyndClient { } } +/** + * Rewrites `/v1/` to `/v1//` for the hosted gateway. + * + * Only the first `/v1/` segment is rewritten, so a base URL that itself contains a path + * prefix is left intact. + */ +function chainRoutingMiddleware(chain: string): Middleware { + return { + onRequest({ request }) { + const url = new URL(request.url); + url.pathname = url.pathname.replace('/v1/', `/v1/${chain}/`); + return new Request(url, request); + }, + }; +} + function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } diff --git a/clients/typescript/client/src/index.ts b/clients/typescript/client/src/index.ts index a413cfddf..9dfeed5ef 100644 --- a/clients/typescript/client/src/index.ts +++ b/clients/typescript/client/src/index.ts @@ -57,5 +57,7 @@ export type { RetryConfig, SigningHints, } from "./client.js"; +export { createFyndClient } from "./autogen.js"; +export type { CreateFyndClientOptions, Middleware } from "./autogen.js"; export { viemProvider } from "./viem.js"; export type { ViemPublicClient } from "./viem.js";