use color_eyre::{eyre::bail, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use crate::project::Network;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Account {
pub name: String,
pub address: String,
pub network: Network,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccountStore {
pub accounts: Vec<Account>,
}
impl AccountStore {
pub fn new() -> Self {
Self {
accounts: Vec::new(),
}
}
pub async fn load(path: &Path) -> Result<Self> {
if !tokio::fs::try_exists(path).await.unwrap_or(false) {
return Ok(Self::new());
}
let content = tokio::fs::read_to_string(path).await?;
let store: Self = toml::from_str(&content)?;
Ok(store)
}
pub async fn save(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let content = toml::to_string_pretty(self)?;
tokio::fs::write(path, content).await?;
Ok(())
}
pub fn add(&mut self, account: Account) {
self.accounts.push(account);
}
pub fn get(&self, name: &str) -> Option<&Account> {
self.accounts.iter().find(|a| a.name == name)
}
pub fn list(&self) -> &[Account] {
&self.accounts
}
#[allow(dead_code)]
pub fn remove(&mut self, name: &str) -> bool {
let len = self.accounts.len();
self.accounts.retain(|a| a.name != name);
self.accounts.len() < len
}
}
pub fn get_accounts_path(project_dir: &Path) -> PathBuf {
project_dir.join(".procyon").join("accounts.toml")
}
pub fn get_network_rpc_url(network: &Network) -> Result<&'static str> {
match network {
Network::Local => Ok("http://localhost:8000/rpc"),
Network::Testnet => Ok("https://soroban-testnet.stellar.org"),
Network::Mainnet => bail!(
"No public mainnet RPC endpoint exists. Configure a provider endpoint to use mainnet."
),
}
}
pub fn get_network_horizon_url(network: &Network) -> &'static str {
match network {
Network::Local => "http://localhost:8000",
Network::Testnet => "https://horizon-testnet.stellar.org",
Network::Mainnet => "https://horizon.stellar.org",
}
}
fn is_stellar_address(candidate: &str) -> bool {
candidate.len() == 56
&& candidate.starts_with('G')
&& candidate
.bytes()
.all(|b| b.is_ascii_uppercase() || b.is_ascii_digit())
}
pub async fn query_balance(address: &str, network: &Network) -> Result<String> {
if !is_stellar_address(address) {
bail!("Not a valid Stellar account address: {}", address);
}
let url = format!("{}/accounts/{}", get_network_horizon_url(network), address);
let response = reqwest::Client::new().get(&url).send().await?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
bail!("Account does not exist on {}: {}", network, address);
}
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
bail!("Horizon error {}: {}", status, body);
}
let json: serde_json::Value = response.json().await?;
let balances = json["balances"].as_array().cloned().unwrap_or_default();
let mut result = String::new();
for balance in &balances {
let amount = balance["balance"].as_str().unwrap_or("0");
if balance["asset_type"].as_str() == Some("native") {
result.push_str(&format!("XLM: {}\n", amount));
} else {
let code = balance["asset_code"].as_str().unwrap_or("???");
result.push_str(&format!("{}: {}\n", code, amount));
}
}
if result.is_empty() {
result = format!("Account {} holds no balances", address);
}
Ok(result)
}