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 symbol(&self) -> &str {
280 &self.inner.symbol
281 }
282
283 pub fn value(&self, amount: Amount) -> Value {
285 Value {
286 amount,
287 asset_id: self.id(),
288 }
289 }
290
291 pub fn units(&self) -> Vec<Unit> {
295 (0..self.inner.units.len())
296 .map(|unit_index| Unit {
297 unit_index,
298 inner: self.inner.clone(),
299 })
300 .collect()
301 }
302
303 pub fn default_unit(&self) -> Unit {
305 Unit {
306 unit_index: self.inner.display_index,
307 inner: self.inner.clone(),
308 }
309 }
310
311 pub fn base_unit(&self) -> Unit {
315 Unit {
316 unit_index: self.inner.units.len() - 1,
317 inner: self.inner.clone(),
318 }
319 }
320
321 pub fn best_unit_for(&self, amount: Amount) -> Unit {
327 if amount == 0u64.into() {
329 return self.default_unit();
330 }
331 let mut selected_index = 0;
332 let mut selected_exponent = 0;
333 for (unit_index, unit) in self.inner.units.iter().enumerate() {
334 let unit_amount = Amount::from(10u128.pow(unit.exponent as u32));
335 if unit_amount <= amount && unit.exponent >= selected_exponent {
336 selected_index = unit_index;
337 selected_exponent = unit.exponent;
338 }
339 }
340 return Unit {
341 unit_index: selected_index,
342 inner: self.inner.clone(),
343 };
344 }
345
346 pub fn starts_with(&self, prefix: &str) -> bool {
347 self.inner.base_denom.starts_with(prefix)
348 }
349
350 pub fn default_for(denom: &Denom) -> Option<Metadata> {
351 REGISTRY.parse_denom(&denom.denom)
352 }
353
354 pub fn is_auction_nft(&self) -> bool {
355 self.starts_with("auctionnft_")
356 }
357
358 pub fn is_withdrawn_auction_nft(&self) -> bool {
359 self.starts_with("auctionnft_2")
360 }
361
362 pub fn is_opened_position_nft(&self) -> bool {
363 let prefix = "lpnft_opened_".to_string();
364
365 self.starts_with(&prefix)
366 }
367
368 pub fn is_withdrawn_position_nft(&self) -> bool {
369 let prefix = "lpnft_withdrawn_".to_string();
370
371 self.starts_with(&prefix)
372 }
373
374 pub fn is_closed_position_nft(&self) -> bool {
375 let prefix = "lpnft_closed_".to_string();
376
377 self.starts_with(&prefix)
378 }
379
380 pub fn best_effort_ibc_transfer_parse(&self) -> Option<(String, String)> {
388 let base_denom = self.base_denom().denom;
389 parse::ibc_transfer_path(&base_denom)
390 }
391}
392
393impl From<Metadata> for Id {
394 fn from(base: Metadata) -> Id {
395 base.id()
396 }
397}
398
399impl Hash for Metadata {
400 fn hash<H: hash::Hasher>(&self, state: &mut H) {
401 self.inner.base_denom.hash(state);
402 }
403}
404
405impl PartialEq for Metadata {
406 fn eq(&self, other: &Self) -> bool {
407 self.inner.base_denom.eq(&other.inner.base_denom)
408 }
409}
410
411impl Eq for Metadata {}
412
413impl PartialOrd for Metadata {
414 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
415 Some(self.cmp(other))
416 }
417}
418
419impl Ord for Metadata {
420 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
421 self.inner.base_denom.cmp(&other.inner.base_denom)
422 }
423}
424
425impl Debug for Metadata {
426 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
427 let Self { inner } = self;
428 let Inner {
429 id,
430 base_denom,
431 description,
432 images,
433 units,
434 display_index,
435 name,
436 symbol,
437 priority_score,
438 badges,
439 coingecko_id,
440 } = inner.as_ref();
441
442 f.debug_struct("Metadata")
443 .field("id", id)
444 .field("base_denom", base_denom)
445 .field("description", description)
446 .field("images", images)
447 .field("badges", badges)
448 .field("priority_score", priority_score)
449 .field("units", units)
450 .field("display_index", display_index)
451 .field("name", name)
452 .field("symbol", symbol)
453 .field("coingecko_id", coingecko_id)
454 .finish()
455 }
456}
457
458impl Display for Metadata {
459 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
460 f.write_str(self.inner.base_denom.as_str())
461 }
462}
463
464impl Unit {
465 pub fn base(&self) -> Metadata {
466 Metadata {
467 inner: self.inner.clone(),
468 }
469 }
470
471 pub fn id(&self) -> Id {
473 self.inner.id
474 }
475
476 pub fn format_value(&self, value: Amount) -> String {
477 let power_of_ten = Amount::from(10u128.pow(self.exponent().into()));
478 let v1 = value / power_of_ten;
479 let v2 = value % power_of_ten;
480
481 let v2_str = format!(
483 "{:0width$}",
484 u128::from(v2),
485 width = self.exponent() as usize
486 );
487
488 let v2_stripped = v2_str.trim_end_matches('0');
491
492 if v2 != Amount::zero() {
493 format!("{v1}.{v2_stripped}")
494 } else {
495 format!("{v1}")
496 }
497 }
498
499 pub fn parse_value(&self, value: &str) -> anyhow::Result<Amount> {
500 let split: Vec<&str> = value.split('.').collect();
501 if split.len() > 2 {
502 anyhow::bail!("expected only one decimal point")
503 } else {
504 let left = split[0];
505
506 let right = if split.len() > 1 { split[1] } else { "0" };
509
510 let v1 = left.parse::<u128>().map_err(|e| anyhow::anyhow!(e))?;
511 let mut v2 = right.parse::<u128>().map_err(|e| anyhow::anyhow!(e))?;
512 let v1_power_of_ten = 10u128.pow(self.exponent().into());
513
514 if right.len() == (self.exponent() + 1) as usize && v2 == 0 {
515 return Ok(v1.into());
517 } else if right.len() > self.exponent().into() {
518 anyhow::bail!("cannot represent this value");
519 }
520
521 let v2_power_of_ten = 10u128.pow((self.exponent() - right.len() as u8).into());
522 v2 = v2
523 .checked_mul(v2_power_of_ten)
524 .context("multiplication overflowed when applying right hand side exponent")?;
525
526 let v = v1
527 .checked_mul(v1_power_of_ten)
528 .and_then(|x| x.checked_add(v2));
529
530 if let Some(value) = v {
531 Ok(value.into())
532 } else {
533 anyhow::bail!("overflow!")
534 }
535 }
536 }
537
538 pub fn exponent(&self) -> u8 {
539 self.inner
540 .units
541 .get(self.unit_index)
542 .expect("there must be an entry for unit_index")
543 .exponent
544 }
545
546 pub fn unit_amount(&self) -> Amount {
547 10u128.pow(self.exponent().into()).into()
548 }
549
550 pub fn value(&self, amount: Amount) -> Value {
552 Value {
553 asset_id: self.id(),
554 amount: amount * self.unit_amount(),
555 }
556 }
557}
558
559impl Hash for Unit {
560 fn hash<H: hash::Hasher>(&self, state: &mut H) {
561 self.inner.base_denom.hash(state);
562 self.unit_index.hash(state);
563 }
564}
565
566impl PartialEq for Unit {
567 fn eq(&self, other: &Self) -> bool {
568 self.inner.base_denom.eq(&other.inner.base_denom) && self.unit_index.eq(&other.unit_index)
569 }
570}
571
572impl Eq for Unit {}
573
574impl Debug for Unit {
575 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
576 f.write_str(self.inner.units[self.unit_index].denom.as_str())
577 }
578}
579
580impl Display for Unit {
581 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
582 f.write_str(self.inner.units[self.unit_index].denom.as_str())
583 }
584}
585
586pub mod parse {
587 use lazy_static::lazy_static;
588
589 lazy_static! {
590 static ref IBC_RE: regex::Regex = regex::Regex::new(
591 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/._\-]*)$"
592 ).expect("regex compilation works");
593 }
594
595 pub fn ibc_transfer_path(base: &str) -> Option<(String, String)> {
596 let caps = IBC_RE.captures(base)?;
602 Some((caps["path"].to_owned(), caps["denom"].to_owned()))
603 }
604}
605
606#[cfg(test)]
607mod ibc_transfer_path_tests {
608 use crate::asset::denom_metadata::parse::ibc_transfer_path as p;
609
610 #[test]
613 fn single_hop_uusdc() {
614 let got = p("transfer/channel-2/uusdc");
615 assert_eq!(
616 got,
617 Some(("transfer/channel-2".to_string(), "uusdc".to_string()))
618 );
619 }
620
621 #[test]
624 fn factory_shitmos() {
625 let got = p("transfer/channel-4/factory/osmo1q77cw0mmlluxu0wr29fcdd0tdnh78gzhkvhe4n6ulal9qvrtu43qtd0nh8/shitmos");
626 assert_eq!(
627 got,
628 Some((
629 "transfer/channel-4".to_string(),
630 "factory/osmo1q77cw0mmlluxu0wr29fcdd0tdnh78gzhkvhe4n6ulal9qvrtu43qtd0nh8/shitmos"
631 .to_string()
632 ))
633 );
634 }
635
636 #[test]
638 fn cw20_filtered_out() {
639 let got = p("cw20:inj19vy83ne9tzta2yqynj8yg7dq9ghca6yqn9hyej");
640 assert_eq!(got, None);
641 }
642
643 #[test]
646 fn multihop_wasm_evm_hex() {
647 let got = p(
648 "transfer/channel-0/transfer/08-wasm-1369/0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2",
649 );
650 assert_eq!(
651 got,
652 Some((
653 "transfer/channel-0/transfer/08-wasm-1369".to_string(),
654 "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2".to_string()
655 ))
656 );
657 }
658
659 #[test]
662 fn gamm_pool() {
663 let got = p("transfer/channel-4/gamm/pool/1402");
664 assert_eq!(
665 got,
666 Some((
667 "transfer/channel-4".to_string(),
668 "gamm/pool/1402".to_string()
669 ))
670 );
671 }
672}
673
674#[cfg(test)]
675mod tests {
676 use std::sync::Arc;
677
678 #[test]
679 fn can_parse_metadata_from_chain_registry() {
680 const SOME_COSMOS_JSON: &str = r#"
681 {
682 "description": "The native staking token of dYdX Protocol.",
683 "denom_units": [
684 {
685 "denom": "adydx",
686 "exponent": 0
687 },
688 {
689 "denom": "dydx",
690 "exponent": 18
691 }
692 ],
693 "base": "adydx",
694 "name": "dYdX",
695 "display": "dydx",
696 "symbol": "DYDX",
697 "logo_URIs": {
698 "png": "https://raw.githubusercontent.com/cosmos/chain-registry/master/dydx/images/dydx.png",
699 "svg": "https://raw.githubusercontent.com/cosmos/chain-registry/master/dydx/images/dydx.svg"
700 },
701 "coingecko_id": "dydx",
702 "images": [
703 {
704 "png": "https://raw.githubusercontent.com/cosmos/chain-registry/master/dydx/images/dydx.png",
705 "svg": "https://raw.githubusercontent.com/cosmos/chain-registry/master/dydx/images/dydx.svg"
706 },
707 {
708 "svg": "https://raw.githubusercontent.com/cosmos/chain-registry/master/dydx/images/dydx-circle.svg",
709 "theme": {
710 "circle": true
711 }
712 }
713 ]
714 }
715 "#;
716
717 let _metadata: super::Metadata = serde_json::from_str(SOME_COSMOS_JSON).unwrap();
718
719 }
723
724 #[test]
725 fn encoding_round_trip_succeeds() {
726 let metadata = super::Metadata::try_from("upenumbra").unwrap();
727
728 let proto = super::pb::Metadata::from(metadata.clone());
729
730 let metadata_2 = super::Metadata::try_from(proto).unwrap();
731
732 assert_eq!(metadata, metadata_2);
733 }
734
735 #[test]
736 #[should_panic]
737 fn changing_asset_id_without_changing_denom_fails_decoding() {
738 let mut metadata = super::Metadata::try_from("upenumbra").unwrap();
739
740 let inner = Arc::get_mut(&mut metadata.inner).unwrap();
741
742 inner.id = super::Id::from_raw_denom("uusd");
743
744 let proto = super::pb::Metadata::from(metadata);
745
746 let _domain_type = super::Metadata::try_from(proto).unwrap();
749 }
750}