Skip to content
Open
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
20 changes: 11 additions & 9 deletions src/commands/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use polymarket_client_sdk::gamma::{
types::request::{EventByIdRequest, EventBySlugRequest, EventTagsRequest, EventsRequest},
};

use super::is_numeric_id;
use super::{ResolvedId, resolve_id};
use crate::output::events::{print_event_detail, print_events_table};
use crate::output::tags::print_tags_table;
use crate::output::{OutputFormat, print_json};
Expand Down Expand Up @@ -51,7 +51,7 @@ pub enum EventsCommand {

/// Get a single event by ID or slug
Get {
/// Event ID (numeric) or slug
/// Event ID (numeric), slug, or Polymarket URL
id: String,
},

Expand Down Expand Up @@ -93,13 +93,15 @@ pub async fn execute(client: &gamma::Client, args: EventsArgs, output: OutputFor
}

EventsCommand::Get { id } => {
let is_numeric = is_numeric_id(&id);
let event = if is_numeric {
let req = EventByIdRequest::builder().id(id).build();
client.event_by_id(&req).await?
} else {
let req = EventBySlugRequest::builder().slug(id).build();
client.event_by_slug(&req).await?
let event = match resolve_id(&id, false) {
ResolvedId::Numeric(n) => {
let req = EventByIdRequest::builder().id(n).build();
client.event_by_id(&req).await?
}
ResolvedId::Slug(slug) => {
let req = EventBySlugRequest::builder().slug(slug).build();
client.event_by_slug(&req).await?
}
};

match output {
Expand Down
20 changes: 11 additions & 9 deletions src/commands/markets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use polymarket_client_sdk::gamma::{
},
};

use super::is_numeric_id;
use super::{ResolvedId, resolve_id};
use crate::output::markets::{print_market_detail, print_markets_table};
use crate::output::tags::print_tags_table;
use crate::output::{OutputFormat, print_json};
Expand Down Expand Up @@ -53,7 +53,7 @@ pub enum MarketsCommand {

/// Get a single market by ID or slug
Get {
/// Market ID (numeric) or slug
/// Market ID (numeric), slug, or Polymarket URL
id: String,
},

Expand Down Expand Up @@ -107,13 +107,15 @@ pub async fn execute(
}

MarketsCommand::Get { id } => {
let is_numeric = is_numeric_id(&id);
let market = if is_numeric {
let req = MarketByIdRequest::builder().id(id).build();
client.market_by_id(&req).await?
} else {
let req = MarketBySlugRequest::builder().slug(id).build();
client.market_by_slug(&req).await?
let market = match resolve_id(&id, true) {
ResolvedId::Numeric(n) => {
let req = MarketByIdRequest::builder().id(n).build();
client.market_by_id(&req).await?
}
ResolvedId::Slug(slug) => {
let req = MarketBySlugRequest::builder().slug(slug).build();
client.market_by_slug(&req).await?
}
};

match output {
Expand Down
274 changes: 274 additions & 0 deletions src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,106 @@ pub fn parse_condition_id(s: &str) -> anyhow::Result<B256> {
.map_err(|_| anyhow::anyhow!("Invalid condition ID: must be a 0x-prefixed 32-byte hex"))
}

/// Parsed Polymarket URL with event slug and optional market slug.
#[derive(Debug, PartialEq)]
pub struct PolymarketUrl {
pub event_slug: String,
pub market_slug: Option<String>,
}

/// Parse a Polymarket URL into its event and optional market slugs.
///
/// Accepts URLs with or without scheme (`https://`, `http://`), with or without
/// `www.`, and strips query strings, fragments, and trailing slashes.
///
/// Returns `None` for non-Polymarket URLs or URLs missing `/event/<slug>`.
pub fn parse_polymarket_url(input: &str) -> Option<PolymarketUrl> {
// Strip scheme if present
let without_scheme = input
.strip_prefix("https://")
.or_else(|| input.strip_prefix("http://"))
.unwrap_or(input);

// Split host from path at the first '/'
let (host, path) = match without_scheme.find('/') {
Some(i) => (&without_scheme[..i], &without_scheme[i..]),
None => return None, // No path at all
};

// Verify it's a polymarket.com host
let host_lower = host.to_ascii_lowercase();
if host_lower != "polymarket.com" && host_lower != "www.polymarket.com" {
return None;
}

// Strip query string and fragment
let path = path.split('?').next().unwrap_or(path);
let path = path.split('#').next().unwrap_or(path);

// Strip trailing slash
let path = path.strip_suffix('/').unwrap_or(path);

// Expect /event/<event_slug>[/<market_slug>]
let path = path.strip_prefix("/event/")?;
if path.is_empty() {
return None;
}

let mut segments = path.split('/');
let event_slug = segments.next()?.to_string();
if event_slug.is_empty() {
return None;
}

let market_slug = segments
.next()
.filter(|s| !s.is_empty())
.map(|s| s.to_string());

Some(PolymarketUrl {
event_slug,
market_slug,
})
}

/// What `resolve_id` determined the input to be.
#[derive(Debug, PartialEq)]
pub enum ResolvedId {
/// A numeric API id (e.g. "12345").
Numeric(String),
/// A slug extracted from a Polymarket URL or passed directly.
Slug(String),
}

/// Resolve a user-provided identifier that may be a Polymarket URL, a numeric
/// ID, or a plain slug.
///
/// Accepts URLs like `https://polymarket.com/event/<event>[/<market>]`.
/// When `prefer_market` is true and the URL contains a market slug, the market
/// slug is used; otherwise the event slug is used.
pub fn resolve_id(input: &str, prefer_market: bool) -> ResolvedId {
if let Some(parsed) = parse_polymarket_url(input) {
let slug = if prefer_market {
parsed.market_slug.unwrap_or(parsed.event_slug)
} else {
parsed.event_slug
};
return ResolvedId::Slug(slug);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Market URL without market slug misresolves

Medium Severity

When prefer_market is true, resolve_id falls back to event_slug if the Polymarket URL has no market_slug. This makes markets get treat an event-only URL as a market slug and call market_by_slug with an event slug, which can lead to incorrect lookups or confusing failures.

Fix in Cursor Fix in Web

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional. When the URL has no market slug, falling back to the event slug is better than erroring -- it gives the API a chance to resolve it (works when event/market slugs match, which is common for single-market events).

When it doesn't match, the API returns a clear 404 ("slug not found"), same as typing any wrong slug. No silent misresolution is possible.


if is_numeric_id(input) {
ResolvedId::Numeric(input.to_string())
} else {
ResolvedId::Slug(input.to_string())
}
}

#[cfg(test)]
mod tests {
use super::*;

// ── is_numeric_id ──────────────────────────────────────────────

#[test]
fn is_numeric_id_pure_digits() {
assert!(is_numeric_id("12345"));
Expand All @@ -52,6 +148,8 @@ mod tests {
assert!(!is_numeric_id(""));
}

// ── parse_address / parse_condition_id ──────────────────────────

#[test]
fn parse_address_valid_hex() {
let addr = "0x0000000000000000000000000000000000000001";
Expand Down Expand Up @@ -87,4 +185,180 @@ mod tests {
let err = parse_condition_id("garbage").unwrap_err().to_string();
assert!(err.contains("32-byte"), "got: {err}");
}

// ── parse_polymarket_url ───────────────────────────────────────

#[test]
fn parse_url_standard_event() {
let url = "https://polymarket.com/event/will-bitcoin-hit-100k";
let parsed = parse_polymarket_url(url).unwrap();
assert_eq!(parsed.event_slug, "will-bitcoin-hit-100k");
assert_eq!(parsed.market_slug, None);
}

#[test]
fn parse_url_event_with_market() {
let url = "https://polymarket.com/event/will-bitcoin-hit-100k/bitcoin-100k-by-march";
let parsed = parse_polymarket_url(url).unwrap();
assert_eq!(parsed.event_slug, "will-bitcoin-hit-100k");
assert_eq!(parsed.market_slug.as_deref(), Some("bitcoin-100k-by-march"));
}

#[test]
fn parse_url_http_scheme() {
let url = "http://polymarket.com/event/some-event";
let parsed = parse_polymarket_url(url).unwrap();
assert_eq!(parsed.event_slug, "some-event");
}

#[test]
fn parse_url_no_scheme() {
let url = "polymarket.com/event/some-event/some-market";
let parsed = parse_polymarket_url(url).unwrap();
assert_eq!(parsed.event_slug, "some-event");
assert_eq!(parsed.market_slug.as_deref(), Some("some-market"));
}

#[test]
fn parse_url_www_prefix() {
let url = "https://www.polymarket.com/event/some-event";
let parsed = parse_polymarket_url(url).unwrap();
assert_eq!(parsed.event_slug, "some-event");
}

#[test]
fn parse_url_www_no_scheme() {
let url = "www.polymarket.com/event/some-event";
let parsed = parse_polymarket_url(url).unwrap();
assert_eq!(parsed.event_slug, "some-event");
}

#[test]
fn parse_url_trailing_slash() {
let url = "https://polymarket.com/event/some-event/";
let parsed = parse_polymarket_url(url).unwrap();
assert_eq!(parsed.event_slug, "some-event");
assert_eq!(parsed.market_slug, None);
}

#[test]
fn parse_url_trailing_slash_with_market() {
let url = "https://polymarket.com/event/some-event/some-market/";
let parsed = parse_polymarket_url(url).unwrap();
assert_eq!(parsed.event_slug, "some-event");
assert_eq!(parsed.market_slug.as_deref(), Some("some-market"));
}

#[test]
fn parse_url_with_query_string() {
let url = "https://polymarket.com/event/some-event/some-market?tid=abc123";
let parsed = parse_polymarket_url(url).unwrap();
assert_eq!(parsed.event_slug, "some-event");
assert_eq!(parsed.market_slug.as_deref(), Some("some-market"));
}

#[test]
fn parse_url_with_fragment() {
let url = "https://polymarket.com/event/some-event#comments";
let parsed = parse_polymarket_url(url).unwrap();
assert_eq!(parsed.event_slug, "some-event");
}

#[test]
fn parse_url_with_query_and_fragment() {
let url = "https://polymarket.com/event/some-event/some-market?tid=1#top";
let parsed = parse_polymarket_url(url).unwrap();
assert_eq!(parsed.event_slug, "some-event");
assert_eq!(parsed.market_slug.as_deref(), Some("some-market"));
}

#[test]
fn parse_url_extra_path_segments_ignored() {
let url = "https://polymarket.com/event/my-event/my-market/extra/stuff";
let parsed = parse_polymarket_url(url).unwrap();
assert_eq!(parsed.event_slug, "my-event");
assert_eq!(parsed.market_slug.as_deref(), Some("my-market"));
}

#[test]
fn parse_url_rejects_non_polymarket_domain() {
assert!(parse_polymarket_url("https://example.com/event/foo").is_none());
assert!(parse_polymarket_url("https://notpolymarket.com/event/foo").is_none());
}

#[test]
fn parse_url_rejects_missing_event_prefix() {
assert!(parse_polymarket_url("https://polymarket.com/markets/foo").is_none());
assert!(parse_polymarket_url("https://polymarket.com/foo").is_none());
}

#[test]
fn parse_url_rejects_empty_slug() {
assert!(parse_polymarket_url("https://polymarket.com/event/").is_none());
}

#[test]
fn parse_url_rejects_plain_slug() {
assert!(parse_polymarket_url("will-bitcoin-hit-100k").is_none());
}

#[test]
fn parse_url_rejects_numeric_id() {
assert!(parse_polymarket_url("12345").is_none());
}

#[test]
fn parse_url_rejects_no_path() {
assert!(parse_polymarket_url("https://polymarket.com").is_none());
assert!(parse_polymarket_url("polymarket.com").is_none());
}

// ── resolve_id ─────────────────────────────────────────────────

#[test]
fn resolve_id_numeric() {
assert_eq!(
resolve_id("12345", false),
ResolvedId::Numeric("12345".to_string())
);
assert_eq!(
resolve_id("12345", true),
ResolvedId::Numeric("12345".to_string())
);
}

#[test]
fn resolve_id_plain_slug() {
assert_eq!(
resolve_id("will-bitcoin-hit-100k", false),
ResolvedId::Slug("will-bitcoin-hit-100k".to_string())
);
}

#[test]
fn resolve_id_url_prefer_market_true() {
let url = "https://polymarket.com/event/my-event/my-market";
assert_eq!(
resolve_id(url, true),
ResolvedId::Slug("my-market".to_string())
);
}

#[test]
fn resolve_id_url_prefer_market_false() {
let url = "https://polymarket.com/event/my-event/my-market";
assert_eq!(
resolve_id(url, false),
ResolvedId::Slug("my-event".to_string())
);
}

#[test]
fn resolve_id_url_no_market_prefer_market_true() {
let url = "https://polymarket.com/event/my-event";
assert_eq!(
resolve_id(url, true),
ResolvedId::Slug("my-event".to_string())
);
}
}
Loading