From d4405cbe64edbbef86a238bfcafafa9b6efe431c Mon Sep 17 00:00:00 2001 From: Thales Vinagre Date: Sat, 18 Jul 2026 16:04:57 +0000 Subject: [PATCH 1/2] feat: add hosted-API auth and per-chain routing to clients The hosted gateway at fynd-api.propellerheads.xyz requires a bearer token and per-chain request paths (/v1/{chain}/quote). Neither typed client could produce those requests, forcing users onto raw curl/fetch. Adds with_api_key/with_chain to the Rust FyndClientBuilder (HostedConfig struct) and apiKey/chain/headers to the TypeScript FyndClientOptions. The TS chain routing is an openapi-fetch middleware that rewrites /v1/ to /v1/{chain}/, keeping call sites typed against the schema literals. createFyndClient and the Middleware type are now re-exported from the package root. Also fixes a signing bug: build_quote_only hardcoded chain_id 1, so a quote-only client on a non-mainnet chain would sign transactions stamped for mainnet whenever SigningHints supplied nonce, fees, and gas. The chain ID is now derived from the chain slug. build() checks the slug against the chain the RPC node reports. Both options are opt-in; unset, they reproduce the previous unauthenticated requests to unprefixed paths. --- clients/CLAUDE.md | 24 +- clients/rust/src/client.rs | 428 ++++++++++++++++++- clients/rust/src/lib.rs | 4 +- clients/rust/tests/integration.rs | 5 +- clients/typescript/client/src/autogen.ts | 30 +- clients/typescript/client/src/client.test.ts | 114 ++++- clients/typescript/client/src/client.ts | 49 ++- clients/typescript/client/src/index.ts | 2 + 8 files changed, 629 insertions(+), 27 deletions(-) diff --git a/clients/CLAUDE.md b/clients/CLAUDE.md index acc858b7e..9d97921ac 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 `Authorization: Bearer ` + .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..d1e112642 100644 --- a/clients/rust/src/client.rs +++ b/clients/rust/src/client.rs @@ -358,6 +358,51 @@ 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 `Authorization: Bearer ` 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 +421,7 @@ pub struct FyndClientBuilder { rpc_url: Option, submit_url: Option, sender: Option
, + hosted: HostedConfig, } impl FyndClientBuilder { @@ -395,9 +441,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 `Authorization: Bearer ` on every Fynd API request. 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 +507,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 +523,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 +545,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 +558,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 +605,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 +630,7 @@ impl FyndClientBuilder { default_sender: self.sender, provider, submit_provider, + hosted: self.hosted, info_cache: tokio::sync::OnceCell::new(), }) } @@ -556,6 +657,7 @@ where default_sender: Option
, provider: P, submit_provider: P, + hosted: HostedConfig, info_cache: tokio::sync::OnceCell, } @@ -576,6 +678,7 @@ where default_sender: Option
, provider: P, submit_provider: P, + hosted: HostedConfig, ) -> Self { Self { http, @@ -585,10 +688,27 @@ 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 bearer token when an API key is configured. + fn authorized(&self, request: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + match &self.hosted.api_key { + Some(api_key) => request.bearer_auth(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 +748,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 +798,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 +814,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 +1075,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 +1410,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 +1442,7 @@ mod tests { default_sender, provider, submit_provider, + hosted, ); (client, asserter) @@ -2246,4 +2383,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_bearer_token_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", "Bearer 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", "Bearer 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", "Bearer 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", "Bearer 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..1a3a9e1d1 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 a bearer token', 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('Bearer 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('Bearer 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('Bearer secret-key'); + }); +}); diff --git a/clients/typescript/client/src/client.ts b/clients/typescript/client/src/client.ts index 74aa7302f..44ac2421a 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,22 @@ 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 `Authorization: Bearer ` on every + * request. 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 +143,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'] = `Bearer ${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 +645,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"; From 4d63aaafc4cbc9e790707f9cabf2a29d192af5e5 Mon Sep 17 00:00:00 2001 From: Thales Lima Date: Sat, 18 Jul 2026 21:09:52 -0300 Subject: [PATCH 2/2] fix(client): send API key as raw Authorization header, not Bearer The deployed ph-nginx-auth gateway matches the entire raw Authorization header value against user_tokens.token, with no Bearer stripping. Verified against the live prod gateway with an admin key: a raw key returns 200 while `Authorization: Bearer ` returns 401. Send the key verbatim (Rust and TypeScript clients) and update the doc comments and header assertions. Co-Authored-By: Claude Fable 5 --- clients/CLAUDE.md | 2 +- clients/rust/src/client.rs | 28 ++++++++++++-------- clients/typescript/client/src/client.test.ts | 8 +++--- clients/typescript/client/src/client.ts | 7 ++--- 4 files changed, 26 insertions(+), 19 deletions(-) diff --git a/clients/CLAUDE.md b/clients/CLAUDE.md index 9d97921ac..bfc3203fa 100644 --- a/clients/CLAUDE.md +++ b/clients/CLAUDE.md @@ -49,7 +49,7 @@ Against the hosted gateway (`fynd-api.propellerheads.xyz`), add auth and per-cha ```rust FyndClientBuilder::new("https://fynd-api.propellerheads.xyz") - .with_api_key(tycho_api_key) // sent as `Authorization: Bearer ` + .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()? ``` diff --git a/clients/rust/src/client.rs b/clients/rust/src/client.rs index d1e112642..c15451ca2 100644 --- a/clients/rust/src/client.rs +++ b/clients/rust/src/client.rs @@ -397,7 +397,8 @@ fn chain_id_for_slug(chain: &str) -> Result { /// unauthenticated requests against `{base_url}/v1/…`. #[derive(Clone, Default)] pub struct HostedConfig { - /// API key sent as `Authorization: Bearer ` on every Fynd API request. + /// 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, @@ -447,9 +448,9 @@ impl FyndClientBuilder { /// Authenticate against the hosted Fynd gateway. /// - /// The key is sent as `Authorization: Bearer ` on every Fynd API request. The Tycho - /// API key issued by the keygen bot also authenticates Fynd. Not needed for self-hosted - /// instances. + /// 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 @@ -701,10 +702,15 @@ where } } - /// Attach the hosted-gateway bearer token when an API key is configured. + /// 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.bearer_auth(api_key), + Some(api_key) => request.header(reqwest::header::AUTHORIZATION, api_key), None => request, } } @@ -2455,7 +2461,7 @@ mod tests { } #[tokio::test] - async fn quote_sends_bearer_token_to_chain_scoped_path() { + async fn quote_sends_api_key_to_chain_scoped_path() { use wiremock::{ matchers::{header, method, path}, Mock, MockServer, ResponseTemplate, @@ -2479,7 +2485,7 @@ mod tests { Mock::given(method("POST")) .and(path("/v1/base/quote")) - .and(header("authorization", "Bearer secret-key")) + .and(header("authorization", "secret-key")) .respond_with(ResponseTemplate::new(200).set_body_json(body)) .expect(1) .mount(&server) @@ -2560,7 +2566,7 @@ mod tests { Mock::given(method("GET")) .and(path("/v1/arbitrum/health")) - .and(header("authorization", "Bearer secret-key")) + .and(header("authorization", "secret-key")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "healthy": true, "last_update_ms": 100, @@ -2595,7 +2601,7 @@ mod tests { Mock::given(method("GET")) .and(path("/v1/unichain/info")) - .and(header("authorization", "Bearer secret-key")) + .and(header("authorization", "secret-key")) .respond_with(ResponseTemplate::new(200).set_body_json(make_info_body())) .expect(1) .mount(&server) @@ -2626,7 +2632,7 @@ mod tests { Mock::given(method("GET")) .and(path("/v1/health")) - .and(header("authorization", "Bearer secret-key")) + .and(header("authorization", "secret-key")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "healthy": true, "last_update_ms": 1, diff --git a/clients/typescript/client/src/client.test.ts b/clients/typescript/client/src/client.test.ts index 1a3a9e1d1..5b600b91a 100644 --- a/clients/typescript/client/src/client.test.ts +++ b/clients/typescript/client/src/client.test.ts @@ -1024,13 +1024,13 @@ describe('hosted gateway auth and routing', () => { vi.unstubAllGlobals(); }); - it('sends the api key as a bearer token', async () => { + 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('Bearer secret-key'); + expect(sentRequest(fetchMock).headers.get('authorization')).toBe('secret-key'); }); it('routes to the chain-scoped path when chain is set', async () => { @@ -1057,7 +1057,7 @@ describe('hosted gateway auth and routing', () => { const request = sentRequest(fetchMock); expect(new URL(request.url).pathname).toBe('/v1/unichain/quote'); - expect(request.headers.get('authorization')).toBe('Bearer secret-key'); + expect(request.headers.get('authorization')).toBe('secret-key'); }); it('keeps legacy paths and sends no auth header when neither option is set', async () => { @@ -1102,6 +1102,6 @@ describe('hosted gateway auth and routing', () => { await client.health(); - expect(sentRequest(fetchMock).headers.get('authorization')).toBe('Bearer secret-key'); + 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 44ac2421a..d89fd2279 100644 --- a/clients/typescript/client/src/client.ts +++ b/clients/typescript/client/src/client.ts @@ -96,8 +96,9 @@ 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 `Authorization: Bearer ` on every - * request. The Tycho API key also authenticates Fynd. Omit for self-hosted instances. + * 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; /** @@ -145,7 +146,7 @@ export class FyndClient { constructor(options: FyndClientOptions) { const headers: Record = { ...options.headers }; if (options.apiKey !== undefined) { - headers['Authorization'] = `Bearer ${options.apiKey}`; + headers['Authorization'] = options.apiKey; } this.http = createFyndClient(options.baseUrl, { headers });