Skip to main content

polyoxide_data/api/
holders.rs

1use polyoxide_core::{HttpClient, QueryBuilder, Request};
2use serde::{Deserialize, Serialize};
3
4use crate::error::DataApiError;
5
6/// Holders namespace for holder-related operations
7#[derive(Clone)]
8pub struct Holders {
9    pub(crate) http_client: HttpClient,
10}
11
12impl Holders {
13    /// Get top holders for markets
14    pub fn list(&self, markets: impl IntoIterator<Item = impl ToString>) -> ListHolders {
15        let market_ids: Vec<String> = markets.into_iter().map(|s| s.to_string()).collect();
16        let mut request = Request::new(self.http_client.clone(), "/holders");
17        if !market_ids.is_empty() {
18            request = request.query("market", market_ids.join(","));
19        }
20
21        ListHolders { request }
22    }
23}
24
25/// Request builder for getting top holders
26pub struct ListHolders {
27    request: Request<Vec<MarketHolders>, DataApiError>,
28}
29
30impl ListHolders {
31    /// Set maximum number of results per market (0-500, default: 20).
32    ///
33    /// Verified live on 2026-07-25: `limit=500` succeeds, `limit=501` returns
34    /// HTTP 400 `{"error":"max holders limit of 500 exceeded"}`, and omitting
35    /// the parameter yields 20 rows. The value is not range-checked here —
36    /// out-of-range values reach the venue and 400.
37    pub fn limit(mut self, limit: u32) -> Self {
38        self.request = self.request.query("limit", limit);
39        self
40    }
41
42    /// Set minimum balance filter (0-999999, default: 1)
43    pub fn min_balance(mut self, min_balance: u32) -> Self {
44        self.request = self.request.query("minBalance", min_balance);
45        self
46    }
47
48    /// Execute the request
49    pub async fn send(self) -> Result<Vec<MarketHolders>, DataApiError> {
50        self.request.send().await
51    }
52}
53
54/// Market holders response containing token and its holders
55#[derive(Debug, Clone, Serialize, Deserialize)]
56#[serde(rename_all(deserialize = "camelCase"))]
57pub struct MarketHolders {
58    /// Token identifier
59    pub token: String,
60    /// List of holders for this token
61    pub holders: Vec<Holder>,
62}
63
64/// Individual holder of a market token
65#[derive(Debug, Clone, Serialize, Deserialize)]
66#[serde(rename_all(deserialize = "camelCase"))]
67pub struct Holder {
68    /// Proxy wallet address
69    pub proxy_wallet: String,
70    /// User bio
71    pub bio: Option<String>,
72    /// Asset identifier (token ID)
73    pub asset: Option<String>,
74    /// User pseudonym
75    pub pseudonym: Option<String>,
76    /// Amount held
77    pub amount: f64,
78    /// Whether username is displayed publicly
79    pub display_username_public: Option<bool>,
80    /// Outcome index (0 or 1 for binary markets)
81    pub outcome_index: u32,
82    /// User display name
83    pub name: Option<String>,
84    /// User profile image URL
85    pub profile_image: Option<String>,
86    /// Optimized profile image URL
87    pub profile_image_optimized: Option<String>,
88    /// Whether the user is verified
89    #[serde(default)]
90    pub verified: Option<bool>,
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    #[test]
98    fn deserialize_market_holders() {
99        let json = r#"{
100            "token": "token_abc",
101            "holders": [
102                {
103                    "proxyWallet": "0xholder1",
104                    "bio": "Top trader",
105                    "asset": "token_abc",
106                    "pseudonym": "whale1",
107                    "amount": 50000.0,
108                    "displayUsernamePublic": true,
109                    "outcomeIndex": 0,
110                    "name": "Holder One",
111                    "profileImage": "https://example.com/img.png",
112                    "profileImageOptimized": "https://example.com/img_opt.png",
113                    "verified": true
114                },
115                {
116                    "proxyWallet": "0xholder2",
117                    "bio": null,
118                    "asset": null,
119                    "pseudonym": null,
120                    "amount": 1000.0,
121                    "displayUsernamePublic": null,
122                    "outcomeIndex": 1,
123                    "name": null,
124                    "profileImage": null,
125                    "profileImageOptimized": null,
126                    "verified": false
127                }
128            ]
129        }"#;
130
131        let mh: MarketHolders = serde_json::from_str(json).unwrap();
132        assert_eq!(mh.token, "token_abc");
133        assert_eq!(mh.holders.len(), 2);
134
135        let h1 = &mh.holders[0];
136        assert_eq!(h1.proxy_wallet, "0xholder1");
137        assert_eq!(h1.bio, Some("Top trader".to_string()));
138        assert!((h1.amount - 50000.0).abs() < f64::EPSILON);
139        assert_eq!(h1.outcome_index, 0);
140        assert_eq!(h1.display_username_public, Some(true));
141        assert_eq!(h1.name, Some("Holder One".to_string()));
142        assert_eq!(h1.verified, Some(true));
143
144        let h2 = &mh.holders[1];
145        assert_eq!(h2.proxy_wallet, "0xholder2");
146        assert!(h2.bio.is_none());
147        assert!(h2.asset.is_none());
148        assert!(h2.pseudonym.is_none());
149        assert!((h2.amount - 1000.0).abs() < f64::EPSILON);
150        assert_eq!(h2.outcome_index, 1);
151        assert!(h2.name.is_none());
152        assert_eq!(h2.verified, Some(false));
153    }
154
155    #[test]
156    fn deserialize_empty_holders_list() {
157        let json = r#"{"token": "empty_token", "holders": []}"#;
158        let mh: MarketHolders = serde_json::from_str(json).unwrap();
159        assert_eq!(mh.token, "empty_token");
160        assert!(mh.holders.is_empty());
161    }
162
163    #[test]
164    fn holder_without_verified_field() {
165        let json = r#"{
166            "proxyWallet": "0xholder",
167            "amount": 100.0,
168            "outcomeIndex": 0
169        }"#;
170        let h: Holder = serde_json::from_str(json).unwrap();
171        assert_eq!(h.proxy_wallet, "0xholder");
172        assert!(h.verified.is_none());
173    }
174}