Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions contract/cmmty/cli/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
[package]
name = "smalda-anchor"
version = "0.1.0"
edition = "2021"

[[bin]]
name = "smalda-anchor"
path = "src/main.rs"

[dependencies]
reqwest = { version = "0.11", features = ["json", "blocking"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
anyhow = "1.0"
131 changes: 131 additions & 0 deletions contract/cmmty/cli/src/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
use std::env;

fn parse_args(args: &[String]) -> Result<(String, Option<String>), String> {
let mut hash: Option<String> = None;
let mut memo: Option<String> = None;
let mut i = 0;

while i < args.len() {
match args[i].as_str() {
"--hash" => {
i += 1;
hash = Some(args.get(i).ok_or("--hash requires a value")?.clone());
}
"--memo" => {
i += 1;
memo = Some(args.get(i).ok_or("--memo requires a value")?.clone());
}
other => return Err(format!("unknown argument: {}", other)),
}
i += 1;
}

let hash = hash.ok_or("--hash is required")?;

// Validate: must be 64 hex characters
if hash.len() != 64 || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(format!(
"invalid hash format: expected 64 hex characters, got '{}'",
hash
));
}

Ok((hash, memo))
}

fn anchor(hash: &str, memo: Option<&str>, secret_key: &str, horizon_url: &str) -> anyhow::Result<()> {
let client = reqwest::blocking::Client::new();

// Build memo text (max 28 bytes)
let memo_text = memo.unwrap_or(hash);
let memo_truncated = &memo_text[..memo_text.len().min(28)];

// Submit a simple payment-like transaction memo to Horizon (stub: POST to /transactions)
// In production this would sign and submit a real Stellar transaction.
let payload = serde_json::json!({
"hash": hash,
"memo": memo_truncated,
"secret_key": secret_key,
});

let url = format!("{}/transactions", horizon_url.trim_end_matches('/'));
let resp = client.post(&url).json(&payload).send()?;

if resp.status().is_success() {
let body: serde_json::Value = resp.json()?;
let tx_hash = body["hash"].as_str().unwrap_or("unknown");
let ledger = body["ledger"].as_u64().unwrap_or(0);
println!("transaction_hash: {}", tx_hash);
println!("ledger: {}", ledger);
} else {
let status = resp.status();
let text = resp.text().unwrap_or_default();
anyhow::bail!("Stellar error {}: {}", status, text);
}

Ok(())
}

fn main() {
let args: Vec<String> = env::args().skip(1).collect();

let (hash, memo) = match parse_args(&args) {
Ok(v) => v,
Err(e) => {
eprintln!("error: {}", e);
eprintln!("usage: smalda-anchor --hash SHA256_HASH [--memo string]");
std::process::exit(1);
}
};

let secret_key = match env::var("STELLAR_SECRET_KEY") {
Ok(k) => k,
Err(_) => {
eprintln!("error: STELLAR_SECRET_KEY environment variable is not set");
std::process::exit(1);
}
};

let horizon_url = env::var("STELLAR_HORIZON_URL")
.unwrap_or_else(|_| "https://horizon-testnet.stellar.org".to_string());

if let Err(e) = anchor(&hash, memo.as_deref(), &secret_key, &horizon_url) {
eprintln!("error: {}", e);
std::process::exit(1);
}
}

#[cfg(test)]
mod tests {
use super::*;

fn args(v: &[&str]) -> Vec<String> {
v.iter().map(|s| s.to_string()).collect()
}

#[test]
fn valid_hash_no_memo() {
let (hash, memo) = parse_args(&args(&["--hash", &"a".repeat(64)])).unwrap();
assert_eq!(hash, "a".repeat(64));
assert!(memo.is_none());
}

#[test]
fn valid_hash_with_memo() {
let (hash, memo) = parse_args(&args(&["--hash", &"b".repeat(64), "--memo", "test"])).unwrap();
assert_eq!(hash, "b".repeat(64));
assert_eq!(memo.as_deref(), Some("test"));
}

#[test]
fn invalid_hash_format_errors() {
let err = parse_args(&args(&["--hash", "tooshort"])).unwrap_err();
assert!(err.contains("invalid hash format"));
}

#[test]
fn missing_hash_errors() {
let err = parse_args(&args(&["--memo", "only-memo"])).unwrap_err();
assert!(err.contains("--hash is required"));
}
}
106 changes: 106 additions & 0 deletions contract/cmmty/multi_signer/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
use tracing::info;

#[derive(Debug, Clone)]
pub struct SignerAccount {
pub secret_key: String,
pub balance_xlm: f64,
}

/// Parse comma-separated STELLAR_SECRET_KEYS into a list of `SignerAccount`s
/// with placeholder balances (0.0). Call `refresh_balances` to populate them.
pub fn load_accounts(keys_csv: &str) -> Vec<SignerAccount> {
keys_csv
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|key| SignerAccount {
secret_key: key.to_string(),
balance_xlm: 0.0,
})
.collect()
}

/// Select the account with the highest balance. Returns `None` if the list is empty.
pub fn select_best(accounts: &[SignerAccount]) -> Option<&SignerAccount> {
accounts
.iter()
.max_by(|a, b| a.balance_xlm.partial_cmp(&b.balance_xlm).unwrap())
}

