Skip to content
Open
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
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ mod middleware;
mod models;
mod routes;
mod services;
pub mod validation;

pub use config::Config;

Expand Down
21 changes: 2 additions & 19 deletions src/routes/accounts.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
use crate::{
error::{AppError, AppResult},
error::AppResult,
middleware::auth::AuthUser,
services::stellar::StellarService,
validation::validate_stellar_address,
AppState,
};
use axum::{
Expand Down Expand Up @@ -57,21 +58,3 @@ pub async fn get_balances(
}
})))
}

// ─── Helpers ──────────────────────────────────────────────────────────────────

/// Rudimentary Stellar address validation — must be 56 chars and start with G.
fn validate_stellar_address(address: &str) -> AppResult<()> {
if address.len() != 56 || !address.starts_with('G') {
return Err(AppError::BadRequest(
"Invalid Stellar address: must be a 56-character G... public key".into(),
));
}
// Ensure it's alphanumeric (base32).
if !address.chars().all(|c| c.is_ascii_alphanumeric()) {
return Err(AppError::BadRequest(
"Invalid Stellar address: contains non-base32 characters".into(),
));
}
Ok(())
}
6 changes: 6 additions & 0 deletions src/routes/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use crate::{
error::{AppError, AppResult},
middleware::auth::issue_jwt,
models::user::{AuthResponse, CreateUserRequest, LoginRequest, User, UserRow},
validation::validate_stellar_address,
AppState,
};
use axum::{extract::State, Json};
Expand Down Expand Up @@ -31,6 +32,11 @@ pub async fn register(
return Err(AppError::Validation("full_name is required".into()));
}

// Validate optional stellar address if provided.
if let Some(ref addr) = req.stellar_address {
validate_stellar_address(addr)?;
}

// Check uniqueness.
let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)")
.bind(&email)
Expand Down
11 changes: 7 additions & 4 deletions src/services/escrow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use crate::{
stellar::StellarService,
transaction::TransactionService,
},
validation::validate_stellar_address,
};
use chrono::Utc;
use sqlx::PgPool;
Expand Down Expand Up @@ -58,11 +59,13 @@ impl EscrowService {
if amount <= 0.0 {
return Err(AppError::Validation("amount must be positive".into()));
}
if req.depositor_account.trim().is_empty() || req.beneficiary_account.trim().is_empty() {
return Err(AppError::Validation(
"depositor_account and beneficiary_account are required".into(),
));

validate_stellar_address(&req.depositor_account)?;
validate_stellar_address(&req.beneficiary_account)?;
if let Some(ref arbiter) = req.arbiter_account {
validate_stellar_address(arbiter)?;
}

if req.unlock_time <= Utc::now() {
return Err(AppError::Validation(
"unlock_time must be in the future".into(),
Expand Down
9 changes: 4 additions & 5 deletions src/services/subscription.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ use crate::{
stellar::StellarService,
transaction::TransactionService,
},
validation::validate_stellar_address,
};
use chrono::{Duration as ChronoDuration, Utc};
use sqlx::PgPool;
Expand Down Expand Up @@ -69,11 +70,9 @@ impl SubscriptionService {
"interval_seconds must be positive".into(),
));
}
if req.payer_account.trim().is_empty() || req.recipient_account.trim().is_empty() {
return Err(AppError::Validation(
"payer_account and recipient_account are required".into(),
));
}

validate_stellar_address(&req.payer_account)?;
validate_stellar_address(&req.recipient_account)?;

let next_execution_at = req
.first_execution_at
Expand Down
62 changes: 62 additions & 0 deletions src/validation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
use crate::error::{AppError, AppResult};

/// Validates that a string is a canonical Stellar Ed25519 public key (`G...` address).
///
/// Checks:
/// 1. Address is non-empty and starts with 'G' (public key) or 'M' (multiplexed).
/// 2. Address matches Stellar strkey encoding (valid Base32 and checksum).
///
/// Uses `stellar_strkey::ed25519::PublicKey` for standard `G...` keys and
/// `stellar_strkey::ed25519::Med25519PublicKey` for `M...` multiplexed keys.
pub fn validate_stellar_address(address: &str) -> AppResult<()> {
let trimmed = address.trim();
if trimmed.is_empty() {
return Err(AppError::Validation(
"Stellar address cannot be empty".into(),
));
}

if trimmed.starts_with('G') {
stellar_strkey::ed25519::PublicKey::from_string(trimmed).map_err(|_| {
AppError::Validation(
"Invalid Stellar address: must be a valid 56-character base32 Ed25519 public key".into(),
)
})?;
Ok(())
} else if trimmed.starts_with('M') {
stellar_strkey::ed25519::Med25519PublicKey::from_string(trimmed).map_err(|_| {
AppError::Validation(
"Invalid Stellar multiplexed address: invalid checksum or format".into(),
)
})?;
Ok(())
} else {
Err(AppError::Validation(
"Invalid Stellar address: must start with 'G' (or 'M' for multiplexed accounts)".into(),
))
}
}

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

#[test]
fn test_valid_stellar_address() {
// Canonical valid Stellar public key
assert!(validate_stellar_address("GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI").is_ok());
assert!(validate_stellar_address("GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN").is_ok());
}

#[test]
fn test_invalid_stellar_address() {
// Wrong length
assert!(validate_stellar_address("GBZXN").is_err());
// Wrong prefix
assert!(validate_stellar_address("SBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI").is_err());
// Invalid checksum
assert!(validate_stellar_address("GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMAD0").is_err());
// Empty
assert!(validate_stellar_address("").is_err());
}
}