penumbra_sdk_asset/asset/
denom_metadata.rs

1use std::{
2    cmp::{Eq, PartialEq},
3    fmt::{Debug, Display},
4    hash::{self, Hash},
5    sync::Arc,
6};
7
8use anyhow::{ensure, Context};
9use decaf377::Fq;
10use penumbra_sdk_num::Amount;
11use penumbra_sdk_proto::{penumbra::core::asset::v1 as pb, view::v1::AssetsResponse, DomainType};
12use regex::Regex;
13use serde::{Deserialize, Serialize};
14
15use crate::{
16    asset::{Id, REGISTRY},
17    Value,
18};
19
20use super::Denom;
21
22/// An asset denomination's metadata.
23///
24/// Each denomination has a unique [`Id`] and base unit, and may also
25/// have other display units.
26#[derive(Serialize, Deserialize, Clone)]
27#[serde(try_from = "pb::Metadata", into = "pb::Metadata")]
28pub struct Metadata {
29    pub(super) inner: Arc<Inner>,
30}
31
32// These are constructed by the asset registry.
33#[derive(Debug)]
34pub(super) struct Inner {
35    // The Penumbra asset ID
36    id: Id,
37    base_denom: String,
38    description: String,
39    // For now, don't bother with a domain type here,
40    // since we don't render images from Rust code.
41    images: Vec<pb::AssetImage>,
42    badges: Vec<pb::AssetImage>,
43    priority_score: u64,
44
45    /// Sorted by priority order.
46    pub(super) units: Vec<BareDenomUnit>,
47    //display: String,
48    // Indexes into the units array.
49    display_index: usize,
50    name: String,
51    symbol: String,
52    coingecko_id: String,
53}
54
55impl DomainType for Metadata {
56    type Proto = pb::Metadata;
57}
58
59impl From<&Inner> for pb::Metadata {
60    fn from(inner: &Inner) -> Self {
61        pb::Metadata {
62            description: inner.description.clone(),
63            base: inner.base_denom.clone(),
64            display: inner.units[inner.display_index].denom.clone(),
65            name: inner.name.clone(),
66            symbol: inner.symbol.clone(),
67            penumbra_asset_id: Some(inner.id.into()),
68            denom_units: inner.units.clone().into_iter().map(|x| x.into()).collect(),
69            images: inner.images.clone(),
70            badges: inner.badges.clone(),
71            priority_score: inner.priority_score,
72            coingecko_id: inner.coingecko_id.clone(),
73        }
74    }
75}
76
77impl TryFrom<pb::Metadata> for Inner {
78    type Error = anyhow::Error;
79
80    fn try_from(value: pb::Metadata) -> Result<Self, Self::Error> {
81        let base_denom = value.base;
82        ensure!(
83            !base_denom.is_empty(),
84            "denom metadata must have a base denom"
85        );
86
87        // Compute the ID from the base denom to ensure we don't get confused.
88        let id = Id::from_raw_denom(&base_denom);
89        // If the ID was supplied, we should check that it's consistent with the base denom.
90        if let Some(supplied_id) = value.penumbra_asset_id {
91            let supplied_id = Id::try_from(supplied_id)?;
92            ensure!(
93                id == supplied_id,
94                "denom metadata has mismatched penumbra asset ID"
95            );
96        }
97
98        // Parse the list of units, which may be empty.
99        let mut units = value
100            .denom_units
101            .into_iter()
102            .map(BareDenomUnit::try_from)
103            .collect::<Result<Vec<_>, _>>()?;
104
105        // Ensure that the base denom is present in the unit list.
106        // TODO: should we require it to be first?
107        if !units.iter().any(|unit| unit.denom == base_denom) {
108            units.push(BareDenomUnit {
109                denom: base_denom.clone(),
110                exponent: 0,
111            });
112        }
113
114        let display_index = if !value.display.is_empty() {
115            units
116                .iter()
117                .position(|unit| unit.denom == value.display)
118                .ok_or_else(|| {
119                    anyhow::anyhow!(
120                        "display denom {} not found in units {:?}",
121                        value.display,
122                        units
123                    )
124                })?
125        } else {
126            0
127        };
128
129        Ok(Inner {
130            id,
131            base_denom,
132            units,
133            display_index,
134            description: value.description,
135            name: value.name,
136            symbol: value.symbol,
137            images: value.images,
138            badges: value.badges,
139            priority_score: value.priority_score,
140            coingecko_id: value.coingecko_id,
141        })
142    }
143}
144
145impl From<Metadata> for pb::Metadata {
146    fn from(dn: Metadata) -> Self {
147        dn.inner.as_ref().into()
148    }
149}
150
151impl TryFrom<pb::Metadata> for Metadata {
152    type Error = anyhow::Error;
153
154    fn try_from(value: pb::Metadata) -> Result<Self, Self::Error> {
155        let inner = Inner::try_from(value)?;
156        Ok(Metadata {
157            inner: Arc::new(inner),
158        })
159    }
160}
161
162impl TryFrom<&str> for Metadata {
163    type Error = anyhow::Error;
164
165    fn try_from(value: &str) -> Result<Self, Self::Error> {
166        REGISTRY
167            .parse_denom(value)
168            .ok_or_else(|| anyhow::anyhow!("invalid denomination {}", value))
169    }
170}
171
172impl TryFrom<AssetsResponse> for Metadata {
173    type Error = anyhow::Error;
174
175    fn try_from(response: AssetsResponse) -> Result<Self, Self::Error> {
176        response
177            .denom_metadata
178            .ok_or_else(|| anyhow::anyhow!("empty AssetsResponse message"))?
179            .try_into()
180    }
181}
182
183/// A unit of some asset denomination.
184#[derive(Clone)]
185pub struct Unit {
186    pub(super) inner: Arc<Inner>,
187    // Indexes into the `units` field on `Inner`.
188    // The units field is always sorted by priority order.
189    pub(super) unit_index: usize,
190}
191
192/// Corresponds to the protobuf `DenomUnit` message.
193///
194/// This is a purely internal type, however, because it doesn't "link back" to
195/// the parent denom, and so instead we expose a nicer `Unit` type.
196#[derive(Clone, Debug)]
197pub(super) struct BareDenomUnit {
198    pub exponent: u8,
199    pub denom: String,
200}
201
202impl TryFrom<pb::DenomUnit> for BareDenomUnit {
203    type Error = anyhow::Error;
204
205    fn try_from(value: pb::DenomUnit) -> Result<Self, Self::Error> {
206        Ok(BareDenomUnit {
207            exponent: value.exponent as u8,
208            denom: value.denom,
209        })
210    }
211}
212impl From<BareDenomUnit> for pb::DenomUnit {
213    fn from(dn: BareDenomUnit) -> Self {
214        pb::DenomUnit {
215            denom: dn.denom,
216            exponent: dn.exponent as u32,
217            aliases: Vec::new(),
218        }
219    }
220}
221
222impl Inner {
223    /// Constructs the backing data for a set of units.
224    ///
225    /// The base denom is added as a unit, so `units` can be empty and should
226    /// not include a unit for the base denomination.
227    pub fn new(base_denom: String, mut units: Vec<BareDenomUnit>) -> Self {
228        let id = Id(Fq::from_le_bytes_mod_order(
229            // XXX choice of hash function?
230            blake2b_simd::Params::default()
231                .personal(b"Penumbra_AssetID")
232                .hash(base_denom.as_bytes())
233                .as_bytes(),
234        ));
235
236        // Perform validity check for each unit.
237
238        for unit in &units {
239            assert_ne!(unit.exponent, 0);
240            assert_ne!(&unit.denom, &base_denom);
241        }
242
243        // Add the base denom as a unit.
244
245        units.push(BareDenomUnit {
246            exponent: 0,
247            denom: base_denom.clone(),
248        });
249
250        Self {
251            id,
252            // TODO: in our legacy registry code we set the preferred unit first
253            // but elsewhere in the ecosystem the order doesn't matter or is opposite
254            //display_index: units.len() - 1,
255            display_index: 0,
256            units,
257            base_denom,
258            description: String::new(),
259            name: String::new(),
260            symbol: String::new(),
261            images: Vec::new(),
262            badges: Vec::new(),
263            priority_score: 0,
264            coingecko_id: String::new(),
265        }
266    }
267}
268
269impl Metadata {
270    /// Return the [`Id`] associated with this denomination.
271    pub fn id(&self) -> Id {
272        self.inner.id
273    }
274
275    pub fn base_denom(&self) -> Denom {
276        Denom {
277            denom: self.inner.base_denom.clone(),
278        }
279    }
280
281    /// Create a value of this denomination.
282    pub fn value(&self, amount: Amount) -> Value {
283        Value {
284            amount,
285            asset_id: self.id(),
286        }
287    }
288
289    /// Return a list of display units for this denomination, in size order.
290    ///
291    /// There will always be at least one display denomination.
292    pub fn units(&self) -> Vec<Unit> {
293        (0..self.inner.units.len())
294            .map(|unit_index| Unit {
295                unit_index,
296                inner: self.inner.clone(),
297            })
298            .collect()
299    }
300
301    /// Returns the default (largest) unit for this denomination.
302    pub fn default_unit(&self) -> Unit {
303        Unit {
304            unit_index: self.inner.display_index,
305            inner: self.inner.clone(),
306        }
307    }
308
309    /// Returns the base (smallest) unit for this denomination.
310    ///
311    /// (This treats the base denomination as a display unit).
312    pub fn base_unit(&self) -> Unit {
313        Unit {
314            unit_index: self.inner.units.len() - 1,
315            inner: self.inner.clone(),
316        }
317    }
318
319    /// Returns the "best" unit for the given amount (expressed in units of the
320    /// base denomination).
321    ///
322    /// This is defined as the largest unit smaller than the given value (so it
323    /// has no leading zeros when formatted).
324    pub fn best_unit_for(&self, amount: Amount) -> Unit {
325        // Special case: use the default unit for 0
326        if amount == 0u64.into() {
327            return self.default_unit();
328        }
329        let mut selected_index = 0;
330        let mut selected_exponent = 0;
331        for (unit_index, unit) in self.inner.units.iter().enumerate() {
332            let unit_amount = Amount::from(10u128.pow(unit.exponent as u32));
333            if unit_amount <= amount && unit.exponent >= selected_exponent {
334                selected_index = unit_index;
335                selected_exponent = unit.exponent;
336            }
337        }
338        return Unit {
339            unit_index: selected_index,
340            inner: self.inner.clone(),
341        };
342    }
343
344    pub fn starts_with(&self, prefix: &str) -> bool {
345        self.inner.base_denom.starts_with(prefix)
346    }
347
348    pub fn default_for(denom: &Denom) -> Option<Metadata> {
349        REGISTRY.parse_denom(&denom.denom)
350    }
351
352    pub fn is_auction_nft(&self) -> bool {
353        self.starts_with("auctionnft_")
354    }
355
356    pub fn is_withdrawn_auction_nft(&self) -> bool {
357        self.starts_with("auctionnft_2")
358    }
359
360    pub fn is_opened_position_nft(&self) -> bool {
361        let prefix = "lpnft_opened_".to_string();
362
363        self.starts_with(&prefix)
364    }
365
366    pub fn is_withdrawn_position_nft(&self) -> bool {
367        let prefix = "lpnft_withdrawn_".to_string();
368
369        self.starts_with(&prefix)
370    }
371
372    pub fn is_closed_position_nft(&self) -> bool {
373        let prefix = "lpnft_closed_".to_string();
374
375        self.starts_with(&prefix)
376    }
377
378    /// Returns the IBC transfer path and base denom
379    /// if this is an IBC transferred asset, `None` otherwise.
380    pub fn ibc_transfer_path(&self) -> anyhow::Result<Option<(String, String)>> {
381        let base_denom = self.base_denom().denom;
382        // The base denom portion of an IBC asset path may contain slashes: https://github.com/cosmos/ibc/issues/737
383        let re = Regex::new(r"^(?<path>transfer/channel-[0-9]+)/(?<denom>[\w\/]+)$")
384            .context("error instantiating denom matching regex")?;
385
386        let Some(caps) = re.captures(&base_denom) else {
387            // Not an IBC asset
388            return Ok(None);
389        };
390
391        Ok(Some((caps["path"].to_string(), caps["denom"].to_string())))
392    }
393}
394
395impl From<Metadata> for Id {
396    fn from(base: Metadata) -> Id {
397        base.id()
398    }
399}
400
401impl Hash for Metadata {
402    fn hash<H: hash::Hasher>(&self, state: &mut H) {
403        self.inner.base_denom.hash(state);
404    }
405}
406
407impl PartialEq for Metadata {
408    fn eq(&self, other: &Self) -> bool {
409        self.inner.base_denom.eq(&other.inner.base_denom)
410    }
411}
412
413impl Eq for Metadata {}
414
415impl PartialOrd for Metadata {
416    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
417        Some(self.cmp(other))
418    }
419}
420
421impl Ord for Metadata {
422    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
423        self.inner.base_denom.cmp(&other.inner.base_denom)
424    }
425}
426
427impl Debug for Metadata {
428    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
429        let Self { inner } = self;
430        let Inner {
431            id,
432            base_denom,
433            description,
434            images,
435            units,
436            display_index,
437            name,
438            symbol,
439            priority_score,
440            badges,
441            coingecko_id,
442        } = inner.as_ref();
443
444        f.debug_struct("Metadata")
445            .field("id", id)
446            .field("base_denom", base_denom)
447            .field("description", description)
448            .field("images", images)
449            .field("badges", badges)
450            .field("priority_score", priority_score)
451            .field("units", units)
452            .field("display_index", display_index)
453            .field("name", name)
454            .field("symbol", symbol)
455            .field("coingecko_id", coingecko_id)
456            .finish()
457    }
458}
459
460impl Display for Metadata {
461    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
462        f.write_str(self.inner.base_denom.as_str())
463    }
464}
465
466impl Unit {
467    pub fn base(&self) -> Metadata {
468        Metadata {
469            inner: self.inner.clone(),
470        }
471    }
472
473    /// Return the [`Id`] associated with this denomination.
474    pub fn id(&self) -> Id {
475        self.inner.id
476    }
477
478    pub fn format_value(&self, value: Amount) -> String {
479        let power_of_ten = Amount::from(10u128.pow(self.exponent().into()));
480        let v1 = value / power_of_ten;
481        let v2 = value % power_of_ten;
482
483        // Pad `v2` to exponent digits.
484        let v2_str = format!(
485            "{:0width$}",
486            u128::from(v2),
487            width = self.exponent() as usize
488        );
489
490        // For `v2`, there may be trailing zeros that should be stripped
491        // since they are after the decimal point.
492        let v2_stripped = v2_str.trim_end_matches('0');
493
494        if v2 != Amount::zero() {
495            format!("{v1}.{v2_stripped}")
496        } else {
497            format!("{v1}")
498        }
499    }
500
501    pub fn parse_value(&self, value: &str) -> anyhow::Result<Amount> {
502        let split: Vec<&str> = value.split('.').collect();
503        if split.len() > 2 {
504            anyhow::bail!("expected only one decimal point")
505        } else {
506            let left = split[0];
507
508            // The decimal point and right hand side is optional. If it's not present, we use "0"
509            // such that the rest of the logic is the same.
510            let right = if split.len() > 1 { split[1] } else { "0" };
511
512            let v1 = left.parse::<u128>().map_err(|e| anyhow::anyhow!(e))?;
513            let mut v2 = right.parse::<u128>().map_err(|e| anyhow::anyhow!(e))?;
514            let v1_power_of_ten = 10u128.pow(self.exponent().into());
515
516            if right.len() == (self.exponent() + 1) as usize && v2 == 0 {
517                // This stanza means that the value is the base unit. Simply return v1.
518                return Ok(v1.into());
519            } else if right.len() > self.exponent().into() {
520                anyhow::bail!("cannot represent this value");
521            }
522
523            let v2_power_of_ten = 10u128.pow((self.exponent() - right.len() as u8).into());
524            v2 = v2
525                .checked_mul(v2_power_of_ten)
526                .context("multiplication overflowed when applying right hand side exponent")?;
527
528            let v = v1
529                .checked_mul(v1_power_of_ten)
530                .and_then(|x| x.checked_add(v2));
531
532            if let Some(value) = v {
533                Ok(value.into())
534            } else {
535                anyhow::bail!("overflow!")
536            }
537        }
538    }
539
540    pub fn exponent(&self) -> u8 {
541        self.inner
542            .units
543            .get(self.unit_index)
544            .expect("there must be an entry for unit_index")
545            .exponent
546    }
547
548    pub fn unit_amount(&self) -> Amount {
549        10u128.pow(self.exponent().into()).into()
550    }
551
552    /// Create a value of this unit, applying the correct exponent.
553    pub fn value(&self, amount: Amount) -> Value {
554        Value {
555            asset_id: self.id(),
556            amount: amount * self.unit_amount(),
557        }
558    }
559}
560
561impl Hash for Unit {
562    fn hash<H: hash::Hasher>(&self, state: &mut H) {
563        self.inner.base_denom.hash(state);
564        self.unit_index.hash(state);
565    }
566}
567
568impl PartialEq for Unit {
569    fn eq(&self, other: &Self) -> bool {
570        self.inner.base_denom.eq(&other.inner.base_denom) && self.unit_index.eq(&other.unit_index)
571    }
572}
573
574impl Eq for Unit {}
575
576impl Debug for Unit {
577    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
578        f.write_str(self.inner.units[self.unit_index].denom.as_str())
579    }
580}
581
582impl Display for Unit {
583    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
584        f.write_str(self.inner.units[self.unit_index].denom.as_str())
585    }
586}
587
588#[cfg(test)]
589mod tests {
590    use std::sync::Arc;
591
592    #[test]
593    fn can_parse_metadata_from_chain_registry() {
594        const SOME_COSMOS_JSON: &str = r#"
595        {
596            "description": "The native staking token of dYdX Protocol.",
597            "denom_units": [
598              {
599                "denom": "adydx",
600                "exponent": 0
601              },
602              {
603                "denom": "dydx",
604                "exponent": 18
605              }
606            ],
607            "base": "adydx",
608            "name": "dYdX",
609            "display": "dydx",
610            "symbol": "DYDX",
611            "logo_URIs": {
612              "png": "https://raw.githubusercontent.com/cosmos/chain-registry/master/dydx/images/dydx.png",
613              "svg": "https://raw.githubusercontent.com/cosmos/chain-registry/master/dydx/images/dydx.svg"
614            },
615            "coingecko_id": "dydx",
616            "images": [
617              {
618                "png": "https://raw.githubusercontent.com/cosmos/chain-registry/master/dydx/images/dydx.png",
619                "svg": "https://raw.githubusercontent.com/cosmos/chain-registry/master/dydx/images/dydx.svg"
620              },
621              {
622                "svg": "https://raw.githubusercontent.com/cosmos/chain-registry/master/dydx/images/dydx-circle.svg",
623                "theme": {
624                  "circle": true
625                }
626              }
627            ]
628          }
629        "#;
630
631        let _metadata: super::Metadata = serde_json::from_str(SOME_COSMOS_JSON).unwrap();
632
633        // uncomment to see what our subset looks like
634        //let json2 = serde_json::to_string_pretty(&_metadata).unwrap();
635        //println!("{}", json2);
636    }
637
638    #[test]
639    fn encoding_round_trip_succeeds() {
640        let metadata = super::Metadata::try_from("upenumbra").unwrap();
641
642        let proto = super::pb::Metadata::from(metadata.clone());
643
644        let metadata_2 = super::Metadata::try_from(proto).unwrap();
645
646        assert_eq!(metadata, metadata_2);
647    }
648
649    #[test]
650    #[should_panic]
651    fn changing_asset_id_without_changing_denom_fails_decoding() {
652        let mut metadata = super::Metadata::try_from("upenumbra").unwrap();
653
654        let inner = Arc::get_mut(&mut metadata.inner).unwrap();
655
656        inner.id = super::Id::from_raw_denom("uusd");
657
658        let proto = super::pb::Metadata::from(metadata);
659
660        // This should throw an error, because the asset ID and denom are now inconsistent.
661
662        let _domain_type = super::Metadata::try_from(proto).unwrap();
663    }
664}