1use 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
12pub const STELLAR_NAMESPACE: &str = "stellar";
14
15pub const STELLAR_TESTNET_RPC_URL: &str = "https://soroban-testnet.stellar.org";
17
18pub const STELLAR_TESTNET_HORIZON_URL: &str = "https://horizon-testnet.stellar.org";
20
21pub const STELLAR_PUBNET_HORIZON_URL: &str = "https://horizon.stellar.org";
23
24pub const STELLAR_PUBNET_PASSPHRASE: &str = "Public Global Stellar Network ; September 2015";
26
27pub const STELLAR_TESTNET_PASSPHRASE: &str = "Test SDF Network ; September 2015";
29
30#[derive(Clone, Copy, PartialEq, Eq, Hash)]
32pub enum StellarChainReference {
33 Pubnet,
35 Testnet,
37}
38
39impl StellarChainReference {
40 pub const PUBNET: Self = Self::Pubnet;
42
43 pub const TESTNET: Self = Self::Testnet;
45
46 pub const ALL: &'static [Self] = &[Self::Pubnet, Self::Testnet];
48
49 #[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 #[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 #[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 #[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 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#[derive(Debug, thiserror::Error)]
172pub enum StellarChainReferenceFormatError {
173 #[error("Invalid namespace {0}, expected stellar")]
175 InvalidNamespace(String),
176 #[error("Invalid stellar chain reference {0}")]
178 InvalidReference(String),
179}
180
181#[derive(Debug, thiserror::Error, Clone, Copy)]
183pub enum StellarRpcUrlError {
184 #[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#[must_use]
193pub fn is_stellar_network(network: &str) -> bool {
194 network == "stellar:pubnet" || network == "stellar:testnet"
195}
196
197#[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#[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#[derive(Clone, Debug, Hash, PartialEq, Eq)]
228pub struct StellarAddress(CompactString);
229
230impl StellarAddress {
231 #[must_use]
233 pub fn as_str(&self) -> &str {
234 self.0.as_str()
235 }
236
237 #[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#[derive(Debug, thiserror::Error)]
290pub enum StellarAddressFormatError {
291 #[error("invalid stellar address: {0}")]
293 Invalid(String),
294}
295
296pub 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#[derive(Clone, Debug, Eq, PartialEq)]
312pub struct StellarTokenAmount(CompactString);
313
314impl StellarTokenAmount {
315 #[must_use]
317 pub fn as_str(&self) -> &str {
318 self.0.as_str()
319 }
320
321 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#[derive(Debug, thiserror::Error)]
399pub enum StellarTokenAmountFormatError {
400 #[error("invalid stellar token amount: {0}")]
402 Invalid(String),
403}
404
405#[derive(Clone, Debug, Eq, PartialEq)]
407pub struct StellarTokenDeployment {
408 pub chain_reference: StellarChainReference,
410 pub address: StellarAddress,
412 pub decimals: u8,
414}
415
416impl StellarTokenDeployment {
417 #[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 #[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 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}