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};
use stellar_strkey::Strkey;
pub const STELLAR_NAMESPACE: &str = "stellar";
pub const STELLAR_TESTNET_RPC_URL: &str = "https://soroban-testnet.stellar.org";
pub const STELLAR_TESTNET_HORIZON_URL: &str = "https://horizon-testnet.stellar.org";
pub const STELLAR_PUBNET_HORIZON_URL: &str = "https://horizon.stellar.org";
pub const STELLAR_PUBNET_PASSPHRASE: &str = "Public Global Stellar Network ; September 2015";
pub const STELLAR_TESTNET_PASSPHRASE: &str = "Test SDF Network ; September 2015";
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub enum StellarChainReference {
Pubnet,
Testnet,
}
impl StellarChainReference {
pub const PUBNET: Self = Self::Pubnet;
pub const TESTNET: Self = Self::Testnet;
pub const ALL: &'static [Self] = &[Self::Pubnet, Self::Testnet];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Pubnet => "pubnet",
Self::Testnet => "testnet",
}
}
#[must_use]
pub const fn passphrase(self) -> &'static str {
match self {
Self::Pubnet => STELLAR_PUBNET_PASSPHRASE,
Self::Testnet => STELLAR_TESTNET_PASSPHRASE,
}
}
#[must_use]
pub const fn default_horizon_url(self) -> &'static str {
match self {
Self::Pubnet => STELLAR_PUBNET_HORIZON_URL,
Self::Testnet => STELLAR_TESTNET_HORIZON_URL,
}
}
#[must_use]
pub const fn default_rpc_url(self) -> Option<&'static str> {
match self {
Self::Pubnet => None,
Self::Testnet => Some(STELLAR_TESTNET_RPC_URL),
}
}
pub fn rpc_url(self, rpc_url: Option<&str>) -> Result<String, StellarRpcUrlError> {
if let Some(url) = rpc_url.filter(|u| !u.is_empty()) {
return Ok(url.to_owned());
}
match self {
Self::Testnet => Ok(STELLAR_TESTNET_RPC_URL.to_owned()),
Self::Pubnet => Err(StellarRpcUrlError::PubnetRpcRequired),
}
}
}
impl Debug for StellarChainReference {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "StellarChainReference({})", self.as_str())
}
}
impl Display for StellarChainReference {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for StellarChainReference {
type Err = StellarChainReferenceFormatError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"pubnet" => Ok(Self::Pubnet),
"testnet" => Ok(Self::Testnet),
other => Err(StellarChainReferenceFormatError::InvalidReference(
other.to_owned(),
)),
}
}
}
impl Serialize for StellarChainReference {
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 StellarChainReference {
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<StellarChainReference> for ChainId {
fn from(value: StellarChainReference) -> Self {
Self::new(STELLAR_NAMESPACE, value.as_str())
}
}
impl TryFrom<ChainId> for StellarChainReference {
type Error = StellarChainReferenceFormatError;
fn try_from(value: ChainId) -> Result<Self, Self::Error> {
let (namespace, reference) = value.into_parts();
if namespace != STELLAR_NAMESPACE {
return Err(StellarChainReferenceFormatError::InvalidNamespace(
namespace,
));
}
Self::from_str(&reference)
.map_err(|_| StellarChainReferenceFormatError::InvalidReference(reference))
}
}
#[derive(Debug, thiserror::Error)]
pub enum StellarChainReferenceFormatError {
#[error("Invalid namespace {0}, expected stellar")]
InvalidNamespace(String),
#[error("Invalid stellar chain reference {0}")]
InvalidReference(String),
}
#[derive(Debug, thiserror::Error, Clone, Copy)]
pub enum StellarRpcUrlError {
#[error(
"Stellar pubnet requires a non-empty rpcUrl. For a list of RPC providers, see https://developers.stellar.org/docs/data/apis/rpc/providers#publicly-accessible-apis"
)]
PubnetRpcRequired,
}
#[must_use]
pub fn is_stellar_network(network: &str) -> bool {
network == "stellar:pubnet" || network == "stellar:testnet"
}
#[must_use]
pub fn ed25519_account_payload(address: &str) -> Option<[u8; 32]> {
match Strkey::from_string(address).ok()? {
Strkey::PublicKeyEd25519(pk) => Some(pk.0),
Strkey::MuxedAccountEd25519(muxed) => Some(muxed.ed25519),
_ => None,
}
}
#[must_use]
pub fn is_facilitator_account(facilitator_addresses: &[String], candidate: &str) -> bool {
ed25519_account_payload(candidate).map_or_else(
|| facilitator_addresses.iter().any(|addr| addr == candidate),
|key| {
facilitator_addresses
.iter()
.any(|addr| ed25519_account_payload(addr) == Some(key))
},
)
}
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
pub struct StellarAddress(CompactString);
impl StellarAddress {
#[must_use]
pub fn as_str(&self) -> &str {
self.0.as_str()
}
#[must_use]
pub fn is_contract(&self) -> bool {
matches!(Strkey::from_string(self.as_str()), Ok(Strkey::Contract(_)))
}
}
impl Display for StellarAddress {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for StellarAddress {
type Err = StellarAddressFormatError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match Strkey::from_string(s) {
Ok(
Strkey::PublicKeyEd25519(_) | Strkey::Contract(_) | Strkey::MuxedAccountEd25519(_),
) => Ok(Self(CompactString::from(s))),
Ok(_) | Err(_) => Err(StellarAddressFormatError::Invalid(s.to_owned())),
}
}
}
impl Serialize for StellarAddress {
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 StellarAddress {
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 StellarAddress {
fn as_ref(&self) -> &str {
self.as_str()
}
}
#[derive(Debug, thiserror::Error)]
pub enum StellarAddressFormatError {
#[error("invalid stellar address: {0}")]
Invalid(String),
}
pub fn parse_contract_address(s: &str) -> Result<StellarAddress, StellarAddressFormatError> {
let address: StellarAddress = s.parse()?;
if address.is_contract() {
Ok(address)
} else {
Err(StellarAddressFormatError::Invalid(s.to_owned()))
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct StellarTokenAmount(CompactString);
impl StellarTokenAmount {
#[must_use]
pub fn as_str(&self) -> &str {
self.0.as_str()
}
pub fn as_i128(&self) -> Result<i128, StellarTokenAmountFormatError> {
self.0
.parse()
.map_err(|_| StellarTokenAmountFormatError::Invalid(self.0.to_string()))
}
}
impl Display for StellarTokenAmount {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for StellarTokenAmount {
type Err = StellarTokenAmountFormatError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s.is_empty()
|| !(s.bytes().all(|b| b.is_ascii_digit())
|| (s.starts_with('-')
&& s.len() > 1
&& s.bytes().skip(1).all(|b| b.is_ascii_digit())))
{
return Err(StellarTokenAmountFormatError::Invalid(s.to_owned()));
}
let _: i128 = s
.parse()
.map_err(|_| StellarTokenAmountFormatError::Invalid(s.to_owned()))?;
Ok(Self(CompactString::from(s)))
}
}
impl Serialize for StellarTokenAmount {
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 StellarTokenAmount {
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<i128> for StellarTokenAmount {
fn from(value: i128) -> Self {
Self(CompactString::from(value.to_string()))
}
}
impl From<u128> for StellarTokenAmount {
fn from(value: u128) -> Self {
Self(CompactString::from(value.to_string()))
}
}
impl TryFrom<StellarTokenAmount> for i128 {
type Error = StellarTokenAmountFormatError;
fn try_from(value: StellarTokenAmount) -> Result<Self, Self::Error> {
value.as_i128()
}
}
#[derive(Debug, thiserror::Error)]
pub enum StellarTokenAmountFormatError {
#[error("invalid stellar token amount: {0}")]
Invalid(String),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct StellarTokenDeployment {
pub chain_reference: StellarChainReference,
pub address: StellarAddress,
pub decimals: u8,
}
impl StellarTokenDeployment {
#[must_use]
pub const fn new(
chain_reference: StellarChainReference,
address: StellarAddress,
decimals: u8,
) -> Self {
Self {
chain_reference,
address,
decimals,
}
}
#[must_use]
pub fn amount(&self, v: i128) -> DeployedTokenAmount<i128, Self> {
DeployedTokenAmount {
amount: v,
token: self.clone(),
}
}
pub fn parse<V>(&self, v: V) -> Result<DeployedTokenAmount<i128, Self>, MoneyAmountParseError>
where
V: TryInto<MoneyAmount>,
MoneyAmountParseError: From<<V as TryInto<MoneyAmount>>::Error>,
{
let amount: u128 = v.try_into()?.to_token_amount(self.decimals)?;
let amount = i128::try_from(amount).map_err(|_| MoneyAmountParseError::OutOfRange)?;
Ok(DeployedTokenAmount {
amount,
token: self.clone(),
})
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, reason = "test assertions")]
mod tests {
use super::*;
#[test]
fn destination_and_contract_addresses() {
assert!(
"GBBO4ZDDZTSM2IUKQYBAST3CFHNPFXECGEFTGWTA2WELR2BIWDK57UVE"
.parse::<StellarAddress>()
.is_ok()
);
assert!(
"CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA"
.parse::<StellarAddress>()
.is_ok()
);
assert!("not-an-address".parse::<StellarAddress>().is_err());
assert!(
parse_contract_address("CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA")
.is_ok()
);
assert!(
parse_contract_address("GBBO4ZDDZTSM2IUKQYBAST3CFHNPFXECGEFTGWTA2WELR2BIWDK57UVE")
.is_err()
);
}
#[test]
fn token_amount_decimal_string() {
let amount: StellarTokenAmount = "10000000".parse().unwrap();
assert_eq!(amount.as_i128().unwrap(), 10_000_000);
assert!("".parse::<StellarTokenAmount>().is_err());
assert!("1.0".parse::<StellarTokenAmount>().is_err());
}
#[test]
fn chain_reference_roundtrip() {
let chain_id: ChainId = StellarChainReference::TESTNET.into();
assert_eq!(chain_id.to_string(), "stellar:testnet");
let back = StellarChainReference::try_from(chain_id).unwrap();
assert_eq!(back, StellarChainReference::TESTNET);
assert!(is_stellar_network("stellar:pubnet"));
assert!(!is_stellar_network("eip155:1"));
let g = "GBBO4ZDDZTSM2IUKQYBAST3CFHNPFXECGEFTGWTA2WELR2BIWDK57UVE";
let payload = ed25519_account_payload(g).unwrap();
let muxed = format!(
"{}",
stellar_strkey::ed25519::MuxedAccount {
ed25519: payload,
id: 7,
}
);
assert_eq!(ed25519_account_payload(&muxed), Some(payload));
assert!(is_facilitator_account(&[g.to_owned()], &muxed));
assert!(!is_facilitator_account(
&[g.to_owned()],
"GCQAXB2D77Y4C66CTGVH25H2RMUKMQJGOWUPK7UXGG5MAQBONUEKFQ4P"
));
assert!(StellarChainReference::PUBNET.rpc_url(None).is_err());
assert!(
StellarChainReference::PUBNET
.rpc_url(Some("https://rpc.example"))
.is_ok()
);
assert_eq!(
StellarChainReference::TESTNET.passphrase(),
STELLAR_TESTNET_PASSPHRASE
);
}
#[test]
fn token_deployment_parse() {
let addr: StellarAddress = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA"
.parse()
.unwrap();
let deployment = StellarTokenDeployment::new(StellarChainReference::TESTNET, addr, 7);
let parsed = deployment.parse("10.50").unwrap();
assert_eq!(parsed.amount, 105_000_000);
}
}