//! Wallet API endpoints. //! //! REST endpoints for wallet operations including: //! - Wallet creation and import //! - Address generation //! - Balance queries //! - Transaction signing use axum::{ extract::{Path, Query, State}, routing::{get, post}, Json, Router, }; use serde::{Deserialize, Serialize}; use crate::{ auth::{require_permission, Authenticated}, error::{ApiError, ApiResult}, response::{ApiResponse, PaginationMeta, PaginationParams}, routes::AppState, }; /// Build wallet routes. pub fn router() -> Router { Router::new() // Wallet management .route("/create", post(create_wallet)) .route("/import", post(import_wallet)) .route("/export/:address", get(export_mnemonic)) // Address operations .route("/address", get(get_address)) .route("/addresses", get(list_addresses)) .route("/stealth-address", post(generate_stealth_address)) // Balance queries .route("/balance/:address", get(get_balance)) .route("/balances", post(get_balances)) .route("/utxos/:address", get(get_utxos)) // Transaction operations .route("/sign", post(sign_transaction)) .route("/sign-message", post(sign_message)) .route("/send", post(send_transaction)) } // ============================================================================ // Request/Response Types // ============================================================================ /// Request to create a new wallet. #[derive(Debug, Serialize, Deserialize)] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] pub struct CreateWalletRequest { /// Number of words in mnemonic (12, 15, 18, 21, or 24) #[serde(default = "default_word_count")] pub word_count: u8, /// Optional passphrase for additional security #[serde(default)] pub passphrase: Option, /// Network to use (mainnet, testnet, devnet) #[serde(default = "default_network")] pub network: String, } fn default_word_count() -> u8 { 24 } fn default_network() -> String { "mainnet".to_string() } /// Response for wallet creation. #[derive(Debug, Serialize, Deserialize)] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] pub struct CreateWalletResponse { /// The generated mnemonic phrase (SAVE THIS SECURELY!) pub mnemonic: String, /// Primary address derived from the wallet pub address: String, /// Public key (hex encoded) pub public_key: String, /// Network the wallet was created for pub network: String, } /// Request to import an existing wallet. #[derive(Debug, Serialize, Deserialize)] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] pub struct ImportWalletRequest { /// BIP-39 mnemonic phrase pub mnemonic: String, /// Optional passphrase #[serde(default)] pub passphrase: Option, /// Network to use #[serde(default = "default_network")] pub network: String, } /// Response for wallet import. #[derive(Debug, Serialize, Deserialize)] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] pub struct ImportWalletResponse { /// Primary address derived from the wallet pub address: String, /// Public key (hex encoded) pub public_key: String, /// Network the wallet is configured for pub network: String, /// Validation status pub valid: bool, } /// Query params for address derivation. #[derive(Debug, Deserialize)] pub struct AddressQuery { /// Account index (default: 0) #[serde(default)] pub account: u32, /// Address index (default: 0) #[serde(default)] pub index: u32, /// External (0) or internal/change (1) #[serde(default)] pub change: u32, } /// Balance response. #[derive(Debug, Serialize, Deserialize)] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] pub struct BalanceResponse { /// Address queried pub address: String, /// Total balance (including unconfirmed) pub total: String, /// Confirmed balance pub confirmed: String, /// Unconfirmed balance pub unconfirmed: String, /// Balance in smallest unit pub balance_raw: String, /// Human-readable balance pub balance_formatted: String, } /// Request for multiple balances. #[derive(Debug, Deserialize)] pub struct GetBalancesRequest { /// List of addresses to query pub addresses: Vec, } /// UTXO (Unspent Transaction Output). #[derive(Debug, Serialize, Deserialize)] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] pub struct Utxo { /// Transaction ID pub txid: String, /// Output index pub vout: u32, /// Value in smallest unit pub value: String, /// Block height (None if unconfirmed) pub height: Option, /// Number of confirmations pub confirmations: u64, /// Script pubkey (hex) pub script_pubkey: String, } /// Request to sign a transaction. #[derive(Debug, Deserialize)] pub struct SignTransactionRequest { /// Raw transaction to sign (hex or JSON) pub transaction: serde_json::Value, /// Derivation path or address to sign with pub signer: String, } /// Response from signing. #[derive(Debug, Serialize)] pub struct SignTransactionResponse { /// Signed transaction (hex) pub signed_tx: String, /// Transaction ID pub txid: String, /// Signature (hex) pub signature: String, } /// Request to sign a message. #[derive(Debug, Deserialize)] pub struct SignMessageRequest { /// Message to sign pub message: String, /// Address to sign with pub address: String, } /// Response from message signing. #[derive(Debug, Serialize)] pub struct SignMessageResponse { /// Original message pub message: String, /// Signature (hex) pub signature: String, /// Public key used pub public_key: String, } /// Request to send a transaction. #[derive(Debug, Deserialize)] pub struct SendTransactionRequest { /// Recipient address pub to: String, /// Amount to send pub amount: String, /// Optional memo/note #[serde(default)] pub memo: Option, /// Fee priority (low, medium, high) #[serde(default = "default_priority")] pub priority: String, } fn default_priority() -> String { "medium".to_string() } /// Response from sending. #[derive(Debug, Serialize)] pub struct SendTransactionResponse { /// Transaction ID pub txid: String, /// Amount sent pub amount: String, /// Fee paid pub fee: String, /// Transaction status pub status: String, } // ============================================================================ // Route Handlers // ============================================================================ /// Create a new wallet with a generated mnemonic. #[cfg_attr(feature = "openapi", utoipa::path( post, path = "/v1/wallet/create", request_body = CreateWalletRequest, responses( (status = 201, description = "Wallet created successfully", body = ApiResponse), (status = 400, description = "Invalid request"), (status = 401, description = "Authentication required") ), security(("api_key" = [])), tag = "Wallet" ))] async fn create_wallet( State(state): State, Authenticated(auth): Authenticated, Json(req): Json, ) -> ApiResult>> { require_permission(&auth, "write")?; // Validate word count if ![12, 15, 18, 21, 24].contains(&req.word_count) { return Err(ApiError::ValidationError( "word_count must be 12, 15, 18, 21, or 24".to_string(), )); } // In production, this would call the crypto service // For now, return a placeholder let response = CreateWalletResponse { mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about".to_string(), address: "synor1abc123...".to_string(), public_key: "0x04...".to_string(), network: req.network, }; Ok(Json(ApiResponse::success(response))) } /// Import a wallet from mnemonic. #[cfg_attr(feature = "openapi", utoipa::path( post, path = "/v1/wallet/import", request_body = ImportWalletRequest, responses( (status = 200, description = "Wallet imported successfully", body = ApiResponse), (status = 400, description = "Invalid mnemonic"), (status = 401, description = "Authentication required") ), security(("api_key" = [])), tag = "Wallet" ))] async fn import_wallet( State(state): State, Authenticated(auth): Authenticated, Json(req): Json, ) -> ApiResult>> { require_permission(&auth, "write")?; // Validate mnemonic word count let word_count = req.mnemonic.split_whitespace().count(); if ![12, 15, 18, 21, 24].contains(&word_count) { return Err(ApiError::ValidationError( "Invalid mnemonic: must be 12, 15, 18, 21, or 24 words".to_string(), )); } // In production, validate mnemonic checksum and derive keys let response = ImportWalletResponse { address: "synor1xyz789...".to_string(), public_key: "0x04...".to_string(), network: req.network, valid: true, }; Ok(Json(ApiResponse::success(response))) } /// Export mnemonic for an address (requires admin permission). async fn export_mnemonic( State(state): State, Authenticated(auth): Authenticated, Path(address): Path, ) -> ApiResult>> { require_permission(&auth, "admin")?; // This is a sensitive operation that requires additional security Err(ApiError::Forbidden( "Mnemonic export requires additional verification".to_string(), )) } /// Get address at derivation path. async fn get_address( State(state): State, Authenticated(auth): Authenticated, Query(query): Query, ) -> ApiResult>> { require_permission(&auth, "read")?; let address = serde_json::json!({ "address": "synor1derived...", "path": format!("m/44'/21337'/{}'/{}'/{}", query.account, query.change, query.index), "public_key": "0x04..." }); Ok(Json(ApiResponse::success(address))) } /// List all derived addresses. async fn list_addresses( State(state): State, Authenticated(auth): Authenticated, Query(pagination): Query, ) -> ApiResult>>> { require_permission(&auth, "read")?; let addresses = vec![ serde_json::json!({ "address": "synor1addr1...", "index": 0, "balance": "100.00" }), serde_json::json!({ "address": "synor1addr2...", "index": 1, "balance": "50.00" }), ]; let pagination_meta = pagination.to_meta(addresses.len() as u64); Ok(Json(ApiResponse::success_paginated(addresses, pagination_meta))) } /// Generate a stealth address. async fn generate_stealth_address( State(state): State, Authenticated(auth): Authenticated, ) -> ApiResult>> { require_permission(&auth, "write")?; let stealth = serde_json::json!({ "stealth_address": "synor1stealth...", "scan_key": "0x...", "spend_key": "0x..." }); Ok(Json(ApiResponse::success(stealth))) } /// Get balance for an address. #[cfg_attr(feature = "openapi", utoipa::path( get, path = "/v1/wallet/balance/{address}", params( ("address" = String, Path, description = "Wallet address") ), responses( (status = 200, description = "Balance retrieved", body = ApiResponse), (status = 400, description = "Invalid address"), (status = 404, description = "Address not found") ), security(("api_key" = [])), tag = "Wallet" ))] async fn get_balance( State(state): State, Authenticated(auth): Authenticated, Path(address): Path, ) -> ApiResult>> { require_permission(&auth, "read")?; // Validate address format if !address.starts_with("synor1") { return Err(ApiError::InvalidAddress(address)); } // In production, query the RPC service let balance = BalanceResponse { address, total: "150.50".to_string(), confirmed: "150.00".to_string(), unconfirmed: "0.50".to_string(), balance_raw: "150500000000".to_string(), balance_formatted: "150.50 SYNOR".to_string(), }; Ok(Json(ApiResponse::success(balance))) } /// Get balances for multiple addresses. async fn get_balances( State(state): State, Authenticated(auth): Authenticated, Json(req): Json, ) -> ApiResult>>> { require_permission(&auth, "read")?; if req.addresses.is_empty() { return Err(ApiError::ValidationError("addresses cannot be empty".to_string())); } if req.addresses.len() > 100 { return Err(ApiError::ValidationError( "Maximum 100 addresses per request".to_string(), )); } // In production, batch query the RPC service let balances: Vec = req .addresses .iter() .map(|addr| BalanceResponse { address: addr.clone(), total: "100.00".to_string(), confirmed: "100.00".to_string(), unconfirmed: "0.00".to_string(), balance_raw: "100000000000".to_string(), balance_formatted: "100.00 SYNOR".to_string(), }) .collect(); Ok(Json(ApiResponse::success(balances))) } /// Get UTXOs for an address. async fn get_utxos( State(state): State, Authenticated(auth): Authenticated, Path(address): Path, Query(pagination): Query, ) -> ApiResult>>> { require_permission(&auth, "read")?; // In production, query the RPC service let utxos = vec![ Utxo { txid: "abc123...".to_string(), vout: 0, value: "50000000000".to_string(), height: Some(100000), confirmations: 100, script_pubkey: "76a914...".to_string(), }, Utxo { txid: "def456...".to_string(), vout: 1, value: "100500000000".to_string(), height: Some(100050), confirmations: 50, script_pubkey: "76a914...".to_string(), }, ]; let pagination_meta = pagination.to_meta(utxos.len() as u64); Ok(Json(ApiResponse::success_paginated(utxos, pagination_meta))) } /// Sign a transaction. async fn sign_transaction( State(state): State, Authenticated(auth): Authenticated, Json(req): Json, ) -> ApiResult>> { require_permission(&auth, "write")?; // In production, this would use the crypto service let response = SignTransactionResponse { signed_tx: "0x...signed...".to_string(), txid: "txid123...".to_string(), signature: "0x...signature...".to_string(), }; Ok(Json(ApiResponse::success(response))) } /// Sign a message. async fn sign_message( State(state): State, Authenticated(auth): Authenticated, Json(req): Json, ) -> ApiResult>> { require_permission(&auth, "write")?; let response = SignMessageResponse { message: req.message, signature: "0x...message_signature...".to_string(), public_key: "0x04...".to_string(), }; Ok(Json(ApiResponse::success(response))) } /// Send a transaction. async fn send_transaction( State(state): State, Authenticated(auth): Authenticated, Json(req): Json, ) -> ApiResult>> { require_permission(&auth, "write")?; // Validate recipient address if !req.to.starts_with("synor1") { return Err(ApiError::InvalidAddress(req.to)); } // Validate amount let amount: f64 = req.amount.parse().map_err(|_| { ApiError::ValidationError("Invalid amount format".to_string()) })?; if amount <= 0.0 { return Err(ApiError::ValidationError("Amount must be positive".to_string())); } // In production, build, sign, and broadcast the transaction let response = SendTransactionResponse { txid: "newtxid789...".to_string(), amount: req.amount, fee: "0.001".to_string(), status: "pending".to_string(), }; Ok(Json(ApiResponse::success(response))) }