token_list/
lib.rs

1#![forbid(unsafe_code)]
2#![warn(missing_docs)]
3
4//! Ethereum [token list](https://tokenlists.org/) standard
5//!
6//! # Examples
7//!
8//! ```no_run
9//! use token_list::TokenList;
10//!
11//! #[tokio::main]
12//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
13//!     // requires enabling the `from-uri` feature
14//!     let token_list = TokenList::from_uri("https://defi.cmc.eth.link").await?;
15//!     
16//!     assert_eq!(token_list.name, "CMC DeFi");
17//!     
18//!     Ok(())
19//! }
20//! ```
21
22use std::collections::HashMap;
23
24use chrono::{DateTime, FixedOffset};
25use semver::Version;
26use serde::{Deserialize, Serialize};
27use url::Url;
28
29/// A list of Ethereum token metadata conforming to the [token list schema].
30///
31/// [token list schema]: https://uniswap.org/tokenlist.schema.json
32#[derive(Serialize, Deserialize, PartialEq, Clone, Debug)]
33#[serde(rename_all = "camelCase")]
34pub struct TokenList {
35    /// The name of the token list
36    pub name: String,
37
38    /// The timestamp of this list version; i.e. when this immutable version of
39    /// the list was created
40    pub timestamp: DateTime<FixedOffset>,
41
42    /// The version of the list, used in change detection
43    #[serde(with = "version")]
44    pub version: Version,
45
46    /// A URI for the logo of the token list; prefer SVG or PNG of size 256x256
47    #[serde(rename = "logoURI", skip_serializing_if = "Option::is_none")]
48    pub logo_uri: Option<Url>,
49
50    /// Keywords associated with the contents of the list; may be used in list
51    /// discoverability
52    #[serde(default, skip_serializing_if = "Vec::is_empty")]
53    pub keywords: Vec<String>,
54
55    /// A mapping of tag identifiers to their name and description
56    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
57    pub tags: HashMap<String, Tag>,
58
59    /// The list of tokens included in the list
60    #[serde(default, skip_serializing_if = "Vec::is_empty")]
61    pub tokens: Vec<Token>,
62}
63
64impl TokenList {
65    /// Constructs a [`TokenList`] from the JSON contents of the specified URI.
66    ///
67    /// **Note**: This must be called from a running tokio >1.0.0 runtime.
68    #[cfg(feature = "from-uri")]
69    pub async fn from_uri<T: reqwest::IntoUrl>(uri: T) -> Result<Self, Error> {
70        Ok(reqwest::get(uri).await?.error_for_status()?.json().await?)
71    }
72
73    /// Constructs a [`TokenList`] from the JSON contents of the specified URI.
74    #[cfg(feature = "from-uri-blocking")]
75    pub fn from_uri_blocking<T: reqwest::IntoUrl>(uri: T) -> Result<Self, Error> {
76        Ok(reqwest::blocking::get(uri)?.error_for_status()?.json()?)
77    }
78
79    /// Constructs a [`TokenList`] from the JSON contents of the specified URI.
80    ///
81    /// **Note**: This must be called from a running tokio 0.1.x runtime.
82    #[cfg(feature = "from-uri-compat")]
83    pub async fn from_uri_compat<T: reqwest09::IntoUrl>(uri: T) -> Result<Self, Error> {
84        use futures::compat::Future01CompatExt;
85        use futures01::Future;
86        use reqwest09::r#async::{Client, Response};
87
88        let fut = Client::new()
89            .get(uri)
90            .send()
91            .and_then(Response::error_for_status)
92            .and_then(|mut res| res.json())
93            .compat();
94
95        Ok(fut.await?)
96    }
97}
98
99/// Metadata for a single token in a token list
100#[derive(Serialize, Deserialize, PartialEq, Clone, Debug)]
101#[serde(rename_all = "camelCase")]
102pub struct Token {
103    /// The name of the token
104    pub name: String,
105
106    /// The symbol for the token; must be alphanumeric.
107    pub symbol: String,
108
109    /// The checksummed address of the token on the specified chain ID
110    pub address: String,
111
112    /// The chain ID of the Ethereum network where this token is deployed
113    pub chain_id: u32,
114
115    /// The number of decimals for the token balance
116    pub decimals: u16,
117
118    /// A URI to the token logo asset; if not set, interface will attempt to
119    /// find a logo based on the token address; suggest SVG or PNG of size 64x64
120    #[serde(rename = "logoURI", skip_serializing_if = "Option::is_none")]
121    pub logo_uri: Option<Url>,
122
123    /// An array of tag identifiers associated with the token; tags are defined
124    /// at the list level
125    #[serde(default, skip_serializing_if = "Vec::is_empty")]
126    pub tags: Vec<String>,
127
128    /// An object containing any arbitrary or vendor-specific token metadata
129    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
130    pub extensions: HashMap<String, Option<ExtensionValue>>,
131}
132
133impl Token {
134    /// Gets the value of `polygonAddress` if present (and a `String`) in the
135    /// `extensions` map.
136    pub fn polygon_address(&self) -> Option<&str> {
137        self.extensions
138            .get("polygonAddress")
139            .and_then(|val| val.as_ref().and_then(|v| v.as_str()))
140    }
141}
142
143/// Definition of a tag that can be associated with a token via its identifier
144#[derive(Serialize, Deserialize, PartialEq, Clone, Debug)]
145#[serde(rename_all = "camelCase")]
146pub struct Tag {
147    /// The name of the tag
148    pub name: String,
149
150    /// A user-friendly description of the tag
151    pub description: String,
152}
153
154/// The value for a user-defined extension.
155#[derive(Serialize, Deserialize, PartialEq, Clone, Debug)]
156#[serde(untagged)]
157#[allow(missing_docs)]
158pub enum ExtensionValue {
159    String(String),
160    Number(Number),
161    Boolean(bool),
162}
163
164impl ExtensionValue {
165    /// If the `ExtensionValue` is a `String`, returns the associated `str`.
166    /// Returns `None` otherwise.
167    pub fn as_str(&self) -> Option<&str> {
168        match self {
169            ExtensionValue::String(val) => Some(val),
170            ExtensionValue::Number(_) => None,
171            ExtensionValue::Boolean(_) => None,
172        }
173    }
174
175    /// If the `ExtensionValue` is a `Boolean`, returns the associated `bool`.
176    /// Returns `None` otherwise.
177    pub fn as_bool(&self) -> Option<bool> {
178        match self {
179            ExtensionValue::String(_) => None,
180            ExtensionValue::Number(_) => None,
181            ExtensionValue::Boolean(val) => Some(*val),
182        }
183    }
184
185    /// If the `ExtensionValue` is a `Number` and an `i64`, returns the
186    /// associated `i64`. Returns `None` otherwise.
187    pub fn as_i64(&self) -> Option<i64> {
188        match self {
189            ExtensionValue::String(_) => None,
190            ExtensionValue::Number(val) => val.as_i64(),
191            ExtensionValue::Boolean(_) => None,
192        }
193    }
194
195    /// If the `ExtensionValue` is a `Number` and an `f64`, returns the
196    /// associated `f64`. Returns `None` otherwise.
197    pub fn as_f64(&self) -> Option<f64> {
198        match self {
199            ExtensionValue::String(_) => None,
200            ExtensionValue::Number(val) => val.as_f64(),
201            ExtensionValue::Boolean(_) => None,
202        }
203    }
204}
205
206/// A number
207#[derive(Serialize, Deserialize, PartialEq, Clone, Copy, Debug)]
208#[serde(untagged)]
209#[allow(missing_docs)]
210pub enum Number {
211    Integer(i64),
212    Float(f64),
213}
214
215impl Number {
216    /// If the `Number` is a `i64`, returns the associated `i64`. Returns `None`
217    /// otherwise.
218    pub fn as_i64(&self) -> Option<i64> {
219        match self {
220            Number::Integer(val) => Some(*val),
221            Number::Float(_) => None,
222        }
223    }
224
225    /// If the `Number` is a `f64`, returns the associated `f64`. Returns `None`
226    /// otherwise.
227    pub fn as_f64(&self) -> Option<f64> {
228        match self {
229            Number::Integer(_) => None,
230            Number::Float(val) => Some(*val),
231        }
232    }
233}
234
235/// Represents all errors that can occur when using this library.
236#[cfg(any(
237    feature = "from-uri",
238    feature = "from-uri-blocking",
239    feature = "from-uri-compat"
240))]
241#[derive(thiserror::Error, Debug)]
242pub enum Error {
243    /// HTTP/TCP etc. transport level error.
244    #[cfg(feature = "from-uri")]
245    #[error(transparent)]
246    Transport(#[from] reqwest::Error),
247
248    /// HTTP/TCP etc. transport level error.
249    #[cfg(feature = "from-uri-compat")]
250    #[error(transparent)]
251    TransportCompat(#[from] reqwest09::Error),
252}
253
254mod version {
255    use semver::Version;
256    use serde::{de, ser::SerializeStruct, Deserialize};
257
258    pub fn serialize<S>(value: &Version, serializer: S) -> Result<S::Ok, S::Error>
259    where
260        S: serde::Serializer,
261    {
262        let mut version = serializer.serialize_struct("Version", 3)?;
263        version.serialize_field("major", &value.major)?;
264        version.serialize_field("minor", &value.minor)?;
265        version.serialize_field("patch", &value.patch)?;
266        version.end()
267    }
268
269    pub fn deserialize<'de, D>(deserializer: D) -> Result<Version, D::Error>
270    where
271        D: de::Deserializer<'de>,
272    {
273        #[derive(Deserialize)]
274        struct InternalVersion {
275            major: u64,
276            minor: u64,
277            patch: u64,
278        }
279
280        InternalVersion::deserialize(deserializer).map(|v| Version::new(v.major, v.minor, v.patch))
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use chrono::TimeZone;
287    use serde_json::json;
288
289    use super::*;
290
291    const TELCOINS_TOKEN_LIST_URI: &str =
292        "https://raw.githubusercontent.com/telcoin/token-lists/e6a4cd7/telcoins.json";
293
294    #[cfg(feature = "from-uri")]
295    #[tokio::test]
296    async fn from_uri() {
297        let _token_list = TokenList::from_uri(TELCOINS_TOKEN_LIST_URI).await.unwrap();
298    }
299
300    #[cfg(feature = "from-uri-blocking")]
301    #[test]
302    fn from_uri_blocking() {
303        let _token_list = TokenList::from_uri_blocking(TELCOINS_TOKEN_LIST_URI).unwrap();
304    }
305
306    #[cfg(feature = "from-uri-compat")]
307    #[test]
308    fn from_uri_compat() {
309        use futures::future::{FutureExt, TryFutureExt};
310        use tokio01::runtime::Runtime;
311
312        let mut rt = Runtime::new().unwrap();
313
314        rt.block_on(
315            TokenList::from_uri_compat(TELCOINS_TOKEN_LIST_URI)
316                .boxed()
317                .compat(),
318        )
319        .unwrap();
320    }
321
322    #[test]
323    fn can_serialize_deserialize_required_fields() {
324        let data_json = json!({
325            "name": "TELcoins",
326            "timestamp": "2021-07-05T20:25:22+00:00",
327            "version": { "major": 0, "minor": 1, "patch": 0 },
328            "tokens": [
329                {
330                    "name": "Telcoin",
331                    "symbol": "TEL",
332                    "address": "0x467bccd9d29f223bce8043b84e8c8b282827790f",
333                    "chainId": 1,
334                    "decimals": 2
335                }
336            ]
337        });
338
339        let data_rs = TokenList {
340            name: "TELcoins".to_owned(),
341            timestamp: FixedOffset::west(0).ymd(2021, 7, 5).and_hms(20, 25, 22),
342            version: Version::new(0, 1, 0),
343            logo_uri: None,
344            keywords: vec![],
345            tags: HashMap::new(),
346            tokens: vec![Token {
347                name: "Telcoin".to_owned(),
348                symbol: "TEL".to_owned(),
349                address: "0x467bccd9d29f223bce8043b84e8c8b282827790f".to_owned(),
350                chain_id: 1,
351                decimals: 2,
352                logo_uri: None,
353                tags: vec![],
354                extensions: HashMap::new(),
355            }],
356        };
357
358        assert_eq!(serde_json::to_value(&data_rs).unwrap(), data_json);
359
360        let token_list: TokenList = serde_json::from_value(data_json).unwrap();
361
362        assert_eq!(token_list, data_rs);
363    }
364
365    #[test]
366    fn can_serialize_deserialize_all_fields() {
367        let data_json = json!({
368            "name": "TELcoins",
369            "timestamp": "2021-07-05T20:25:22+00:00",
370            "version": { "major": 0, "minor": 1, "patch": 0 },
371            "logoURI": "https://raw.githubusercontent.com/telcoin/token-lists/master/assets/logo-telcoin-250x250.png",
372            "keywords": ["defi", "telcoin"],
373            "tags": {
374                "telcoin": {
375                    "description": "Part of the Telcoin ecosystem.",
376                    "name": "telcoin"
377                }
378            },
379            "tokens": [
380                {
381                    "name": "Telcoin",
382                    "symbol": "TEL",
383                    "address": "0x467bccd9d29f223bce8043b84e8c8b282827790f",
384                    "chainId": 1,
385                    "decimals": 2,
386                    "logoURI": "https://raw.githubusercontent.com/telcoin/token-lists/master/assets/logo-telcoin-250x250.png",
387                    "tags": ["telcoin"],
388                    "extensions": {
389                        "is_mapped_to_polygon": true,
390                        "polygon_address": "0xdf7837de1f2fa4631d716cf2502f8b230f1dcc32",
391                        "polygon_chain_id": 137
392                    }
393                }
394            ]
395        });
396
397        let logo_uri: Url = "https://raw.githubusercontent.com/telcoin/token-lists/master/assets/logo-telcoin-250x250.png".parse().unwrap();
398        let data_rs = TokenList {
399            name: "TELcoins".to_owned(),
400            timestamp: FixedOffset::west(0).ymd(2021, 7, 5).and_hms(20, 25, 22),
401            version: Version::new(0, 1, 0),
402            logo_uri: Some(logo_uri.clone()),
403            keywords: vec!["defi".to_owned(), "telcoin".to_owned()],
404            tags: vec![(
405                "telcoin".to_owned(),
406                Tag {
407                    name: "telcoin".to_owned(),
408                    description: "Part of the Telcoin ecosystem.".to_owned(),
409                },
410            )]
411            .into_iter()
412            .collect(),
413            tokens: vec![Token {
414                name: "Telcoin".to_owned(),
415                symbol: "TEL".to_owned(),
416                address: "0x467bccd9d29f223bce8043b84e8c8b282827790f".to_owned(),
417                chain_id: 1,
418                decimals: 2,
419                logo_uri: Some(logo_uri),
420                tags: vec!["telcoin".to_owned()],
421                extensions: vec![
422                    (
423                        "is_mapped_to_polygon".to_owned(),
424                        Some(ExtensionValue::Boolean(true)),
425                    ),
426                    (
427                        "polygon_address".to_owned(),
428                        Some(ExtensionValue::String(
429                            "0xdf7837de1f2fa4631d716cf2502f8b230f1dcc32".to_owned(),
430                        )),
431                    ),
432                    (
433                        "polygon_chain_id".to_owned(),
434                        Some(ExtensionValue::Number(Number::Integer(137))),
435                    ),
436                ]
437                .into_iter()
438                .collect(),
439            }],
440        };
441
442        assert_eq!(serde_json::to_value(&data_rs).unwrap(), data_json,);
443
444        let token_list: TokenList = serde_json::from_value(data_json).unwrap();
445
446        assert_eq!(token_list, data_rs);
447    }
448}