penumbra_sdk_asset/asset/
denom_metadata.rs1use 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#[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#[derive(Debug)]
34pub(super) struct Inner {
35 id: Id,
37 base_denom: String,
38 description: String,
39 images: Vec<pb::AssetImage>,
42 badges: Vec<pb::AssetImage>,
43 priority_score: u64,
44
45 pub(super) units: Vec<BareDenomUnit>,
47 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 let id = Id::from_raw_denom(&base_denom);
89 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 let mut units = value
100 .denom_units
101 .into_iter()
102 .map(BareDenomUnit::try_from)
103 .collect::<Result<Vec<_>, _>>()?;
104
105 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#[derive(Clone)]
185pub struct Unit {
186 pub(super) inner: Arc<Inner>,
187 pub(super) unit_index: usize,
190}
191
192#[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 pub fn new(base_denom: String, mut units: Vec<BareDenomUnit>) -> Self {
228 let id = Id(Fq::from_le_bytes_mod_order(
229 blake2b_simd::Params::default()
231 .personal(b"Penumbra_AssetID")
232 .hash(base_denom.as_bytes())
233 .as_bytes(),
234 ));
235
236 for unit in &units {
239 assert_ne!(unit.exponent, 0);
240 assert_ne!(&unit.denom, &base_denom);
241 }
242
243 units.push(BareDenomUnit {
246 exponent: 0,
247 denom: base_denom.clone(),
248 });
249
250 Self {
251 id,
252 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 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 pub fn value(&self, amount: Amount) -> Value {
283 Value {
284 amount,
285 asset_id: self.id(),
286 }
287 }
288
289 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 pub fn default_unit(&self) -> Unit {
303 Unit {
304 unit_index: self.inner.display_index,
305 inner: self.inner.clone(),
306 }
307 }
308
309 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 pub fn best_unit_for(&self, amount: Amount) -> Unit {
325 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 pub fn ibc_transfer_path(&self) -> anyhow::Result<Option<(String, String)>> {
381 let base_denom = self.base_denom().denom;
382 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 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 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 let v2_str = format!(
485 "{:0width$}",
486 u128::from(v2),
487 width = self.exponent() as usize
488 );
489
490 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 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 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 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 }
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 let _domain_type = super::Metadata::try_from(proto).unwrap();
663 }
664}