use std::sync::RwLock;
use std::time::{Duration, Instant};
use crate::transaction::fee_model::FeeModel;
use crate::transaction::fee_models::SatoshisPerKilobyte;
use crate::transaction::transaction::Transaction;
use crate::Result;
pub const DEFAULT_POLICY_URL: &str = "https://arc.gorillapool.io/v1/policy";
pub const DEFAULT_CACHE_TTL_SECS: u64 = 300;
pub const DEFAULT_FALLBACK_RATE: u64 = 100;
#[derive(Debug, Clone)]
pub struct LivePolicyConfig {
pub policy_url: String,
pub api_key: Option<String>,
pub cache_ttl: Duration,
pub fallback_rate: u64,
pub timeout_ms: u64,
}
impl Default for LivePolicyConfig {
fn default() -> Self {
Self {
policy_url: DEFAULT_POLICY_URL.to_string(),
api_key: None,
cache_ttl: Duration::from_secs(DEFAULT_CACHE_TTL_SECS),
fallback_rate: DEFAULT_FALLBACK_RATE,
timeout_ms: 10_000,
}
}
}
struct CachedRate {
rate: u64,
fetched_at: Instant,
}
pub struct LivePolicy {
config: LivePolicyConfig,
cached_rate: RwLock<Option<CachedRate>>,
#[cfg(feature = "http")]
client: reqwest::Client,
}
impl Default for LivePolicy {
fn default() -> Self {
Self::new()
}
}
impl LivePolicy {
pub fn new() -> Self {
Self::with_config(LivePolicyConfig::default())
}
pub fn with_url(policy_url: &str) -> Self {
Self::with_config(LivePolicyConfig {
policy_url: policy_url.to_string(),
..Default::default()
})
}
pub fn with_config(config: LivePolicyConfig) -> Self {
Self {
config,
cached_rate: RwLock::new(None),
#[cfg(feature = "http")]
client: reqwest::Client::new(),
}
}
pub fn policy_url(&self) -> &str {
&self.config.policy_url
}
pub fn cache_ttl(&self) -> Duration {
self.config.cache_ttl
}
pub fn cached_rate(&self) -> Option<u64> {
let cache = self.cached_rate.read().ok()?;
let cached = cache.as_ref()?;
if cached.fetched_at.elapsed() < self.config.cache_ttl {
Some(cached.rate)
} else {
None
}
}
pub fn effective_rate(&self) -> u64 {
self.cached_rate().unwrap_or(self.config.fallback_rate)
}
pub fn set_rate(&self, rate: u64) {
if let Ok(mut cache) = self.cached_rate.write() {
*cache = Some(CachedRate {
rate,
fetched_at: Instant::now(),
});
}
}
#[cfg(feature = "http")]
pub async fn refresh(&self) -> Result<u64> {
use serde::Deserialize;
#[derive(Deserialize)]
struct Policy {
#[serde(rename = "miningFee")]
mining_fee: Option<MiningFee>,
policy: Option<InnerPolicy>,
}
#[derive(Deserialize)]
struct InnerPolicy {
#[serde(rename = "miningFee")]
mining_fee: Option<MiningFee>,
}
#[derive(Deserialize)]
struct MiningFee {
satoshis: Option<u64>,
bytes: Option<u64>,
}
let mut request = self.client.get(&self.config.policy_url);
if let Some(ref api_key) = self.config.api_key {
request = request.header("Authorization", format!("Bearer {}", api_key));
}
let response = request
.timeout(Duration::from_millis(self.config.timeout_ms))
.send()
.await
.map_err(|e| crate::Error::FeeModelError(format!("Policy fetch failed: {}", e)))?;
if !response.status().is_success() {
return Err(crate::Error::FeeModelError(format!(
"Policy endpoint returned HTTP {}",
response.status()
)));
}
let policy: Policy = response
.json()
.await
.map_err(|e| crate::Error::FeeModelError(format!("Failed to parse policy: {}", e)))?;
let mining_fee = policy
.mining_fee
.or_else(|| policy.policy.and_then(|p| p.mining_fee))
.ok_or_else(|| {
crate::Error::FeeModelError("No mining fee found in policy response".to_string())
})?;
let satoshis = mining_fee.satoshis.unwrap_or(1);
let bytes = mining_fee.bytes.unwrap_or(1);
let rate_per_kb = (satoshis * 1000) / bytes;
if let Ok(mut cache) = self.cached_rate.write() {
*cache = Some(CachedRate {
rate: rate_per_kb,
fetched_at: Instant::now(),
});
}
Ok(rate_per_kb)
}
#[cfg(not(feature = "http"))]
pub async fn refresh(&self) -> Result<u64> {
Err(crate::Error::FeeModelError(
"HTTP feature not enabled. Add 'http' feature to Cargo.toml".to_string(),
))
}
}
impl FeeModel for LivePolicy {
fn compute_fee(&self, tx: &Transaction) -> Result<u64> {
let rate = self.effective_rate();
SatoshisPerKilobyte::new(rate).compute_fee(tx)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = LivePolicyConfig::default();
assert_eq!(config.policy_url, DEFAULT_POLICY_URL);
assert!(config.api_key.is_none());
assert_eq!(
config.cache_ttl,
Duration::from_secs(DEFAULT_CACHE_TTL_SECS)
);
assert_eq!(config.fallback_rate, DEFAULT_FALLBACK_RATE);
}
#[test]
fn test_new() {
let fee_model = LivePolicy::new();
assert_eq!(fee_model.policy_url(), DEFAULT_POLICY_URL);
assert_eq!(
fee_model.cache_ttl(),
Duration::from_secs(DEFAULT_CACHE_TTL_SECS)
);
}
#[test]
fn test_with_url() {
let fee_model = LivePolicy::with_url("https://custom.arc.com/v1/policy");
assert_eq!(fee_model.policy_url(), "https://custom.arc.com/v1/policy");
}
#[test]
fn test_fallback_rate() {
let fee_model = LivePolicy::new();
assert_eq!(fee_model.effective_rate(), DEFAULT_FALLBACK_RATE);
}
#[test]
fn test_set_rate() {
let fee_model = LivePolicy::new();
fee_model.set_rate(200);
assert_eq!(fee_model.cached_rate(), Some(200));
assert_eq!(fee_model.effective_rate(), 200);
}
#[test]
fn test_compute_fee_with_fallback() {
let fee_model = LivePolicy::new();
let tx = Transaction::new();
let fee = fee_model.compute_fee(&tx).unwrap();
assert_eq!(fee, 1);
}
#[test]
fn test_compute_fee_with_cached_rate() {
let fee_model = LivePolicy::new();
fee_model.set_rate(1000); let tx = Transaction::new();
let fee = fee_model.compute_fee(&tx).unwrap();
assert_eq!(fee, 10);
}
#[test]
fn test_with_config() {
let config = LivePolicyConfig {
policy_url: "https://test.arc.com/policy".to_string(),
api_key: Some("test-key".to_string()),
cache_ttl: Duration::from_secs(60),
fallback_rate: 50,
timeout_ms: 5_000,
};
let fee_model = LivePolicy::with_config(config);
assert_eq!(fee_model.policy_url(), "https://test.arc.com/policy");
assert_eq!(fee_model.cache_ttl(), Duration::from_secs(60));
assert_eq!(fee_model.effective_rate(), 50); }
}