synor/crates/synor-gateway/src/routes/wallet.rs
Gulshan Yadav 03c1664739 feat: Implement standard API response types and health check endpoints
- Added `ApiResponse`, `ResponseMeta`, and related structures for standardized API responses.
- Created health check routes including basic health, liveness, and readiness checks.
- Introduced `AppState` for shared application state across routes.
- Developed wallet API endpoints for wallet management, address operations, balance queries, and transaction signing.
- Implemented RPC API endpoints for blockchain operations including block queries, transaction handling, and network information.
2026-01-28 15:03:36 +05:30

605 lines
17 KiB
Rust

//! 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<AppState> {
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<String>,
/// 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<String>,
/// 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<String>,
}
/// 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<u64>,
/// 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<String>,
/// 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<CreateWalletResponse>),
(status = 400, description = "Invalid request"),
(status = 401, description = "Authentication required")
),
security(("api_key" = [])),
tag = "Wallet"
))]
async fn create_wallet(
State(state): State<AppState>,
Authenticated(auth): Authenticated,
Json(req): Json<CreateWalletRequest>,
) -> ApiResult<Json<ApiResponse<CreateWalletResponse>>> {
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<ImportWalletResponse>),
(status = 400, description = "Invalid mnemonic"),
(status = 401, description = "Authentication required")
),
security(("api_key" = [])),
tag = "Wallet"
))]
async fn import_wallet(
State(state): State<AppState>,
Authenticated(auth): Authenticated,
Json(req): Json<ImportWalletRequest>,
) -> ApiResult<Json<ApiResponse<ImportWalletResponse>>> {
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<AppState>,
Authenticated(auth): Authenticated,
Path(address): Path<String>,
) -> ApiResult<Json<ApiResponse<serde_json::Value>>> {
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<AppState>,
Authenticated(auth): Authenticated,
Query(query): Query<AddressQuery>,
) -> ApiResult<Json<ApiResponse<serde_json::Value>>> {
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<AppState>,
Authenticated(auth): Authenticated,
Query(pagination): Query<PaginationParams>,
) -> ApiResult<Json<ApiResponse<Vec<serde_json::Value>>>> {
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<AppState>,
Authenticated(auth): Authenticated,
) -> ApiResult<Json<ApiResponse<serde_json::Value>>> {
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<BalanceResponse>),
(status = 400, description = "Invalid address"),
(status = 404, description = "Address not found")
),
security(("api_key" = [])),
tag = "Wallet"
))]
async fn get_balance(
State(state): State<AppState>,
Authenticated(auth): Authenticated,
Path(address): Path<String>,
) -> ApiResult<Json<ApiResponse<BalanceResponse>>> {
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<AppState>,
Authenticated(auth): Authenticated,
Json(req): Json<GetBalancesRequest>,
) -> ApiResult<Json<ApiResponse<Vec<BalanceResponse>>>> {
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<BalanceResponse> = 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<AppState>,
Authenticated(auth): Authenticated,
Path(address): Path<String>,
Query(pagination): Query<PaginationParams>,
) -> ApiResult<Json<ApiResponse<Vec<Utxo>>>> {
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<AppState>,
Authenticated(auth): Authenticated,
Json(req): Json<SignTransactionRequest>,
) -> ApiResult<Json<ApiResponse<SignTransactionResponse>>> {
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<AppState>,
Authenticated(auth): Authenticated,
Json(req): Json<SignMessageRequest>,
) -> ApiResult<Json<ApiResponse<SignMessageResponse>>> {
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<AppState>,
Authenticated(auth): Authenticated,
Json(req): Json<SendTransactionRequest>,
) -> ApiResult<Json<ApiResponse<SendTransactionResponse>>> {
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)))
}