use std::{
cmp::{Eq, PartialEq},
fmt::{Debug, Display},
hash::{self, Hash},
sync::Arc,
};
use anyhow::{ensure, Context};
use decaf377::Fq;
use penumbra_num::Amount;
use penumbra_proto::{penumbra::core::asset::v1 as pb, view::v1::AssetsResponse, DomainType};
use regex::Regex;
use serde::{Deserialize, Serialize};
use crate::{
asset::{Id, REGISTRY},
Value,
};
use super::Denom;
#[derive(Serialize, Deserialize, Clone)]
#[serde(try_from = "pb::Metadata", into = "pb::Metadata")]
pub struct Metadata {
pub(super) inner: Arc<Inner>,
}
#[derive(Debug)]
pub(super) struct Inner {
id: Id,
base_denom: String,
description: String,
images: Vec<pb::AssetImage>,
badges: Vec<pb::AssetImage>,
priority_score: u64,
pub(super) units: Vec<BareDenomUnit>,
display_index: usize,
name: String,
symbol: String,
}
impl DomainType for Metadata {
type Proto = pb::Metadata;
}
impl From<&Inner> for pb::Metadata {
fn from(inner: &Inner) -> Self {
pb::Metadata {
description: inner.description.clone(),
base: inner.base_denom.clone(),
display: inner.units[inner.display_index].denom.clone(),
name: inner.name.clone(),
symbol: inner.symbol.clone(),
penumbra_asset_id: Some(inner.id.into()),
denom_units: inner.units.clone().into_iter().map(|x| x.into()).collect(),
images: inner.images.clone(),
badges: inner.badges.clone(),
priority_score: inner.priority_score,
}
}
}
impl TryFrom<pb::Metadata> for Inner {
type Error = anyhow::Error;
fn try_from(value: pb::Metadata) -> Result<Self, Self::Error> {
let base_denom = value.base;
ensure!(
!base_denom.is_empty(),
"denom metadata must have a base denom"
);
let id = Id::from_raw_denom(&base_denom);
if let Some(supplied_id) = value.penumbra_asset_id {
let supplied_id = Id::try_from(supplied_id)?;
ensure!(
id == supplied_id,
"denom metadata has mismatched penumbra asset ID"
);
}
let mut units = value
.denom_units
.into_iter()
.map(BareDenomUnit::try_from)
.collect::<Result<Vec<_>, _>>()?;
if !units.iter().any(|unit| unit.denom == base_denom) {
units.push(BareDenomUnit {
denom: base_denom.clone(),
exponent: 0,
});
}
let display_index = if !value.display.is_empty() {
units
.iter()
.position(|unit| unit.denom == value.display)
.ok_or_else(|| {
anyhow::anyhow!(
"display denom {} not found in units {:?}",
value.display,
units
)
})?
} else {
0
};
Ok(Inner {
id,
base_denom,
units,
display_index,
description: value.description,
name: value.name,
symbol: value.symbol,
images: value.images,
badges: value.badges,
priority_score: value.priority_score,
})
}
}
impl From<Metadata> for pb::Metadata {
fn from(dn: Metadata) -> Self {
dn.inner.as_ref().into()
}
}
impl TryFrom<pb::Metadata> for Metadata {
type Error = anyhow::Error;
fn try_from(value: pb::Metadata) -> Result<Self, Self::Error> {
let inner = Inner::try_from(value)?;
Ok(Metadata {
inner: Arc::new(inner),
})
}
}
impl TryFrom<&str> for Metadata {
type Error = anyhow::Error;
fn try_from(value: &str) -> Result<Self, Self::Error> {
REGISTRY
.parse_denom(value)
.ok_or_else(|| anyhow::anyhow!("invalid denomination {}", value))
}
}
impl TryFrom<AssetsResponse> for Metadata {
type Error = anyhow::Error;
fn try_from(response: AssetsResponse) -> Result<Self, Self::Error> {
response
.denom_metadata
.ok_or_else(|| anyhow::anyhow!("empty AssetsResponse message"))?
.try_into()
}
}
#[derive(Clone)]
pub struct Unit {
pub(super) inner: Arc<Inner>,
pub(super) unit_index: usize,
}
#[derive(Clone, Debug)]
pub(super) struct BareDenomUnit {
pub exponent: u8,
pub denom: String,
}
impl TryFrom<pb::DenomUnit> for BareDenomUnit {
type Error = anyhow::Error;
fn try_from(value: pb::DenomUnit) -> Result<Self, Self::Error> {
Ok(BareDenomUnit {
exponent: value.exponent as u8,
denom: value.denom,
})
}
}
impl From<BareDenomUnit> for pb::DenomUnit {
fn from(dn: BareDenomUnit) -> Self {
pb::DenomUnit {
denom: dn.denom,
exponent: dn.exponent as u32,
aliases: Vec::new(),
}
}
}
impl Inner {
pub fn new(base_denom: String, mut units: Vec<BareDenomUnit>) -> Self {
let id = Id(Fq::from_le_bytes_mod_order(
blake2b_simd::Params::default()
.personal(b"Penumbra_AssetID")
.hash(base_denom.as_bytes())
.as_bytes(),
));
for unit in &units {
assert_ne!(unit.exponent, 0);
assert_ne!(&unit.denom, &base_denom);
}
units.push(BareDenomUnit {
exponent: 0,
denom: base_denom.clone(),
});
Self {
id,
display_index: 0,
units,
base_denom,
description: String::new(),
name: String::new(),
symbol: String::new(),
images: Vec::new(),
badges: Vec::new(),
priority_score: 0,
}
}
}
impl Metadata {
pub fn id(&self) -> Id {
self.inner.id
}
pub fn base_denom(&self) -> Denom {
Denom {
denom: self.inner.base_denom.clone(),
}
}
pub fn value(&self, amount: Amount) -> Value {
Value {
amount,
asset_id: self.id(),
}
}
pub fn units(&self) -> Vec<Unit> {
(0..self.inner.units.len())
.map(|unit_index| Unit {
unit_index,
inner: self.inner.clone(),
})
.collect()
}
pub fn default_unit(&self) -> Unit {
Unit {
unit_index: self.inner.display_index,
inner: self.inner.clone(),
}
}
pub fn base_unit(&self) -> Unit {
Unit {
unit_index: self.inner.units.len() - 1,
inner: self.inner.clone(),
}
}
pub fn best_unit_for(&self, amount: Amount) -> Unit {
if amount == 0u64.into() {
return self.default_unit();
}
let mut selected_index = 0;
let mut selected_exponent = 0;
for (unit_index, unit) in self.inner.units.iter().enumerate() {
let unit_amount = Amount::from(10u128.pow(unit.exponent as u32));
if unit_amount <= amount && unit.exponent >= selected_exponent {
selected_index = unit_index;
selected_exponent = unit.exponent;
}
}
return Unit {
unit_index: selected_index,
inner: self.inner.clone(),
};
}
pub fn starts_with(&self, prefix: &str) -> bool {
self.inner.base_denom.starts_with(prefix)
}
pub fn default_for(denom: &Denom) -> Option<Metadata> {
REGISTRY.parse_denom(&denom.denom)
}
pub fn is_auction_nft(&self) -> bool {
self.starts_with("auctionnft_")
}
pub fn is_withdrawn_auction_nft(&self) -> bool {
self.starts_with("auctionnft_2")
}
pub fn is_opened_position_nft(&self) -> bool {
let prefix = "lpnft_opened_".to_string();
self.starts_with(&prefix)
}
pub fn is_withdrawn_position_nft(&self) -> bool {
let prefix = "lpnft_withdrawn_".to_string();
self.starts_with(&prefix)
}
pub fn is_closed_position_nft(&self) -> bool {
let prefix = "lpnft_closed_".to_string();
self.starts_with(&prefix)
}
pub fn ibc_transfer_path(&self) -> anyhow::Result<Option<(String, String)>> {
let base_denom = self.base_denom().denom;
let re = Regex::new(r"^(?<path>transfer/channel-[0-9]+)/(?<denom>[\w\/]+)$")
.context("error instantiating denom matching regex")?;
let Some(caps) = re.captures(&base_denom) else {
return Ok(None);
};
Ok(Some((caps["path"].to_string(), caps["denom"].to_string())))
}
}
impl From<Metadata> for Id {
fn from(base: Metadata) -> Id {
base.id()
}
}
impl Hash for Metadata {
fn hash<H: hash::Hasher>(&self, state: &mut H) {
self.inner.base_denom.hash(state);
}
}
impl PartialEq for Metadata {
fn eq(&self, other: &Self) -> bool {
self.inner.base_denom.eq(&other.inner.base_denom)
}
}
impl Eq for Metadata {}
impl PartialOrd for Metadata {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Metadata {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.inner.base_denom.cmp(&other.inner.base_denom)
}
}
impl Debug for Metadata {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let Self { inner } = self;
let Inner {
id,
base_denom,
description,
images,
units,
display_index,
name,
symbol,
priority_score,
badges,
} = inner.as_ref();
f.debug_struct("Metadata")
.field("id", id)
.field("base_denom", base_denom)
.field("description", description)
.field("images", images)
.field("badges", badges)
.field("priority_score", priority_score)
.field("units", units)
.field("display_index", display_index)
.field("name", name)
.field("symbol", symbol)
.finish()
}
}
impl Display for Metadata {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.inner.base_denom.as_str())
}
}
impl Unit {
pub fn base(&self) -> Metadata {
Metadata {
inner: self.inner.clone(),
}
}
pub fn id(&self) -> Id {
self.inner.id
}
pub fn format_value(&self, value: Amount) -> String {
let power_of_ten = Amount::from(10u128.pow(self.exponent().into()));
let v1 = value / power_of_ten;
let v2 = value % power_of_ten;
let v2_str = format!(
"{:0width$}",
u128::from(v2),
width = self.exponent() as usize
);
let v2_stripped = v2_str.trim_end_matches('0');
if v2 != Amount::zero() {
format!("{v1}.{v2_stripped}")
} else {
format!("{v1}")
}
}
pub fn parse_value(&self, value: &str) -> anyhow::Result<Amount> {
let split: Vec<&str> = value.split('.').collect();
if split.len() > 2 {
anyhow::bail!("expected only one decimal point")
} else {
let left = split[0];
let right = if split.len() > 1 { split[1] } else { "0" };
let v1 = left.parse::<u128>().map_err(|e| anyhow::anyhow!(e))?;
let mut v2 = right.parse::<u128>().map_err(|e| anyhow::anyhow!(e))?;
let v1_power_of_ten = 10u128.pow(self.exponent().into());
if right.len() == (self.exponent() + 1) as usize && v2 == 0 {
return Ok(v1.into());
} else if right.len() > self.exponent().into() {
anyhow::bail!("cannot represent this value");
}
let v2_power_of_ten = 10u128.pow((self.exponent() - right.len() as u8).into());
v2 = v2
.checked_mul(v2_power_of_ten)
.context("multiplication overflowed when applying right hand side exponent")?;
let v = v1
.checked_mul(v1_power_of_ten)
.and_then(|x| x.checked_add(v2));
if let Some(value) = v {
Ok(value.into())
} else {
anyhow::bail!("overflow!")
}
}
}
pub fn exponent(&self) -> u8 {
self.inner
.units
.get(self.unit_index)
.expect("there must be an entry for unit_index")
.exponent
}
pub fn unit_amount(&self) -> Amount {
10u128.pow(self.exponent().into()).into()
}
pub fn value(&self, amount: Amount) -> Value {
Value {
asset_id: self.id(),
amount: amount * self.unit_amount(),
}
}
}
impl Hash for Unit {
fn hash<H: hash::Hasher>(&self, state: &mut H) {
self.inner.base_denom.hash(state);
self.unit_index.hash(state);
}
}
impl PartialEq for Unit {
fn eq(&self, other: &Self) -> bool {
self.inner.base_denom.eq(&other.inner.base_denom) && self.unit_index.eq(&other.unit_index)
}
}
impl Eq for Unit {}
impl Debug for Unit {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.inner.units[self.unit_index].denom.as_str())
}
}
impl Display for Unit {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.inner.units[self.unit_index].denom.as_str())
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
#[test]
fn can_parse_metadata_from_chain_registry() {
const SOME_COSMOS_JSON: &str = r#"
{
"description": "The native staking token of dYdX Protocol.",
"denom_units": [
{
"denom": "adydx",
"exponent": 0
},
{
"denom": "dydx",
"exponent": 18
}
],
"base": "adydx",
"name": "dYdX",
"display": "dydx",
"symbol": "DYDX",
"logo_URIs": {
"png": "https://raw.githubusercontent.com/cosmos/chain-registry/master/dydx/images/dydx.png",
"svg": "https://raw.githubusercontent.com/cosmos/chain-registry/master/dydx/images/dydx.svg"
},
"coingecko_id": "dydx",
"images": [
{
"png": "https://raw.githubusercontent.com/cosmos/chain-registry/master/dydx/images/dydx.png",
"svg": "https://raw.githubusercontent.com/cosmos/chain-registry/master/dydx/images/dydx.svg"
},
{
"svg": "https://raw.githubusercontent.com/cosmos/chain-registry/master/dydx/images/dydx-circle.svg",
"theme": {
"circle": true
}
}
]
}
"#;
let _metadata: super::Metadata = serde_json::from_str(SOME_COSMOS_JSON).unwrap();
}
#[test]
fn encoding_round_trip_succeeds() {
let metadata = super::Metadata::try_from("upenumbra").unwrap();
let proto = super::pb::Metadata::from(metadata.clone());
let metadata_2 = super::Metadata::try_from(proto).unwrap();
assert_eq!(metadata, metadata_2);
}
#[test]
#[should_panic]
fn changing_asset_id_without_changing_denom_fails_decoding() {
let mut metadata = super::Metadata::try_from("upenumbra").unwrap();
let inner = Arc::get_mut(&mut metadata.inner).unwrap();
inner.id = super::Id::from_raw_denom("uusd");
let proto = super::pb::Metadata::from(metadata);
let _domain_type = super::Metadata::try_from(proto).unwrap();
}
}