use std::fmt::{Debug, Display, Formatter};
use std::str::FromStr;
use compact_str::CompactString;
use r402_core::amount::{MoneyAmount, MoneyAmountParseError};
use r402_core::chain::{ChainId, DeployedTokenAmount};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
pub const HEDERA_NAMESPACE: &str = "hedera";
pub const HBAR_ASSET_ID: &str = "0.0.0";
pub const HBAR_DECIMALS: u8 = 8;
pub const USDC_DECIMALS: u8 = 6;
pub const HEDERA_MAINNET_USDC: &str = "0.0.456858";
pub const HEDERA_TESTNET_USDC: &str = "0.0.429274";
pub const HEDERA_MAINNET_MIRROR_NODE_URL: &str = "https://mainnet-public.mirrornode.hedera.com";
pub const HEDERA_TESTNET_MIRROR_NODE_URL: &str = "https://testnet.mirrornode.hedera.com";
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub enum HederaChainReference {
Mainnet,
Testnet,
}
impl HederaChainReference {
pub const MAINNET: Self = Self::Mainnet;
pub const TESTNET: Self = Self::Testnet;
pub const ALL: &'static [Self] = &[Self::Mainnet, Self::Testnet];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Mainnet => "mainnet",
Self::Testnet => "testnet",
}
}
#[must_use]
pub const fn default_mirror_url(self) -> &'static str {
match self {
Self::Mainnet => HEDERA_MAINNET_MIRROR_NODE_URL,
Self::Testnet => HEDERA_TESTNET_MIRROR_NODE_URL,
}
}
}
impl Debug for HederaChainReference {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "HederaChainReference({})", self.as_str())
}
}
impl Display for HederaChainReference {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for HederaChainReference {
type Err = HederaChainReferenceFormatError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"mainnet" => Ok(Self::Mainnet),
"testnet" => Ok(Self::Testnet),
other => Err(HederaChainReferenceFormatError::InvalidReference(
other.to_owned(),
)),
}
}
}
impl Serialize for HederaChainReference {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for HederaChainReference {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
s.parse().map_err(serde::de::Error::custom)
}
}
impl From<HederaChainReference> for ChainId {
fn from(value: HederaChainReference) -> Self {
Self::new(HEDERA_NAMESPACE, value.as_str())
}
}
impl TryFrom<ChainId> for HederaChainReference {
type Error = HederaChainReferenceFormatError;
fn try_from(value: ChainId) -> Result<Self, Self::Error> {
let (namespace, reference) = value.into_parts();
if namespace != HEDERA_NAMESPACE {
return Err(HederaChainReferenceFormatError::InvalidNamespace(namespace));
}
Self::from_str(&reference)
.map_err(|_| HederaChainReferenceFormatError::InvalidReference(reference))
}
}
#[derive(Debug, thiserror::Error)]
pub enum HederaChainReferenceFormatError {
#[error("Invalid namespace {0}, expected hedera")]
InvalidNamespace(String),
#[error("Invalid hedera chain reference {0}")]
InvalidReference(String),
}
#[must_use]
pub fn is_hedera_network(network: &str) -> bool {
network == "hedera:mainnet" || network == "hedera:testnet"
}
#[must_use]
pub fn is_entity_id(s: &str) -> bool {
if s.is_empty() {
return false;
}
let mut dots = 0u8;
let mut last_dot = true;
for b in s.bytes() {
if b == b'.' {
if last_dot || dots == 2 {
return false;
}
dots = dots.saturating_add(1);
last_dot = true;
continue;
}
if !b.is_ascii_digit() {
return false;
}
last_dot = false;
}
dots == 2 && !last_dot
}
#[must_use]
pub fn is_hbar_asset(asset: &str) -> bool {
asset == HBAR_ASSET_ID
}
#[must_use]
pub fn is_valid_asset(asset: &str) -> bool {
is_hbar_asset(asset) || is_entity_id(asset)
}
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
pub struct HederaAddress(CompactString);
impl HederaAddress {
#[must_use]
pub fn as_str(&self) -> &str {
self.0.as_str()
}
#[must_use]
pub fn is_entity_id(&self) -> bool {
is_entity_id(self.as_str())
}
}
impl Display for HederaAddress {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for HederaAddress {
type Err = HederaAddressFormatError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s.is_empty() || s.chars().all(char::is_whitespace) {
return Err(HederaAddressFormatError::Invalid(s.to_owned()));
}
if is_entity_id(s) {
return Ok(Self(CompactString::from(s)));
}
#[cfg(any(feature = "client", feature = "facilitator"))]
{
if hedera::AccountId::from_str(s).is_ok() {
return Ok(Self(CompactString::from(s)));
}
}
Err(HederaAddressFormatError::Invalid(s.to_owned()))
}
}
impl Serialize for HederaAddress {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for HederaAddress {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
s.parse().map_err(serde::de::Error::custom)
}
}
impl AsRef<str> for HederaAddress {
fn as_ref(&self) -> &str {
self.as_str()
}
}
#[derive(Debug, thiserror::Error)]
pub enum HederaAddressFormatError {
#[error("invalid hedera address: {0}")]
Invalid(String),
}
#[must_use]
pub fn hedera_account_ids_equal(left: &str, right: &str) -> bool {
if left == right {
return true;
}
#[cfg(any(feature = "client", feature = "facilitator"))]
{
if let (Ok(l), Ok(r)) = (
hedera::AccountId::from_str(left),
hedera::AccountId::from_str(right),
) {
return l == r;
}
}
false
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct HederaTokenAmount(CompactString);
impl HederaTokenAmount {
#[must_use]
pub fn as_str(&self) -> &str {
self.0.as_str()
}
pub fn as_u64(&self) -> Result<u64, HederaTokenAmountFormatError> {
self.0
.parse()
.map_err(|_| HederaTokenAmountFormatError::Invalid(self.0.to_string()))
}
}
impl Display for HederaTokenAmount {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for HederaTokenAmount {
type Err = HederaTokenAmountFormatError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
return Err(HederaTokenAmountFormatError::Invalid(s.to_owned()));
}
let _: u64 = s
.parse()
.map_err(|_| HederaTokenAmountFormatError::Invalid(s.to_owned()))?;
Ok(Self(CompactString::from(s)))
}
}
impl Serialize for HederaTokenAmount {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for HederaTokenAmount {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
s.parse().map_err(serde::de::Error::custom)
}
}
impl From<u64> for HederaTokenAmount {
fn from(value: u64) -> Self {
Self(CompactString::from(value.to_string()))
}
}
impl TryFrom<HederaTokenAmount> for u64 {
type Error = HederaTokenAmountFormatError;
fn try_from(value: HederaTokenAmount) -> Result<Self, Self::Error> {
value.as_u64()
}
}
#[derive(Debug, thiserror::Error)]
pub enum HederaTokenAmountFormatError {
#[error("invalid hedera token amount: {0}")]
Invalid(String),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct HederaTokenDeployment {
pub chain_reference: HederaChainReference,
pub address: HederaAddress,
pub decimals: u8,
}
impl HederaTokenDeployment {
#[must_use]
pub const fn new(
chain_reference: HederaChainReference,
address: HederaAddress,
decimals: u8,
) -> Self {
Self {
chain_reference,
address,
decimals,
}
}
#[must_use]
pub fn amount(&self, v: u64) -> DeployedTokenAmount<u64, Self> {
DeployedTokenAmount {
amount: v,
token: self.clone(),
}
}
pub fn parse<V>(&self, v: V) -> Result<DeployedTokenAmount<u64, Self>, MoneyAmountParseError>
where
V: TryInto<MoneyAmount>,
MoneyAmountParseError: From<<V as TryInto<MoneyAmount>>::Error>,
{
let amount: u64 = v.try_into()?.to_token_amount(self.decimals)?;
Ok(DeployedTokenAmount {
amount,
token: self.clone(),
})
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, reason = "test assertions")]
mod tests {
use super::*;
#[test]
fn entity_id_validation() {
assert!(is_entity_id("0.0.1234"));
assert!(is_entity_id("0.0.0"));
assert!(!is_entity_id(""));
assert!(!is_entity_id("0.0"));
assert!(!is_entity_id("0.0.1234.5"));
assert!(!is_entity_id(".0.1"));
assert!(!is_entity_id("0.0."));
assert!(!is_entity_id("not-an-account"));
assert!(!is_entity_id("0x000000000000000000000000000000000000abcd"));
}
#[test]
fn address_entity_and_reject_garbage() {
assert!("0.0.5001".parse::<HederaAddress>().is_ok());
assert!("not-an-account".parse::<HederaAddress>().is_err());
assert!("".parse::<HederaAddress>().is_err());
}
#[test]
fn token_amount_decimal_string() {
let amount: HederaTokenAmount = "1000".parse().unwrap();
assert_eq!(amount.as_u64().unwrap(), 1000);
assert!("".parse::<HederaTokenAmount>().is_err());
assert!("1.0".parse::<HederaTokenAmount>().is_err());
assert!("-1".parse::<HederaTokenAmount>().is_err());
}
#[test]
fn chain_reference_roundtrip() {
let chain_id: ChainId = HederaChainReference::TESTNET.into();
assert_eq!(chain_id.to_string(), "hedera:testnet");
let back = HederaChainReference::try_from(chain_id).unwrap();
assert_eq!(back, HederaChainReference::TESTNET);
assert!(is_hedera_network("hedera:mainnet"));
assert!(!is_hedera_network("eip155:1"));
}
#[test]
fn token_deployment_parse() {
let addr: HederaAddress = HEDERA_TESTNET_USDC.parse().unwrap();
let deployment = HederaTokenDeployment::new(HederaChainReference::TESTNET, addr, 6);
let parsed = deployment.parse("10.50").unwrap();
assert_eq!(parsed.amount, 10_500_000);
}
#[test]
fn asset_helpers() {
assert!(is_hbar_asset(HBAR_ASSET_ID));
assert!(is_valid_asset(HBAR_ASSET_ID));
assert!(is_valid_asset(HEDERA_MAINNET_USDC));
assert!(!is_valid_asset("invalid-asset"));
}
}