use crate::config::constants::DEV_FEE_AUDIT_EVENT_KIND;
use crate::config::MOSTRO_CONFIG;
use mostro_core::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize, Default, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum BondApplyTo {
#[default]
Take,
Make,
Both,
}
impl BondApplyTo {
pub fn applies_to_taker(self) -> bool {
matches!(self, BondApplyTo::Take | BondApplyTo::Both)
}
pub fn applies_to_maker(self) -> bool {
matches!(self, BondApplyTo::Make | BondApplyTo::Both)
}
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct AntiAbuseBondSettings {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_bond_amount_pct")]
pub amount_pct: f64,
#[serde(default = "default_bond_base_amount")]
pub base_amount_sats: i64,
#[serde(default)]
pub apply_to: BondApplyTo,
#[serde(default)]
pub slash_on_waiting_timeout: bool,
#[serde(
default = "default_slash_node_share_pct",
deserialize_with = "deserialize_slash_node_share_pct"
)]
pub slash_node_share_pct: f64,
#[serde(default = "default_payout_invoice_window_seconds")]
pub payout_invoice_window_seconds: u64,
#[serde(default = "default_payout_max_retries")]
pub payout_max_retries: u32,
#[serde(default = "default_payout_claim_window_days")]
pub payout_claim_window_days: u32,
}
fn default_bond_amount_pct() -> f64 {
0.01
}
fn default_bond_base_amount() -> i64 {
1_000
}
fn default_payout_invoice_window_seconds() -> u64 {
300
}
fn default_payout_max_retries() -> u32 {
5
}
fn default_slash_node_share_pct() -> f64 {
0.5
}
fn deserialize_slash_node_share_pct<'de, D>(deserializer: D) -> Result<f64, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error as _;
let v = f64::deserialize(deserializer)?;
if !(0.0..=1.0).contains(&v) {
return Err(D::Error::custom(format!(
"slash_node_share_pct must be in [0.0, 1.0], got {v}"
)));
}
Ok(v)
}
fn default_payout_claim_window_days() -> u32 {
15
}
impl Default for AntiAbuseBondSettings {
fn default() -> Self {
Self {
enabled: false,
amount_pct: default_bond_amount_pct(),
base_amount_sats: default_bond_base_amount(),
apply_to: BondApplyTo::default(),
slash_on_waiting_timeout: false,
slash_node_share_pct: default_slash_node_share_pct(),
payout_invoice_window_seconds: default_payout_invoice_window_seconds(),
payout_max_retries: default_payout_max_retries(),
payout_claim_window_days: default_payout_claim_window_days(),
}
}
}
#[derive(Debug, Deserialize, Serialize, Default, Clone)]
pub struct ExpirationSettings {
#[serde(skip_serializing_if = "Option::is_none")]
pub order_days: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rating_days: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dispute_days: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fee_audit_days: Option<u32>,
}
impl ExpirationSettings {
pub fn get_expiration_for_kind(&self, kind: u16) -> Option<u32> {
match kind {
NOSTR_ORDER_EVENT_KIND => self.order_days.or(Some(30)), NOSTR_RATING_EVENT_KIND => self.rating_days.or(Some(90)), NOSTR_DISPUTE_EVENT_KIND => self.dispute_days.or(Some(90)), DEV_FEE_AUDIT_EVENT_KIND => self.fee_audit_days.or(Some(365)), _ => None, }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rating_kind_uses_configured_days() {
let settings = ExpirationSettings {
rating_days: Some(30),
..Default::default()
};
assert_eq!(
settings.get_expiration_for_kind(NOSTR_RATING_EVENT_KIND),
Some(30)
);
}
#[test]
fn rating_kind_falls_back_to_90_when_unconfigured() {
let settings = ExpirationSettings::default();
assert_eq!(
settings.get_expiration_for_kind(NOSTR_RATING_EVENT_KIND),
Some(90)
);
}
#[test]
fn order_kind_falls_back_to_30_when_unconfigured() {
let settings = ExpirationSettings::default();
assert_eq!(
settings.get_expiration_for_kind(NOSTR_ORDER_EVENT_KIND),
Some(30)
);
}
#[test]
fn dispute_kind_falls_back_to_90_when_unconfigured() {
let settings = ExpirationSettings::default();
assert_eq!(
settings.get_expiration_for_kind(NOSTR_DISPUTE_EVENT_KIND),
Some(90)
);
}
#[test]
fn unknown_kind_returns_none() {
let settings = ExpirationSettings::default();
assert_eq!(settings.get_expiration_for_kind(12345), None);
}
}
macro_rules! impl_try_from_settings {
($($ty:ty => $field:ident),*) => {
$(
impl TryFrom<super::Settings> for $ty {
type Error = mostro_core::error::MostroError;
fn try_from(_: super::Settings) -> Result<Self, Self::Error> {
Ok(MOSTRO_CONFIG.get().unwrap().$field.clone())
}
}
)*
};
}
#[derive(Debug, Deserialize, Serialize, Default, Clone)]
pub struct DatabaseSettings {
pub url: String,
}
#[derive(Debug, Deserialize, Serialize, Default, Clone)]
pub struct LightningSettings {
pub lnd_cert_file: String,
pub lnd_macaroon_file: String,
pub lnd_grpc_host: String,
pub invoice_expiration_window: u32,
pub hold_invoice_cltv_delta: u32,
pub hold_invoice_expiration_window: u32,
pub payment_attempts: u32,
pub payment_retries_interval: u32,
}
#[derive(Debug, Deserialize, Serialize, Default, Clone)]
pub struct NostrSettings {
#[serde(default)]
pub nsec_privkey: String,
pub relays: Vec<String>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct RpcSettings {
pub enabled: bool,
pub listen_address: String,
pub port: u16,
#[serde(default = "default_rate_limiter_stale_duration")]
pub rate_limiter_stale_duration: u64,
}
fn default_rate_limiter_stale_duration() -> u64 {
3600
}
impl Default for RpcSettings {
fn default() -> Self {
Self {
enabled: false,
listen_address: "127.0.0.1".to_string(),
port: 50051,
rate_limiter_stale_duration: default_rate_limiter_stale_duration(),
}
}
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct MostroSettings {
pub fee: f64,
pub max_routing_fee: f64,
pub max_order_amount: u32,
pub min_payment_amount: u32,
pub expiration_hours: u32,
pub expiration_seconds: u32,
pub user_rates_sent_interval_seconds: u32,
pub max_expiration_days: u32,
pub publish_relays_interval: u32,
pub pow: u8,
pub publish_mostro_info_interval: u32,
#[serde(default = "default_bitcoin_price_api_url")]
pub bitcoin_price_api_url: String,
pub fiat_currencies_accepted: Vec<String>,
pub max_orders_per_response: u8,
pub dev_fee_percentage: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub about: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub picture: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub website: Option<String>,
#[serde(default = "default_publish_exchange_rates")]
pub publish_exchange_rates_to_nostr: bool,
#[serde(default = "default_exchange_rates_update_interval")]
pub exchange_rates_update_interval_seconds: u64,
}
fn default_bitcoin_price_api_url() -> String {
"https://api.yadio.io".to_string()
}
fn default_publish_exchange_rates() -> bool {
true }
fn default_exchange_rates_update_interval() -> u64 {
300 }
impl Default for MostroSettings {
fn default() -> Self {
Self {
fee: 0.0,
max_routing_fee: 0.002,
max_order_amount: 1000000,
min_payment_amount: 100,
expiration_hours: 24,
expiration_seconds: 900,
user_rates_sent_interval_seconds: 3600,
max_expiration_days: 15,
publish_relays_interval: 60,
pow: 0,
publish_mostro_info_interval: 300,
bitcoin_price_api_url: "https://api.yadio.io".to_string(),
fiat_currencies_accepted: vec![
"USD".to_string(),
"EUR".to_string(),
"ARS".to_string(),
"CUP".to_string(),
],
max_orders_per_response: 10,
dev_fee_percentage: 0.30,
name: None,
about: None,
picture: None,
website: None,
publish_exchange_rates_to_nostr: default_publish_exchange_rates(),
exchange_rates_update_interval_seconds: default_exchange_rates_update_interval(),
}
}
}
impl_try_from_settings!(
DatabaseSettings => database,
LightningSettings => lightning,
NostrSettings => nostr,
MostroSettings => mostro,
RpcSettings => rpc
);
#[cfg(test)]
mod anti_abuse_bond_tests {
use super::*;
#[test]
fn defaults_are_off() {
let cfg = AntiAbuseBondSettings::default();
assert!(!cfg.enabled);
assert!(!cfg.slash_on_waiting_timeout);
assert_eq!(cfg.apply_to, BondApplyTo::Take);
assert_eq!(cfg.amount_pct, 0.01);
assert_eq!(cfg.base_amount_sats, 1_000);
assert_eq!(cfg.payout_invoice_window_seconds, 300);
assert_eq!(cfg.payout_max_retries, 5);
assert_eq!(cfg.slash_node_share_pct, 0.5);
assert_eq!(cfg.payout_claim_window_days, 15);
}
#[test]
fn apply_to_predicates() {
assert!(BondApplyTo::Take.applies_to_taker());
assert!(!BondApplyTo::Take.applies_to_maker());
assert!(!BondApplyTo::Make.applies_to_taker());
assert!(BondApplyTo::Make.applies_to_maker());
assert!(BondApplyTo::Both.applies_to_taker());
assert!(BondApplyTo::Both.applies_to_maker());
}
#[test]
fn toml_omits_block() {
#[derive(serde::Deserialize)]
struct Stub {
anti_abuse_bond: Option<AntiAbuseBondSettings>,
}
let parsed: Stub = toml::from_str("").expect("empty toml is valid");
assert!(parsed.anti_abuse_bond.is_none());
}
#[test]
fn toml_minimal_block_defaults() {
#[derive(serde::Deserialize)]
struct Stub {
anti_abuse_bond: AntiAbuseBondSettings,
}
let parsed: Stub =
toml::from_str("[anti_abuse_bond]\nenabled = true").expect("minimal block parses");
assert!(parsed.anti_abuse_bond.enabled);
assert_eq!(parsed.anti_abuse_bond.apply_to, BondApplyTo::Take);
assert_eq!(parsed.anti_abuse_bond.base_amount_sats, 1_000);
}
#[test]
fn toml_apply_to_both() {
#[derive(serde::Deserialize)]
struct Stub {
anti_abuse_bond: AntiAbuseBondSettings,
}
let parsed: Stub = toml::from_str(
r#"[anti_abuse_bond]
enabled = true
apply_to = "both"
slash_on_waiting_timeout = true"#,
)
.expect("toml parses");
assert_eq!(parsed.anti_abuse_bond.apply_to, BondApplyTo::Both);
assert!(parsed.anti_abuse_bond.slash_on_waiting_timeout);
}
#[test]
fn toml_slash_node_share_pct_and_claim_window_override() {
#[derive(serde::Deserialize)]
struct Stub {
anti_abuse_bond: AntiAbuseBondSettings,
}
let parsed: Stub = toml::from_str(
r#"[anti_abuse_bond]
enabled = true
slash_node_share_pct = 0.25
payout_claim_window_days = 30"#,
)
.expect("toml parses");
assert_eq!(parsed.anti_abuse_bond.slash_node_share_pct, 0.25);
assert_eq!(parsed.anti_abuse_bond.payout_claim_window_days, 30);
}
#[test]
fn toml_slash_node_share_pct_boundaries_accepted() {
#[derive(serde::Deserialize)]
struct Stub {
anti_abuse_bond: AntiAbuseBondSettings,
}
for (pct, expected) in [("0.0", 0.0), ("1.0", 1.0)] {
let toml_str = format!("[anti_abuse_bond]\nslash_node_share_pct = {pct}");
let parsed: Stub = toml::from_str(&toml_str).expect("boundary value should parse");
assert_eq!(parsed.anti_abuse_bond.slash_node_share_pct, expected);
}
}
#[test]
fn toml_slash_node_share_pct_below_zero_rejected() {
#[derive(Debug, serde::Deserialize)]
struct Stub {
#[allow(dead_code)]
anti_abuse_bond: AntiAbuseBondSettings,
}
let err = toml::from_str::<Stub>("[anti_abuse_bond]\nslash_node_share_pct = -0.1")
.expect_err("negative pct must be rejected");
let msg = err.to_string();
assert!(
msg.contains("slash_node_share_pct") && msg.contains("[0.0, 1.0]"),
"error message should name the field and the valid range, got: {msg}"
);
}
#[test]
fn toml_slash_node_share_pct_above_one_rejected() {
#[derive(Debug, serde::Deserialize)]
struct Stub {
#[allow(dead_code)]
anti_abuse_bond: AntiAbuseBondSettings,
}
let err = toml::from_str::<Stub>("[anti_abuse_bond]\nslash_node_share_pct = 1.5")
.expect_err("pct above 1.0 must be rejected");
let msg = err.to_string();
assert!(
msg.contains("slash_node_share_pct") && msg.contains("[0.0, 1.0]"),
"error message should name the field and the valid range, got: {msg}"
);
}
#[test]
fn toml_legacy_slash_on_lost_dispute_parses() {
#[derive(serde::Deserialize)]
struct Stub {
anti_abuse_bond: AntiAbuseBondSettings,
}
let parsed: Stub = toml::from_str(
r#"[anti_abuse_bond]
enabled = true
slash_on_lost_dispute = true
slash_node_share_pct = 0.25
payout_claim_window_days = 30"#,
)
.expect("legacy slash_on_lost_dispute should be silently ignored");
assert!(parsed.anti_abuse_bond.enabled);
assert_eq!(parsed.anti_abuse_bond.slash_node_share_pct, 0.25);
assert_eq!(parsed.anti_abuse_bond.payout_claim_window_days, 30);
}
}