Skip to main content

r402_stellar/chain/
types.rs

1//! Wire format types for Stellar chain interactions.
2
3use std::fmt::{Debug, Display, Formatter};
4use std::str::FromStr;
5
6use compact_str::CompactString;
7use r402_core::amount::{MoneyAmount, MoneyAmountParseError};
8use r402_core::chain::{ChainId, DeployedTokenAmount};
9use serde::{Deserialize, Deserializer, Serialize, Serializer};
10use stellar_strkey::Strkey;
11
12/// The CAIP-2 namespace for Stellar chains.
13pub const STELLAR_NAMESPACE: &str = "stellar";
14
15/// Default Soroban RPC for testnet.
16pub const STELLAR_TESTNET_RPC_URL: &str = "https://soroban-testnet.stellar.org";
17
18/// Horizon API for testnet.
19pub const STELLAR_TESTNET_HORIZON_URL: &str = "https://horizon-testnet.stellar.org";
20
21/// Horizon API for pubnet.
22pub const STELLAR_PUBNET_HORIZON_URL: &str = "https://horizon.stellar.org";
23
24/// Pubnet network passphrase.
25pub const STELLAR_PUBNET_PASSPHRASE: &str = "Public Global Stellar Network ; September 2015";
26
27/// Testnet network passphrase.
28pub const STELLAR_TESTNET_PASSPHRASE: &str = "Test SDF Network ; September 2015";
29
30/// A Stellar chain reference (`pubnet` or `testnet`).
31#[derive(Clone, Copy, PartialEq, Eq, Hash)]
32pub enum StellarChainReference {
33    /// Stellar pubnet (`stellar:pubnet`).
34    Pubnet,
35    /// Stellar testnet (`stellar:testnet`).
36    Testnet,
37}
38
39impl StellarChainReference {
40    /// Stellar pubnet (`stellar:pubnet`).
41    pub const PUBNET: Self = Self::Pubnet;
42
43    /// Stellar testnet (`stellar:testnet`).
44    pub const TESTNET: Self = Self::Testnet;
45
46    /// All chain references with built-in support.
47    pub const ALL: &'static [Self] = &[Self::Pubnet, Self::Testnet];
48
49    /// Returns the CAIP-2 reference string.
50    #[must_use]
51    pub const fn as_str(self) -> &'static str {
52        match self {
53            Self::Pubnet => "pubnet",
54            Self::Testnet => "testnet",
55        }
56    }
57
58    /// Returns the Stellar network passphrase.
59    #[must_use]
60    pub const fn passphrase(self) -> &'static str {
61        match self {
62            Self::Pubnet => STELLAR_PUBNET_PASSPHRASE,
63            Self::Testnet => STELLAR_TESTNET_PASSPHRASE,
64        }
65    }
66
67    /// Returns the default Horizon URL for this network.
68    #[must_use]
69    pub const fn default_horizon_url(self) -> &'static str {
70        match self {
71            Self::Pubnet => STELLAR_PUBNET_HORIZON_URL,
72            Self::Testnet => STELLAR_TESTNET_HORIZON_URL,
73        }
74    }
75
76    /// Returns the default Soroban RPC URL, if one exists.
77    ///
78    /// Pubnet has no public default; the operator must supply an RPC URL.
79    #[must_use]
80    pub const fn default_rpc_url(self) -> Option<&'static str> {
81        match self {
82            Self::Pubnet => None,
83            Self::Testnet => Some(STELLAR_TESTNET_RPC_URL),
84        }
85    }
86
87    /// Resolves the RPC URL, requiring an operator URL on pubnet.
88    ///
89    /// # Errors
90    ///
91    /// Returns [`StellarRpcUrlError::PubnetRpcRequired`] when `rpc_url` is
92    /// empty on pubnet.
93    pub fn rpc_url(self, rpc_url: Option<&str>) -> Result<String, StellarRpcUrlError> {
94        if let Some(url) = rpc_url.filter(|u| !u.is_empty()) {
95            return Ok(url.to_owned());
96        }
97        match self {
98            Self::Testnet => Ok(STELLAR_TESTNET_RPC_URL.to_owned()),
99            Self::Pubnet => Err(StellarRpcUrlError::PubnetRpcRequired),
100        }
101    }
102}
103
104impl Debug for StellarChainReference {
105    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
106        write!(f, "StellarChainReference({})", self.as_str())
107    }
108}
109
110impl Display for StellarChainReference {
111    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
112        f.write_str(self.as_str())
113    }
114}
115
116impl FromStr for StellarChainReference {
117    type Err = StellarChainReferenceFormatError;
118
119    fn from_str(s: &str) -> Result<Self, Self::Err> {
120        match s {
121            "pubnet" => Ok(Self::Pubnet),
122            "testnet" => Ok(Self::Testnet),
123            other => Err(StellarChainReferenceFormatError::InvalidReference(
124                other.to_owned(),
125            )),
126        }
127    }
128}
129
130impl Serialize for StellarChainReference {
131    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
132    where
133        S: Serializer,
134    {
135        serializer.serialize_str(self.as_str())
136    }
137}
138
139impl<'de> Deserialize<'de> for StellarChainReference {
140    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
141    where
142        D: Deserializer<'de>,
143    {
144        let s = String::deserialize(deserializer)?;
145        s.parse().map_err(serde::de::Error::custom)
146    }
147}
148
149impl From<StellarChainReference> for ChainId {
150    fn from(value: StellarChainReference) -> Self {
151        Self::new(STELLAR_NAMESPACE, value.as_str())
152    }
153}
154
155impl TryFrom<ChainId> for StellarChainReference {
156    type Error = StellarChainReferenceFormatError;
157
158    fn try_from(value: ChainId) -> Result<Self, Self::Error> {
159        let (namespace, reference) = value.into_parts();
160        if namespace != STELLAR_NAMESPACE {
161            return Err(StellarChainReferenceFormatError::InvalidNamespace(
162                namespace,
163            ));
164        }
165        Self::from_str(&reference)
166            .map_err(|_| StellarChainReferenceFormatError::InvalidReference(reference))
167    }
168}
169
170/// Error type for parsing Stellar chain references.
171#[derive(Debug, thiserror::Error)]
172pub enum StellarChainReferenceFormatError {
173    /// The namespace was not `"stellar"`.
174    #[error("Invalid namespace {0}, expected stellar")]
175    InvalidNamespace(String),
176    /// The reference was not `pubnet` or `testnet`.
177    #[error("Invalid stellar chain reference {0}")]
178    InvalidReference(String),
179}
180
181/// Error resolving a Stellar RPC URL.
182#[derive(Debug, thiserror::Error, Clone, Copy)]
183pub enum StellarRpcUrlError {
184    /// Pubnet has no public default RPC; the operator must supply one.
185    #[error(
186        "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"
187    )]
188    PubnetRpcRequired,
189}
190
191/// Returns `true` when `network` is a canonical Stellar CAIP-2 identifier.
192#[must_use]
193pub fn is_stellar_network(network: &str) -> bool {
194    network == "stellar:pubnet" || network == "stellar:testnet"
195}
196
197/// Ed25519 payload of a G-account or muxed M-account strkey.
198///
199/// Contract (`C…`) and other strkey types return `None`. Used so facilitator
200/// safety checks treat `G…` and `M…` of the same key as the same account.
201#[must_use]
202pub fn ed25519_account_payload(address: &str) -> Option<[u8; 32]> {
203    match Strkey::from_string(address).ok()? {
204        Strkey::PublicKeyEd25519(pk) => Some(pk.0),
205        Strkey::MuxedAccountEd25519(muxed) => Some(muxed.ed25519),
206        _ => None,
207    }
208}
209
210/// Returns `true` when `candidate` is the same G/M ed25519 key as any
211/// facilitator address.
212#[must_use]
213pub fn is_facilitator_account(facilitator_addresses: &[String], candidate: &str) -> bool {
214    ed25519_account_payload(candidate).map_or_else(
215        || facilitator_addresses.iter().any(|addr| addr == candidate),
216        |key| {
217            facilitator_addresses
218                .iter()
219                .any(|addr| ed25519_account_payload(addr) == Some(key))
220        },
221    )
222}
223
224/// A Stellar address: G-account, C-account, or muxed M-account.
225///
226/// Stored as [`CompactString`]. Validation uses [`stellar_strkey`].
227#[derive(Clone, Debug, Hash, PartialEq, Eq)]
228pub struct StellarAddress(CompactString);
229
230impl StellarAddress {
231    /// Returns the address as a string.
232    #[must_use]
233    pub fn as_str(&self) -> &str {
234        self.0.as_str()
235    }
236
237    /// Returns `true` when this is a contract (`C…`) address.
238    #[must_use]
239    pub fn is_contract(&self) -> bool {
240        matches!(Strkey::from_string(self.as_str()), Ok(Strkey::Contract(_)))
241    }
242}
243
244impl Display for StellarAddress {
245    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
246        f.write_str(self.as_str())
247    }
248}
249
250impl FromStr for StellarAddress {
251    type Err = StellarAddressFormatError;
252
253    fn from_str(s: &str) -> Result<Self, Self::Err> {
254        match Strkey::from_string(s) {
255            Ok(
256                Strkey::PublicKeyEd25519(_) | Strkey::Contract(_) | Strkey::MuxedAccountEd25519(_),
257            ) => Ok(Self(CompactString::from(s))),
258            Ok(_) | Err(_) => Err(StellarAddressFormatError::Invalid(s.to_owned())),
259        }
260    }
261}
262
263impl Serialize for StellarAddress {
264    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
265    where
266        S: Serializer,
267    {
268        serializer.serialize_str(self.as_str())
269    }
270}
271
272impl<'de> Deserialize<'de> for StellarAddress {
273    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
274    where
275        D: Deserializer<'de>,
276    {
277        let s = String::deserialize(deserializer)?;
278        s.parse().map_err(serde::de::Error::custom)
279    }
280}
281
282impl AsRef<str> for StellarAddress {
283    fn as_ref(&self) -> &str {
284        self.as_str()
285    }
286}
287
288/// Errors that can occur when parsing a Stellar address.
289#[derive(Debug, thiserror::Error)]
290pub enum StellarAddressFormatError {
291    /// The string is not a G, C, or M Stellar address.
292    #[error("invalid stellar address: {0}")]
293    Invalid(String),
294}
295
296/// Parses a SEP-41 contract address (`C…` only).
297///
298/// # Errors
299///
300/// Returns [`StellarAddressFormatError`] when the string is not a contract.
301pub fn parse_contract_address(s: &str) -> Result<StellarAddress, StellarAddressFormatError> {
302    let address: StellarAddress = s.parse()?;
303    if address.is_contract() {
304        Ok(address)
305    } else {
306        Err(StellarAddressFormatError::Invalid(s.to_owned()))
307    }
308}
309
310/// SEP-41 atomic units as a decimal string; parsed as `i128`.
311#[derive(Clone, Debug, Eq, PartialEq)]
312pub struct StellarTokenAmount(CompactString);
313
314impl StellarTokenAmount {
315    /// Returns the decimal string.
316    #[must_use]
317    pub fn as_str(&self) -> &str {
318        self.0.as_str()
319    }
320
321    /// Parses the amount as `i128`.
322    ///
323    /// # Errors
324    ///
325    /// Returns [`StellarTokenAmountFormatError`] if the string is not a decimal `i128`.
326    pub fn as_i128(&self) -> Result<i128, StellarTokenAmountFormatError> {
327        self.0
328            .parse()
329            .map_err(|_| StellarTokenAmountFormatError::Invalid(self.0.to_string()))
330    }
331}
332
333impl Display for StellarTokenAmount {
334    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
335        f.write_str(self.as_str())
336    }
337}
338
339impl FromStr for StellarTokenAmount {
340    type Err = StellarTokenAmountFormatError;
341
342    fn from_str(s: &str) -> Result<Self, Self::Err> {
343        if s.is_empty()
344            || !(s.bytes().all(|b| b.is_ascii_digit())
345                || (s.starts_with('-')
346                    && s.len() > 1
347                    && s.bytes().skip(1).all(|b| b.is_ascii_digit())))
348        {
349            return Err(StellarTokenAmountFormatError::Invalid(s.to_owned()));
350        }
351        let _: i128 = s
352            .parse()
353            .map_err(|_| StellarTokenAmountFormatError::Invalid(s.to_owned()))?;
354        Ok(Self(CompactString::from(s)))
355    }
356}
357
358impl Serialize for StellarTokenAmount {
359    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
360    where
361        S: Serializer,
362    {
363        serializer.serialize_str(self.as_str())
364    }
365}
366
367impl<'de> Deserialize<'de> for StellarTokenAmount {
368    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
369    where
370        D: Deserializer<'de>,
371    {
372        let s = String::deserialize(deserializer)?;
373        s.parse().map_err(serde::de::Error::custom)
374    }
375}
376
377impl From<i128> for StellarTokenAmount {
378    fn from(value: i128) -> Self {
379        Self(CompactString::from(value.to_string()))
380    }
381}
382
383impl From<u128> for StellarTokenAmount {
384    fn from(value: u128) -> Self {
385        Self(CompactString::from(value.to_string()))
386    }
387}
388
389impl TryFrom<StellarTokenAmount> for i128 {
390    type Error = StellarTokenAmountFormatError;
391
392    fn try_from(value: StellarTokenAmount) -> Result<Self, Self::Error> {
393        value.as_i128()
394    }
395}
396
397/// Error parsing a SEP-41 token amount.
398#[derive(Debug, thiserror::Error)]
399pub enum StellarTokenAmountFormatError {
400    /// The string is not a signed decimal integer.
401    #[error("invalid stellar token amount: {0}")]
402    Invalid(String),
403}
404
405/// Information about a SEP-41 token deployment on a Stellar network.
406#[derive(Clone, Debug, Eq, PartialEq)]
407pub struct StellarTokenDeployment {
408    /// The Stellar network where this token is deployed.
409    pub chain_reference: StellarChainReference,
410    /// The SEP-41 contract address (`C…`).
411    pub address: StellarAddress,
412    /// The number of decimal places for this token.
413    pub decimals: u8,
414}
415
416impl StellarTokenDeployment {
417    /// Creates a new token deployment.
418    #[must_use]
419    pub const fn new(
420        chain_reference: StellarChainReference,
421        address: StellarAddress,
422        decimals: u8,
423    ) -> Self {
424        Self {
425            chain_reference,
426            address,
427            decimals,
428        }
429    }
430
431    /// Creates a deployed token amount with the given raw atomic units.
432    #[must_use]
433    pub fn amount(&self, v: i128) -> DeployedTokenAmount<i128, Self> {
434        DeployedTokenAmount {
435            amount: v,
436            token: self.clone(),
437        }
438    }
439
440    /// Parses a human-readable amount into a deployed token amount.
441    ///
442    /// # Errors
443    ///
444    /// Returns [`MoneyAmountParseError`] if the value cannot be parsed, exceeds
445    /// precision, or overflows `u128`.
446    pub fn parse<V>(&self, v: V) -> Result<DeployedTokenAmount<i128, Self>, MoneyAmountParseError>
447    where
448        V: TryInto<MoneyAmount>,
449        MoneyAmountParseError: From<<V as TryInto<MoneyAmount>>::Error>,
450    {
451        let amount: u128 = v.try_into()?.to_token_amount(self.decimals)?;
452        let amount = i128::try_from(amount).map_err(|_| MoneyAmountParseError::OutOfRange)?;
453        Ok(DeployedTokenAmount {
454            amount,
455            token: self.clone(),
456        })
457    }
458}
459
460#[cfg(test)]
461#[allow(clippy::unwrap_used, reason = "test assertions")]
462mod tests {
463    use super::*;
464
465    #[test]
466    fn destination_and_contract_addresses() {
467        assert!(
468            "GBBO4ZDDZTSM2IUKQYBAST3CFHNPFXECGEFTGWTA2WELR2BIWDK57UVE"
469                .parse::<StellarAddress>()
470                .is_ok()
471        );
472        assert!(
473            "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA"
474                .parse::<StellarAddress>()
475                .is_ok()
476        );
477        assert!("not-an-address".parse::<StellarAddress>().is_err());
478        assert!(
479            parse_contract_address("CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA")
480                .is_ok()
481        );
482        assert!(
483            parse_contract_address("GBBO4ZDDZTSM2IUKQYBAST3CFHNPFXECGEFTGWTA2WELR2BIWDK57UVE")
484                .is_err()
485        );
486    }
487
488    #[test]
489    fn token_amount_decimal_string() {
490        let amount: StellarTokenAmount = "10000000".parse().unwrap();
491        assert_eq!(amount.as_i128().unwrap(), 10_000_000);
492        assert!("".parse::<StellarTokenAmount>().is_err());
493        assert!("1.0".parse::<StellarTokenAmount>().is_err());
494    }
495
496    #[test]
497    fn chain_reference_roundtrip() {
498        let chain_id: ChainId = StellarChainReference::TESTNET.into();
499        assert_eq!(chain_id.to_string(), "stellar:testnet");
500        let back = StellarChainReference::try_from(chain_id).unwrap();
501        assert_eq!(back, StellarChainReference::TESTNET);
502        assert!(is_stellar_network("stellar:pubnet"));
503        assert!(!is_stellar_network("eip155:1"));
504        let g = "GBBO4ZDDZTSM2IUKQYBAST3CFHNPFXECGEFTGWTA2WELR2BIWDK57UVE";
505        let payload = ed25519_account_payload(g).unwrap();
506        let muxed = format!(
507            "{}",
508            stellar_strkey::ed25519::MuxedAccount {
509                ed25519: payload,
510                id: 7,
511            }
512        );
513        assert_eq!(ed25519_account_payload(&muxed), Some(payload));
514        assert!(is_facilitator_account(&[g.to_owned()], &muxed));
515        assert!(!is_facilitator_account(
516            &[g.to_owned()],
517            "GCQAXB2D77Y4C66CTGVH25H2RMUKMQJGOWUPK7UXGG5MAQBONUEKFQ4P"
518        ));
519        assert!(StellarChainReference::PUBNET.rpc_url(None).is_err());
520        assert!(
521            StellarChainReference::PUBNET
522                .rpc_url(Some("https://rpc.example"))
523                .is_ok()
524        );
525        assert_eq!(
526            StellarChainReference::TESTNET.passphrase(),
527            STELLAR_TESTNET_PASSPHRASE
528        );
529    }
530
531    #[test]
532    fn token_deployment_parse() {
533        let addr: StellarAddress = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA"
534            .parse()
535            .unwrap();
536        let deployment = StellarTokenDeployment::new(StellarChainReference::TESTNET, addr, 7);
537        let parsed = deployment.parse("10.50").unwrap();
538        assert_eq!(parsed.amount, 105_000_000);
539    }
540}