From 8e8f8c0c5b4714b4098c73946f2ef0de3e012488 Mon Sep 17 00:00:00 2001 From: James Jackson Date: Sun, 13 Sep 2026 13:10:05 -0400 Subject: [PATCH] fix: support Gamma and Data API host overrides --- README.md | 22 ++++++++ src/auth.rs | 9 +-- src/endpoints.rs | 51 +++++++++++++++++ src/main.rs | 38 +++++++++---- tests/api_hosts.rs | 138 +++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 239 insertions(+), 19 deletions(-) create mode 100644 src/endpoints.rs create mode 100644 tests/api_hosts.rs diff --git a/README.md b/README.md index 7575d59..2504998 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,28 @@ polymarket approve set ## Configuration +### API hosts + +Override service URLs for local emulators or test environments: + +| Variable | Default | +| --- | --- | +| `POLYMARKET_GAMMA_HOST` | `https://gamma-api.polymarket.com` | +| `POLYMARKET_DATA_HOST` | `https://data-api.polymarket.com` | +| `POLYMARKET_CLOB_HOST` | `https://clob.polymarket.com` | + +URLs may include a path prefix, with or without a trailing slash. Clients are +created only for the selected command. Invalid URLs produce normal CLI errors. + +```bash +POLYMARKET_GAMMA_HOST=http://127.0.0.1:4400/gamma polymarket markets list +POLYMARKET_DATA_HOST=http://127.0.0.1:4400/data polymarket data positions 0xYOUR_ADDRESS +POLYMARKET_CLOB_HOST=http://127.0.0.1:4400/clob polymarket clob book TOKEN_ID +``` + +These overrides do not redirect Bridge or blockchain RPC requests. RPC has its +own `POLYMARKET_RPC_URL` override. Use fixture credentials with local services. + ### Wallet Setup The CLI needs a private key to sign orders and on-chain transactions. Three ways to provide it (checked in this order): diff --git a/src/auth.rs b/src/auth.rs index 66c1a60..e553987 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -7,15 +7,10 @@ use polymarket_client_sdk_v2::auth::{LocalSigner, Normal, Signer as _}; use polymarket_client_sdk_v2::clob::types::SignatureType; use polymarket_client_sdk_v2::{POLYGON, clob}; -use crate::config; +use crate::{config, endpoints}; -const DEFAULT_CLOB_HOST: &str = "https://clob.polymarket.com"; const DEFAULT_RPC_URL: &str = "https://polygon.drpc.org"; -fn clob_host() -> String { - std::env::var("POLYMARKET_CLOB_HOST").unwrap_or_else(|_| DEFAULT_CLOB_HOST.to_string()) -} - fn rpc_url() -> String { std::env::var("POLYMARKET_RPC_URL").unwrap_or_else(|_| DEFAULT_RPC_URL.to_string()) } @@ -61,7 +56,7 @@ pub async fn authenticate_with_signer( } pub fn unauthenticated_clob_client() -> Result { - clob::Client::new(&clob_host(), clob::Config::default()) + clob::Client::new(&endpoints::clob_host(), clob::Config::default()) .context("Failed to create Polymarket CLOB client") } diff --git a/src/endpoints.rs b/src/endpoints.rs new file mode 100644 index 0000000..ec200d2 --- /dev/null +++ b/src/endpoints.rs @@ -0,0 +1,51 @@ +use anyhow::{Context, Result}; +use polymarket_client_sdk_v2::{data, gamma}; + +fn host(variable: &str, default: &str) -> String { + normalize_host(&std::env::var(variable).unwrap_or_else(|_| default.to_owned())) +} + +fn normalize_host(host: &str) -> String { + // SDK requests append paths to the base URL; preserve prefixes with one slash. + format!("{}/", host.trim_end_matches('/')) +} + +pub fn gamma_client() -> Result { + gamma::Client::new(&host( + "POLYMARKET_GAMMA_HOST", + "https://gamma-api.polymarket.com", + )) + .context("Failed to create Gamma client; check POLYMARKET_GAMMA_HOST") +} + +pub fn data_client() -> Result { + data::Client::new(&host( + "POLYMARKET_DATA_HOST", + "https://data-api.polymarket.com", + )) + .context("Failed to create Data client; check POLYMARKET_DATA_HOST") +} + +pub fn clob_host() -> String { + host("POLYMARKET_CLOB_HOST", "https://clob.polymarket.com") +} + +#[cfg(test)] +mod tests { + use super::normalize_host; + + #[test] + fn preserves_service_prefix_with_one_trailing_slash() { + for host in [ + "http://127.0.0.1:4400/gamma", + "http://127.0.0.1:4400/gamma/", + "http://127.0.0.1:4400/gamma///", + ] { + assert_eq!(normalize_host(host), "http://127.0.0.1:4400/gamma/"); + } + assert_eq!( + normalize_host("https://clob.polymarket.com"), + "https://clob.polymarket.com/" + ); + } +} diff --git a/src/main.rs b/src/main.rs index aaf5840..a5fa358 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ mod auth; mod commands; mod config; +mod endpoints; mod output; mod shell; @@ -81,21 +82,32 @@ async fn main() -> ExitCode { #[allow(clippy::too_many_lines)] pub(crate) async fn run(cli: Cli) -> anyhow::Result<()> { - // Lazy-init so we only pay for the client we actually use. - let gamma = std::cell::LazyCell::new(polymarket_client_sdk_v2::gamma::Client::default); - let data = std::cell::LazyCell::new(polymarket_client_sdk_v2::data::Client::default); let bridge = std::cell::LazyCell::new(polymarket_client_sdk_v2::bridge::Client::default); match cli.command { Commands::Setup => commands::setup::execute(), Commands::Shell => Box::pin(shell::run_shell()).await, - Commands::Markets(args) => commands::markets::execute(&gamma, args, cli.output).await, - Commands::Events(args) => commands::events::execute(&gamma, args, cli.output).await, - Commands::Tags(args) => commands::tags::execute(&gamma, args, cli.output).await, - Commands::Series(args) => commands::series::execute(&gamma, args, cli.output).await, - Commands::Comments(args) => commands::comments::execute(&gamma, args, cli.output).await, - Commands::Profiles(args) => commands::profiles::execute(&gamma, args, cli.output).await, - Commands::Sports(args) => commands::sports::execute(&gamma, args, cli.output).await, + Commands::Markets(args) => { + commands::markets::execute(&endpoints::gamma_client()?, args, cli.output).await + } + Commands::Events(args) => { + commands::events::execute(&endpoints::gamma_client()?, args, cli.output).await + } + Commands::Tags(args) => { + commands::tags::execute(&endpoints::gamma_client()?, args, cli.output).await + } + Commands::Series(args) => { + commands::series::execute(&endpoints::gamma_client()?, args, cli.output).await + } + Commands::Comments(args) => { + commands::comments::execute(&endpoints::gamma_client()?, args, cli.output).await + } + Commands::Profiles(args) => { + commands::profiles::execute(&endpoints::gamma_client()?, args, cli.output).await + } + Commands::Sports(args) => { + commands::sports::execute(&endpoints::gamma_client()?, args, cli.output).await + } Commands::Approve(args) => { commands::approve::execute( args, @@ -123,14 +135,16 @@ pub(crate) async fn run(cli: Cli) -> anyhow::Result<()> { ) .await } - Commands::Data(args) => commands::data::execute(&data, args, cli.output).await, + Commands::Data(args) => { + commands::data::execute(&endpoints::data_client()?, args, cli.output).await + } Commands::Bridge(args) => commands::bridge::execute(&bridge, args, cli.output).await, Commands::Wallet(args) => { commands::wallet::execute(args, cli.output, cli.private_key.as_deref()) } Commands::Upgrade => commands::upgrade::execute(), Commands::Status => { - let status = gamma.status().await?; + let status = endpoints::gamma_client()?.status().await?; match cli.output { OutputFormat::Json => { println!("{}", serde_json::json!({"status": status})); diff --git a/tests/api_hosts.rs b/tests/api_hosts.rs new file mode 100644 index 0000000..7b162e2 --- /dev/null +++ b/tests/api_hosts.rs @@ -0,0 +1,138 @@ +#![allow(deprecated)] + +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::thread; +use std::time::{Duration, Instant}; + +use assert_cmd::Command; +use predicates::prelude::*; + +fn local_response(body: &'static str) -> (String, thread::JoinHandle) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let address = listener.local_addr().unwrap(); + let handle = thread::spawn(move || { + let deadline = Instant::now() + Duration::from_secs(10); + let mut stream = loop { + match listener.accept() { + Ok((stream, _)) => break stream, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + assert!( + Instant::now() < deadline, + "CLI did not reach the local host" + ); + thread::sleep(Duration::from_millis(10)); + } + Err(error) => panic!("accept failed: {error}"), + } + }; + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut request = Vec::new(); + let mut buffer = [0; 1024]; + while !request.windows(4).any(|window| window == b"\r\n\r\n") { + let count = stream.read(&mut buffer).unwrap(); + assert!(count > 0, "incomplete HTTP request"); + request.extend_from_slice(&buffer[..count]); + } + write!(stream, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).unwrap(); + String::from_utf8(request) + .unwrap() + .lines() + .next() + .unwrap() + .to_owned() + }); + (format!("http://{address}"), handle) +} + +#[test] +fn routes_all_services_to_prefixed_hosts_with_or_without_trailing_slashes() { + let cases: &[(&str, &str, &[&str], &str, &str)] = &[ + ( + "POLYMARKET_GAMMA_HOST", + "gamma", + &["markets", "list", "--limit", "1"], + "[]", + "/gamma/markets", + ), + ( + "POLYMARKET_DATA_HOST", + "data", + &[ + "data", + "positions", + "0x1111111111111111111111111111111111111111", + ], + "[]", + "/data/positions", + ), + ( + "POLYMARKET_CLOB_HOST", + "clob", + &["clob", "midpoint", "1001"], + "{\"mid\":\"0.58\"}", + "/clob/midpoint", + ), + ]; + for (variable, prefix, args, body, expected_path) in cases { + for suffix in ["", "/", "///"] { + let (base, request) = local_response(body); + let mut command = Command::cargo_bin("polymarket").unwrap(); + // Any accidental use of an unrelated service fails without network access. + for host in [ + "POLYMARKET_GAMMA_HOST", + "POLYMARKET_DATA_HOST", + "POLYMARKET_CLOB_HOST", + ] { + command.env(host, "invalid-url"); + } + command + .env(variable, format!("{base}/{prefix}{suffix}")) + .env_remove("POLYMARKET_PRIVATE_KEY") + .args(["-o", "json"]) + .args(*args) + .timeout(Duration::from_secs(10)) + .assert() + .success(); + let line = request.join().unwrap(); + let path = line + .split_whitespace() + .nth(1) + .unwrap() + .split('?') + .next() + .unwrap(); + assert_eq!(path, *expected_path); + } + } +} + +#[test] +fn invalid_hosts_return_cli_errors_without_panicking() { + for (variable, args) in [ + ("POLYMARKET_GAMMA_HOST", vec!["markets", "list"]), + ( + "POLYMARKET_DATA_HOST", + vec![ + "data", + "positions", + "0x1111111111111111111111111111111111111111", + ], + ), + ("POLYMARKET_CLOB_HOST", vec!["clob", "midpoint", "1001"]), + ] { + Command::cargo_bin("polymarket") + .unwrap() + .env(variable, "invalid-url") + .args(["-o", "json"]) + .args(args) + .timeout(Duration::from_secs(10)) + .assert() + .failure() + .stdout(predicate::str::contains("error")) + .stderr(predicate::str::contains("panicked").not()); + } +}