Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
9 changes: 2 additions & 7 deletions src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
Expand Down Expand Up @@ -61,7 +56,7 @@ pub async fn authenticate_with_signer(
}

pub fn unauthenticated_clob_client() -> Result<clob::Client> {
clob::Client::new(&clob_host(), clob::Config::default())
clob::Client::new(&endpoints::clob_host(), clob::Config::default())
.context("Failed to create Polymarket CLOB client")
}

Expand Down
51 changes: 51 additions & 0 deletions src/endpoints.rs
Original file line number Diff line number Diff line change
@@ -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> {
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> {
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/"
);
}
}
38 changes: 26 additions & 12 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
mod auth;
mod commands;
mod config;
mod endpoints;
mod output;
mod shell;

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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}));
Expand Down
138 changes: 138 additions & 0 deletions tests/api_hosts.rs
Original file line number Diff line number Diff line change
@@ -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<String>) {
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());
}
}