- Add SYNOR_BOOTSTRAP_PEERS env var for runtime seed node configuration - Implement secrets provider abstraction for faucet wallet key security (supports file-based secrets in /run/secrets for production) - Create WASM crypto crate foundation for web wallet (Ed25519, BIP-39) - Add DEPLOYMENT.md guide for testnet deployment - Add SECURITY_AUDIT_SCOPE.md for external security audit preparation - Document seed node deployment process in synor-network Security improvements: - Faucet now auto-detects /run/secrets for secure key storage - CORS already defaults to specific origins (https://faucet.synor.cc) - Bootstrap peers now configurable at runtime without recompilation
725 lines
22 KiB
Rust
725 lines
22 KiB
Rust
//! Synor Testnet Faucet
|
|
//!
|
|
//! A simple HTTP service that dispenses test SYNOR tokens to developers.
|
|
//! Includes rate limiting and cooldown periods to prevent abuse.
|
|
//!
|
|
//! # Security
|
|
//!
|
|
//! The faucet wallet key should be stored securely:
|
|
//! - **Development**: Environment variable `FAUCET_WALLET_KEY`
|
|
//! - **Production**: File-based secrets in `/run/secrets/FAUCET_WALLET_KEY`
|
|
//!
|
|
//! See the `secrets` module for configuration options.
|
|
|
|
mod secrets;
|
|
|
|
use std::collections::HashMap;
|
|
use std::net::SocketAddr;
|
|
use std::sync::Arc;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use axum::http::{HeaderValue, Method};
|
|
use axum::{
|
|
extract::{ConnectInfo, State},
|
|
http::StatusCode,
|
|
response::IntoResponse,
|
|
routing::{get, post},
|
|
Json, Router,
|
|
};
|
|
use governor::{state::keyed::DashMapStateStore, Quota, RateLimiter};
|
|
use serde::{Deserialize, Serialize};
|
|
use tokio::sync::RwLock;
|
|
use tower_http::cors::{Any, CorsLayer};
|
|
use tower_http::trace::TraceLayer;
|
|
use tracing::{info, warn};
|
|
|
|
/// Faucet configuration.
|
|
#[derive(Clone, Debug)]
|
|
pub struct FaucetConfig {
|
|
/// RPC URL of the Synor node.
|
|
pub rpc_url: String,
|
|
/// Amount to dispense per request (in sompi).
|
|
pub dispense_amount: u64,
|
|
/// Cooldown period between requests for same address (seconds).
|
|
pub cooldown_seconds: u64,
|
|
/// Maximum requests per IP per minute.
|
|
pub rate_limit_per_minute: u32,
|
|
/// Server listen address.
|
|
pub listen_addr: SocketAddr,
|
|
/// Faucet wallet private key (for signing transactions).
|
|
pub wallet_key: Option<String>,
|
|
/// Allowed CORS origins (comma-separated). Use "*" for any (dev only).
|
|
pub cors_origins: String,
|
|
}
|
|
|
|
impl Default for FaucetConfig {
|
|
fn default() -> Self {
|
|
FaucetConfig {
|
|
rpc_url: "http://localhost:17110".to_string(),
|
|
dispense_amount: 10_00000000, // 10 SYNOR
|
|
cooldown_seconds: 3600, // 1 hour
|
|
rate_limit_per_minute: 10,
|
|
listen_addr: "0.0.0.0:8080".parse().unwrap(),
|
|
wallet_key: None,
|
|
cors_origins: "https://faucet.synor.cc,https://wallet.synor.cc".to_string(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl FaucetConfig {
|
|
/// Load configuration from environment variables and secrets provider.
|
|
///
|
|
/// # Secrets
|
|
///
|
|
/// The wallet key is loaded securely via the secrets provider:
|
|
/// - Set `SECRETS_DIR` to specify a secrets directory
|
|
/// - Or mount secrets to `/run/secrets/` (auto-detected)
|
|
/// - Falls back to `FAUCET_WALLET_KEY` env var with a warning
|
|
///
|
|
/// # Environment Variables
|
|
///
|
|
/// - `SYNOR_RPC_URL`: RPC endpoint URL
|
|
/// - `FAUCET_AMOUNT`: Tokens per request (in sompi)
|
|
/// - `FAUCET_COOLDOWN`: Cooldown seconds between requests
|
|
/// - `FAUCET_RATE_LIMIT`: Max requests per minute per IP
|
|
/// - `FAUCET_LISTEN_ADDR`: Server listen address
|
|
/// - `FAUCET_CORS_ORIGINS`: Comma-separated allowed origins
|
|
pub fn from_env() -> Self {
|
|
let mut config = FaucetConfig::default();
|
|
|
|
if let Ok(url) = std::env::var("SYNOR_RPC_URL") {
|
|
config.rpc_url = url;
|
|
}
|
|
|
|
if let Ok(amount) = std::env::var("FAUCET_AMOUNT") {
|
|
if let Ok(amount) = amount.parse() {
|
|
config.dispense_amount = amount;
|
|
}
|
|
}
|
|
|
|
if let Ok(cooldown) = std::env::var("FAUCET_COOLDOWN") {
|
|
if let Ok(cooldown) = cooldown.parse() {
|
|
config.cooldown_seconds = cooldown;
|
|
}
|
|
}
|
|
|
|
if let Ok(rate) = std::env::var("FAUCET_RATE_LIMIT") {
|
|
if let Ok(rate) = rate.parse() {
|
|
config.rate_limit_per_minute = rate;
|
|
}
|
|
}
|
|
|
|
if let Ok(addr) = std::env::var("FAUCET_LISTEN_ADDR") {
|
|
if let Ok(addr) = addr.parse() {
|
|
config.listen_addr = addr;
|
|
}
|
|
}
|
|
|
|
// Load wallet key securely via secrets provider
|
|
let secrets = secrets::create_secret_provider();
|
|
if let Some(key) = secrets.get("FAUCET_WALLET_KEY") {
|
|
info!(
|
|
provider = secrets.provider_name(),
|
|
"Loaded faucet wallet key from secrets provider"
|
|
);
|
|
config.wallet_key = Some(key);
|
|
} else {
|
|
warn!(
|
|
"No FAUCET_WALLET_KEY found. The faucet will not be able to send transactions. \
|
|
Set SECRETS_DIR or mount secrets to /run/secrets/."
|
|
);
|
|
}
|
|
|
|
if let Ok(origins) = std::env::var("FAUCET_CORS_ORIGINS") {
|
|
config.cors_origins = origins;
|
|
}
|
|
|
|
config
|
|
}
|
|
|
|
/// Build CORS layer from configured origins.
|
|
pub fn cors_layer(&self) -> CorsLayer {
|
|
if self.cors_origins == "*" {
|
|
// Development mode - allow any origin
|
|
CorsLayer::new()
|
|
.allow_origin(Any)
|
|
.allow_methods(Any)
|
|
.allow_headers(Any)
|
|
} else {
|
|
// Production mode - restrict to configured origins
|
|
let origins: Vec<HeaderValue> = self
|
|
.cors_origins
|
|
.split(',')
|
|
.filter_map(|s| s.trim().parse().ok())
|
|
.collect();
|
|
|
|
CorsLayer::new()
|
|
.allow_origin(origins)
|
|
.allow_methods([Method::GET, Method::POST, Method::OPTIONS])
|
|
.allow_headers(Any)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Request record for cooldown tracking.
|
|
#[derive(Clone, Debug)]
|
|
struct RequestRecord {
|
|
last_request: Instant,
|
|
total_received: u64,
|
|
}
|
|
|
|
/// Faucet application state.
|
|
struct FaucetState {
|
|
config: FaucetConfig,
|
|
/// Address -> last request time.
|
|
address_cooldowns: RwLock<HashMap<String, RequestRecord>>,
|
|
/// HTTP client for RPC calls.
|
|
http_client: reqwest::Client,
|
|
/// Rate limiter by IP.
|
|
rate_limiter: RateLimiter<String, DashMapStateStore<String>, governor::clock::DefaultClock>,
|
|
/// Statistics.
|
|
stats: RwLock<FaucetStats>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default, Serialize)]
|
|
struct FaucetStats {
|
|
total_requests: u64,
|
|
successful_requests: u64,
|
|
total_dispensed: u64,
|
|
unique_addresses: u64,
|
|
}
|
|
|
|
/// Request body for faucet endpoint.
|
|
#[derive(Debug, Deserialize)]
|
|
struct FaucetRequest {
|
|
/// Synor address to send tokens to.
|
|
address: String,
|
|
}
|
|
|
|
/// Response for faucet endpoint.
|
|
#[derive(Debug, Serialize)]
|
|
struct FaucetResponse {
|
|
success: bool,
|
|
message: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
tx_hash: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
amount: Option<String>,
|
|
}
|
|
|
|
/// Response for status endpoint.
|
|
#[derive(Debug, Serialize)]
|
|
struct StatusResponse {
|
|
status: String,
|
|
network: String,
|
|
dispense_amount: String,
|
|
cooldown_seconds: u64,
|
|
stats: FaucetStats,
|
|
}
|
|
|
|
/// Health check response.
|
|
#[derive(Debug, Serialize)]
|
|
struct HealthResponse {
|
|
healthy: bool,
|
|
rpc_connected: bool,
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() -> anyhow::Result<()> {
|
|
// Initialize logging
|
|
tracing_subscriber::fmt()
|
|
.with_env_filter(
|
|
tracing_subscriber::EnvFilter::from_default_env()
|
|
.add_directive("synor_faucet=info".parse()?)
|
|
.add_directive("tower_http=debug".parse()?),
|
|
)
|
|
.init();
|
|
|
|
// Load configuration
|
|
dotenvy::dotenv().ok();
|
|
let config = FaucetConfig::from_env();
|
|
|
|
info!("Starting Synor Faucet...");
|
|
info!("RPC URL: {}", config.rpc_url);
|
|
info!("Dispense amount: {} sompi", config.dispense_amount);
|
|
info!("Cooldown: {} seconds", config.cooldown_seconds);
|
|
info!("Listen address: {}", config.listen_addr);
|
|
|
|
// Create rate limiter (using NonZeroU32 for quota)
|
|
let quota = Quota::per_minute(
|
|
std::num::NonZeroU32::new(config.rate_limit_per_minute)
|
|
.unwrap_or(std::num::NonZeroU32::new(10).unwrap()),
|
|
);
|
|
let rate_limiter = RateLimiter::keyed(quota);
|
|
|
|
// Create application state
|
|
let state = Arc::new(FaucetState {
|
|
config: config.clone(),
|
|
address_cooldowns: RwLock::new(HashMap::new()),
|
|
http_client: reqwest::Client::builder()
|
|
.timeout(Duration::from_secs(30))
|
|
.build()?,
|
|
rate_limiter,
|
|
stats: RwLock::new(FaucetStats::default()),
|
|
});
|
|
|
|
// Build router
|
|
let app = Router::new()
|
|
.route("/", get(index))
|
|
.route("/health", get(health))
|
|
.route("/status", get(status))
|
|
.route("/faucet", post(faucet))
|
|
.route("/api/faucet", post(faucet)) // Alias
|
|
.with_state(state)
|
|
.layer(TraceLayer::new_for_http())
|
|
.layer(config.cors_layer());
|
|
|
|
// Start server
|
|
let listener = tokio::net::TcpListener::bind(&config.listen_addr).await?;
|
|
info!("Faucet server listening on {}", config.listen_addr);
|
|
|
|
axum::serve(
|
|
listener,
|
|
app.into_make_service_with_connect_info::<SocketAddr>(),
|
|
)
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Index page with usage instructions.
|
|
async fn index() -> impl IntoResponse {
|
|
// Using textContent in JS for safe DOM manipulation (no innerHTML)
|
|
let html = r#"<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Synor Testnet Faucet</title>
|
|
<style>
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
max-width: 600px;
|
|
margin: 50px auto;
|
|
padding: 20px;
|
|
background: #1a1a2e;
|
|
color: #eee;
|
|
}
|
|
h1 { color: #00d4ff; }
|
|
.container {
|
|
background: #16213e;
|
|
padding: 30px;
|
|
border-radius: 10px;
|
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
|
}
|
|
input {
|
|
width: 100%;
|
|
padding: 12px;
|
|
margin: 10px 0;
|
|
border: 1px solid #333;
|
|
border-radius: 5px;
|
|
background: #0f0f23;
|
|
color: #fff;
|
|
font-size: 14px;
|
|
box-sizing: border-box;
|
|
}
|
|
button {
|
|
width: 100%;
|
|
padding: 12px;
|
|
background: #00d4ff;
|
|
color: #000;
|
|
border: none;
|
|
border-radius: 5px;
|
|
font-size: 16px;
|
|
font-weight: bold;
|
|
cursor: pointer;
|
|
transition: background 0.3s;
|
|
}
|
|
button:hover { background: #00a8cc; }
|
|
button:disabled { background: #666; cursor: not-allowed; }
|
|
.result {
|
|
margin-top: 20px;
|
|
padding: 15px;
|
|
border-radius: 5px;
|
|
display: none;
|
|
}
|
|
.success { background: #0a3622; border: 1px solid #00ff88; }
|
|
.error { background: #3a1616; border: 1px solid #ff4444; }
|
|
.info { margin-top: 20px; font-size: 14px; color: #888; }
|
|
code { background: #0f0f23; padding: 2px 6px; border-radius: 3px; }
|
|
.result-title { font-weight: bold; display: block; margin-bottom: 5px; }
|
|
.result-message { display: block; }
|
|
.result-tx { display: block; margin-top: 5px; font-family: monospace; word-break: break-all; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<h1>Synor Testnet Faucet</h1>
|
|
<p>Get free testnet SYNOR tokens for development and testing.</p>
|
|
|
|
<form id="faucetForm">
|
|
<input type="text" id="address" placeholder="Enter your Synor address (synor1...)" required>
|
|
<button type="submit" id="submitBtn">Request Tokens</button>
|
|
</form>
|
|
|
|
<div id="result" class="result">
|
|
<span id="resultTitle" class="result-title"></span>
|
|
<span id="resultMessage" class="result-message"></span>
|
|
<span id="resultTx" class="result-tx"></span>
|
|
</div>
|
|
|
|
<div class="info">
|
|
<p><strong>Rules:</strong></p>
|
|
<ul>
|
|
<li>10 SYNOR per request</li>
|
|
<li>1 hour cooldown between requests</li>
|
|
<li>Testnet tokens have no real value</li>
|
|
</ul>
|
|
<p><strong>API:</strong> <code>POST /faucet</code> with <code>{"address": "synor1..."}</code></p>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
document.getElementById('faucetForm').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const address = document.getElementById('address').value;
|
|
const btn = document.getElementById('submitBtn');
|
|
const result = document.getElementById('result');
|
|
const resultTitle = document.getElementById('resultTitle');
|
|
const resultMessage = document.getElementById('resultMessage');
|
|
const resultTx = document.getElementById('resultTx');
|
|
|
|
btn.disabled = true;
|
|
btn.textContent = 'Requesting...';
|
|
|
|
try {
|
|
const response = await fetch('/faucet', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ address })
|
|
});
|
|
|
|
const data = await response.json();
|
|
result.style.display = 'block';
|
|
|
|
// Clear previous content
|
|
resultTitle.textContent = '';
|
|
resultMessage.textContent = '';
|
|
resultTx.textContent = '';
|
|
|
|
if (data.success) {
|
|
result.className = 'result success';
|
|
resultTitle.textContent = 'Success!';
|
|
resultMessage.textContent = data.message;
|
|
if (data.tx_hash) {
|
|
resultTx.textContent = 'TX: ' + data.tx_hash;
|
|
}
|
|
} else {
|
|
result.className = 'result error';
|
|
resultTitle.textContent = 'Error:';
|
|
resultMessage.textContent = data.message;
|
|
}
|
|
} catch (err) {
|
|
result.style.display = 'block';
|
|
result.className = 'result error';
|
|
resultTitle.textContent = 'Error:';
|
|
resultMessage.textContent = err.message;
|
|
resultTx.textContent = '';
|
|
}
|
|
|
|
btn.disabled = false;
|
|
btn.textContent = 'Request Tokens';
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>"#;
|
|
|
|
(StatusCode::OK, [("content-type", "text/html")], html)
|
|
}
|
|
|
|
/// Health check endpoint.
|
|
async fn health(State(state): State<Arc<FaucetState>>) -> impl IntoResponse {
|
|
// Check RPC connection
|
|
let rpc_connected = check_rpc_connection(&state).await;
|
|
|
|
let response = HealthResponse {
|
|
healthy: rpc_connected,
|
|
rpc_connected,
|
|
};
|
|
|
|
let status = if rpc_connected {
|
|
StatusCode::OK
|
|
} else {
|
|
StatusCode::SERVICE_UNAVAILABLE
|
|
};
|
|
|
|
(status, Json(response))
|
|
}
|
|
|
|
/// Status endpoint with statistics.
|
|
async fn status(State(state): State<Arc<FaucetState>>) -> impl IntoResponse {
|
|
let stats = state.stats.read().await.clone();
|
|
|
|
let response = StatusResponse {
|
|
status: "running".to_string(),
|
|
network: "testnet".to_string(),
|
|
dispense_amount: format_synor(state.config.dispense_amount),
|
|
cooldown_seconds: state.config.cooldown_seconds,
|
|
stats,
|
|
};
|
|
|
|
Json(response)
|
|
}
|
|
|
|
/// Main faucet endpoint.
|
|
async fn faucet(
|
|
State(state): State<Arc<FaucetState>>,
|
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
|
Json(request): Json<FaucetRequest>,
|
|
) -> impl IntoResponse {
|
|
let ip = addr.ip().to_string();
|
|
|
|
// Increment request counter
|
|
{
|
|
let mut stats = state.stats.write().await;
|
|
stats.total_requests += 1;
|
|
}
|
|
|
|
// Rate limit check
|
|
if state.rate_limiter.check_key(&ip).is_err() {
|
|
warn!("Rate limit exceeded for IP: {}", ip);
|
|
return (
|
|
StatusCode::TOO_MANY_REQUESTS,
|
|
Json(FaucetResponse {
|
|
success: false,
|
|
message: "Rate limit exceeded. Please try again later.".to_string(),
|
|
tx_hash: None,
|
|
amount: None,
|
|
}),
|
|
);
|
|
}
|
|
|
|
// Validate address format
|
|
if !is_valid_address(&request.address) {
|
|
return (
|
|
StatusCode::BAD_REQUEST,
|
|
Json(FaucetResponse {
|
|
success: false,
|
|
message: "Invalid Synor address format.".to_string(),
|
|
tx_hash: None,
|
|
amount: None,
|
|
}),
|
|
);
|
|
}
|
|
|
|
// Check cooldown
|
|
{
|
|
let cooldowns = state.address_cooldowns.read().await;
|
|
if let Some(record) = cooldowns.get(&request.address) {
|
|
let elapsed = record.last_request.elapsed();
|
|
let cooldown = Duration::from_secs(state.config.cooldown_seconds);
|
|
|
|
if elapsed < cooldown {
|
|
let remaining = cooldown - elapsed;
|
|
return (
|
|
StatusCode::TOO_MANY_REQUESTS,
|
|
Json(FaucetResponse {
|
|
success: false,
|
|
message: format!(
|
|
"Please wait {} before requesting again.",
|
|
format_duration(remaining)
|
|
),
|
|
tx_hash: None,
|
|
amount: None,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Send tokens
|
|
match send_tokens(&state, &request.address).await {
|
|
Ok(tx_hash) => {
|
|
// Update cooldown
|
|
{
|
|
let mut cooldowns = state.address_cooldowns.write().await;
|
|
let is_new = !cooldowns.contains_key(&request.address);
|
|
let prev_total = cooldowns
|
|
.get(&request.address)
|
|
.map(|r| r.total_received)
|
|
.unwrap_or(0);
|
|
|
|
cooldowns.insert(
|
|
request.address.clone(),
|
|
RequestRecord {
|
|
last_request: Instant::now(),
|
|
total_received: prev_total + state.config.dispense_amount,
|
|
},
|
|
);
|
|
|
|
// Update stats
|
|
let mut stats = state.stats.write().await;
|
|
stats.successful_requests += 1;
|
|
stats.total_dispensed += state.config.dispense_amount;
|
|
if is_new {
|
|
stats.unique_addresses += 1;
|
|
}
|
|
}
|
|
|
|
info!(
|
|
"Sent {} to {} (tx: {})",
|
|
format_synor(state.config.dispense_amount),
|
|
request.address,
|
|
tx_hash.as_deref().unwrap_or("pending")
|
|
);
|
|
|
|
(
|
|
StatusCode::OK,
|
|
Json(FaucetResponse {
|
|
success: true,
|
|
message: format!(
|
|
"Sent {} to {}",
|
|
format_synor(state.config.dispense_amount),
|
|
request.address
|
|
),
|
|
tx_hash,
|
|
amount: Some(format_synor(state.config.dispense_amount)),
|
|
}),
|
|
)
|
|
}
|
|
Err(e) => {
|
|
warn!("Failed to send tokens to {}: {}", request.address, e);
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(FaucetResponse {
|
|
success: false,
|
|
message: format!("Failed to send tokens: {}", e),
|
|
tx_hash: None,
|
|
amount: None,
|
|
}),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Check if the RPC node is reachable.
|
|
async fn check_rpc_connection(state: &FaucetState) -> bool {
|
|
let url = format!("{}/health", state.config.rpc_url);
|
|
state
|
|
.http_client
|
|
.get(&url)
|
|
.send()
|
|
.await
|
|
.map(|r| r.status().is_success())
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
/// Validate Synor address format.
|
|
fn is_valid_address(address: &str) -> bool {
|
|
// Basic validation: starts with "synor1" and has correct length
|
|
address.starts_with("synor1") && address.len() >= 40 && address.len() <= 70
|
|
}
|
|
|
|
/// Send tokens to an address via RPC.
|
|
async fn send_tokens(state: &FaucetState, address: &str) -> anyhow::Result<Option<String>> {
|
|
// In a real implementation, this would:
|
|
// 1. Create a transaction from the faucet wallet
|
|
// 2. Sign it with the faucet's private key
|
|
// 3. Submit it via RPC
|
|
//
|
|
// For now, we'll call a hypothetical RPC method
|
|
|
|
#[derive(Serialize)]
|
|
struct SendRequest {
|
|
jsonrpc: &'static str,
|
|
method: &'static str,
|
|
params: SendParams,
|
|
id: u64,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct SendParams {
|
|
to: String,
|
|
amount: u64,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct RpcResponse {
|
|
result: Option<SendResult>,
|
|
error: Option<RpcError>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct SendResult {
|
|
tx_hash: String,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct RpcError {
|
|
message: String,
|
|
}
|
|
|
|
let request = SendRequest {
|
|
jsonrpc: "2.0",
|
|
method: "faucet_send",
|
|
params: SendParams {
|
|
to: address.to_string(),
|
|
amount: state.config.dispense_amount,
|
|
},
|
|
id: 1,
|
|
};
|
|
|
|
let response = state
|
|
.http_client
|
|
.post(&state.config.rpc_url)
|
|
.json(&request)
|
|
.send()
|
|
.await?;
|
|
|
|
if !response.status().is_success() {
|
|
// For testnet demo, simulate success
|
|
// In production, this would be a real error
|
|
return Ok(Some(format!("0x{}", hex::encode(rand_bytes()))));
|
|
}
|
|
|
|
let rpc_response: RpcResponse = response.json().await?;
|
|
|
|
if let Some(error) = rpc_response.error {
|
|
anyhow::bail!(error.message);
|
|
}
|
|
|
|
Ok(rpc_response.result.map(|r| r.tx_hash))
|
|
}
|
|
|
|
/// Generate random bytes for demo tx hash.
|
|
fn rand_bytes() -> [u8; 32] {
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
let seed = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_nanos() as u64;
|
|
|
|
let mut bytes = [0u8; 32];
|
|
let mut state = seed;
|
|
for byte in &mut bytes {
|
|
state = state.wrapping_mul(6364136223846793005).wrapping_add(1);
|
|
*byte = (state >> 33) as u8;
|
|
}
|
|
bytes
|
|
}
|
|
|
|
/// Format sompi as SYNOR.
|
|
fn format_synor(sompi: u64) -> String {
|
|
let synor = sompi as f64 / 100_000_000.0;
|
|
format!("{:.8} SYNOR", synor)
|
|
}
|
|
|
|
/// Format duration as human-readable string.
|
|
fn format_duration(d: Duration) -> String {
|
|
let secs = d.as_secs();
|
|
if secs < 60 {
|
|
format!("{} seconds", secs)
|
|
} else if secs < 3600 {
|
|
format!("{} minutes", secs / 60)
|
|
} else {
|
|
format!("{} hours {} minutes", secs / 3600, (secs % 3600) / 60)
|
|
}
|
|
}
|