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