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#[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#[derive(Debug)]
33pub(super) struct Inner {
34 id: Id,
36 base_denom: String,
37 description: String,
38 images: Vec<pb::AssetImage>,
41 badges: Vec<pb::AssetImage>,
42 priority_score: u64,
43
44 pub(super) units: Vec<BareDenomUnit>,
46 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 let id = Id::from_raw_denom(&base_denom);
88 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 let mut units = value
99 .denom_units
100 .into_iter()
101 .map(BareDenomUnit::try_from)
102 .collect::<Result<Vec<_>, _>>()?;
103
104 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#[derive(Clone)]
184pub struct Unit {
185 pub(super) inner: Arc<Inner>,
186 pub(super) unit_index: usize,
189}
190
191#[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 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 for unit in &units {
237 assert_ne!(unit.exponent, 0);
238 assert_ne!(&unit.denom, &base_denom);
239 }
240
241 units.push(BareDenomUnit {
244 exponent: 0,
245 denom: base_denom.clone(),
246 });
247
248 Self {
249 id,
250 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 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 pub fn value(&self, amount: Amount) -> Value {
281 Value {
282 amount,
283 asset_id: self.id(),
284 }
285 }
286
287 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 pub fn default_unit(&self) -> Unit {
301 Unit {
302 unit_index: self.inner.display_index,
303 inner: self.inner.clone(),
304 }
305 }
306
307 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 pub fn best_unit_for(&self, amount: Amount) -> Unit {
323 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 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 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 let v2_str = format!(
479 "{:0width$}",
480 u128::from(v2),
481 width = self.exponent() as usize
482 );
483
484 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 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 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 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 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 #[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 #[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 #[test]
634 fn cw20_filtered_out() {
635 let got = p("cw20:inj19vy83ne9tzta2yqynj8yg7dq9ghca6yqn9hyej");
636 assert_eq!(got, None);
637 }
638
639 #[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 #[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 }
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 let _domain_type = super::Metadata::try_from(proto).unwrap();
745 }
746}