- 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.
605 lines
17 KiB
Rust
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)))
|
|
}
|