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