/// Attempt to use `accounts` in descending balance order, calling `try_sign` for each.
/// Returns the secret key of the account that succeeded, or an error if all fail.
pub fn sign_with_fallback<F>(accounts: &[SignerAccount], mut try_sign: F) -> anyhow::Result<String>
where
F: FnMut(&str) -> anyhow::Result<()>,
{
let mut sorted: Vec<&SignerAccount> = accounts.iter().collect();
sorted.sort_by(|a, b| b.balance_xlm.partial_cmp(&a.balance_xlm).unwrap());

for account in &sorted {
match try_sign(&account.secret_key) {
Ok(()) => {
info!("Transaction signed with account ending in ...{}", &account.secret_key[account.secret_key.len().saturating_sub(4)..]);
return Ok(account.secret_key.clone());
}
Err(e) => {
info!("Account failed, trying next: {}", e);
}
}
}

anyhow::bail!("all signing accounts failed")
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn load_accounts_parses_csv() {
let accounts = load_accounts("KEY1, KEY2, KEY3");
assert_eq!(accounts.len(), 3);
assert_eq!(accounts[0].secret_key, "KEY1");
assert_eq!(accounts[2].secret_key, "KEY3");
}

#[test]
fn select_best_picks_highest_balance() {
let accounts = vec![
SignerAccount { secret_key: "A".into(), balance_xlm: 5.0 },
SignerAccount { secret_key: "B".into(), balance_xlm: 50.0 },
SignerAccount { secret_key: "C".into(), balance_xlm: 20.0 },
];
let best = select_best(&accounts).unwrap();
assert_eq!(best.secret_key, "B");
}

#[test]
fn sign_with_fallback_uses_next_on_failure() {
let accounts = vec![
SignerAccount { secret_key: "FAIL".into(), balance_xlm: 100.0 },
SignerAccount { secret_key: "OK".into(), balance_xlm: 50.0 },
];

let used = sign_with_fallback(&accounts, |key| {
if key == "FAIL" {
anyhow::bail!("simulated failure")
} else {
Ok(())
}
})
.unwrap();

assert_eq!(used, "OK");
}

#[test]
fn sign_with_fallback_errors_when_all_fail() {
let accounts = vec![
SignerAccount { secret_key: "A".into(), balance_xlm: 10.0 },
SignerAccount { secret_key: "B".into(), balance_xlm: 5.0 },
];

let result = sign_with_fallback(&accounts, |_| anyhow::bail!("always fails"));
assert!(result.is_err());
}
}
119 changes: 119 additions & 0 deletions contract/cmmty/startup_checks/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
use serde::Deserialize;
use tracing::warn;

const LOW_BALANCE_THRESHOLD_XLM: f64 = 10.0;

#[derive(Debug, Clone)]
pub struct BalanceCheckResult {
pub balance_xlm: f64,
pub is_sufficient: bool,
}

#[derive(Debug, Deserialize)]
struct HorizonAccountBalance {
balance: String,
asset_type: String,
}

#[derive(Debug, Deserialize)]
struct HorizonAccountResponse {
balances: Vec<HorizonAccountBalance>,
}

/// Fetches the native XLM balance for `account_id` from Horizon.
pub async fn fetch_xlm_balance(
horizon_url: &str,
account_id: &str,
client: &reqwest::Client,
) -> anyhow::Result<f64> {
let url = format!("{}/accounts/{}", horizon_url.trim_end_matches('/'), account_id);
let resp: HorizonAccountResponse = client.get(&url).send().await?.json().await?;

let native = resp
.balances
.iter()
.find(|b| b.asset_type == "native")
.ok_or_else(|| anyhow::anyhow!("no native balance found"))?;

Ok(native.balance.parse()?)
}

/// Runs the startup balance check. Logs a WARNING if balance < 10 XLM.
/// Returns a `BalanceCheckResult` for inclusion in health responses.
pub async fn run(
horizon_url: &str,
account_id: &str,
client: &reqwest::Client,
) -> anyhow::Result<BalanceCheckResult> {
let balance_xlm = fetch_xlm_balance(horizon_url, account_id, client).await?;
let is_sufficient = balance_xlm >= LOW_BALANCE_THRESHOLD_XLM;

if !is_sufficient {
warn!(
"Stellar account {} has low balance: {:.7} XLM (threshold: {} XLM)",
account_id, balance_xlm, LOW_BALANCE_THRESHOLD_XLM
);
}

Ok(BalanceCheckResult {
balance_xlm,
is_sufficient,
})
}

#[cfg(test)]
mod tests {
use super::*;
use httpmock::prelude::*;

fn make_account_body(balance: &str) -> String {
format!(
r#"{{"balances":[{{"balance":"{}","asset_type":"native"}}]}}"#,
balance
)
}

#[tokio::test]
async fn sufficient_balance_returns_ok() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path_contains("/accounts/");
then.status(200).body(make_account_body("100.0000000"));
});

let client = reqwest::Client::new();
let result = run(&server.base_url(), "GACCOUNT", &client).await.unwrap();

assert!(result.is_sufficient);
assert!((result.balance_xlm - 100.0).abs() < 0.001);
}

#[tokio::test]
async fn insufficient_balance_flags_warning() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path_contains("/accounts/");
then.status(200).body(make_account_body("5.0000000"));
});

let client = reqwest::Client::new();
let result = run(&server.base_url(), "GACCOUNT", &client).await.unwrap();

assert!(!result.is_sufficient);
assert!((result.balance_xlm - 5.0).abs() < 0.001);
}

#[tokio::test]
async fn exactly_threshold_is_sufficient() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path_contains("/accounts/");
then.status(200).body(make_account_body("10.0000000"));
});

let client = reqwest::Client::new();
let result = run(&server.base_url(), "GACCOUNT", &client).await.unwrap();

assert!(result.is_sufficient);
}
}
Loading
Loading