use crate::prelude::*;
use sha2::{Digest, Sha256};
use uuid::Uuid;
pub const PAYMENT_HASH_DOMAIN: &str = "mostro-payer-v1|";
pub const PAYMENT_HASH_HEX_LEN: usize = 64;
pub fn payment_hash(canonical: &str) -> String {
lower_hex(
Sha256::new()
.chain_update(PAYMENT_HASH_DOMAIN.as_bytes())
.chain_update(canonical.as_bytes())
.finalize()
.as_slice(),
)
}
pub const PAYMENT_HASH_ORDER_DOMAIN: &str = "mostro-payer-order-v1|";
pub fn order_bound_payment_hash(order_id: &Uuid, canonical: &str) -> String {
lower_hex(
Sha256::new()
.chain_update(PAYMENT_HASH_ORDER_DOMAIN.as_bytes())
.chain_update(order_id.hyphenated().to_string().as_bytes())
.chain_update(b"|")
.chain_update(canonical.as_bytes())
.finalize()
.as_slice(),
)
}
fn lower_hex(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for b in bytes {
out.push(HEX[usize::from(b >> 4)] as char);
out.push(HEX[usize::from(b & 0x0f)] as char);
}
out
}
pub fn is_valid_payment_hash(hash: &str) -> bool {
hash.len() == PAYMENT_HASH_HEX_LEN
&& hash.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
}
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
pub struct PayerDeclaration {
pub payment_hash: String,
}
impl PayerDeclaration {
pub fn new(payment_hash: String) -> Self {
Self { payment_hash }
}
}
#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum BuyerMode {
Reputation,
FullPrivacy,
#[serde(other)]
Unknown,
}
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
pub struct PaymentHistory {
pub payment_hash: String,
pub buyer_mode: BuyerMode,
pub successful_trades: u32,
pub distinct_counterparties: u32,
pub experienced_counterparties: u32,
pub first_success_at: Option<i64>,
pub last_success_at: Option<i64>,
}
impl PaymentHistory {
pub fn unavailable(payment_hash: String) -> Self {
Self {
payment_hash,
buyer_mode: BuyerMode::FullPrivacy,
successful_trades: 0,
distinct_counterparties: 0,
experienced_counterparties: 0,
first_success_at: None,
last_success_at: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn order_bound_hash_matches_the_protocol_vector() {
let order = Uuid::parse_str("EDE61C96-4C13-4519-BF3A-DCF7F1E9D842").unwrap();
let canonical = "EU|SEPA|DE89370400440532013000|ALICE SMITH";
let hash = order_bound_payment_hash(&order, canonical);
assert_eq!(
hash,
"1f0616c3f355282b7fb73b54f172be84d023f1f63a8a7d7822c4d51f83528628"
);
assert!(is_valid_payment_hash(&hash));
assert_ne!(hash, payment_hash(canonical), "never the reusable hash");
assert_ne!(
hash,
order_bound_payment_hash(&Uuid::new_v4(), canonical),
"different on every order"
);
}
#[test]
fn payment_hash_is_domain_separated_lowercase_sha256() {
let expected = "ee06af92c95429e7cb0cf8428636199a71a01e32bab7a8526d226161f0de9903";
let hash = payment_hash("EU|SEPA|DE89370400440532013000|ALICE SMITH");
assert_eq!(hash.len(), PAYMENT_HASH_HEX_LEN);
assert!(is_valid_payment_hash(&hash));
assert_eq!(hash, expected);
}
#[test]
fn payment_hash_differs_without_the_domain_prefix() {
let canonical = "AR|CVU|0000003100012345678901|27123456789";
let bare: String = Sha256::digest(canonical.as_bytes())
.iter()
.map(|b| format!("{b:02x}"))
.collect();
assert_ne!(payment_hash(canonical), bare);
}
#[test]
fn payment_hash_is_deterministic_and_input_sensitive() {
let a = payment_hash("EU|SEPA|DE89370400440532013000|ALICE SMITH");
assert_eq!(
a,
payment_hash("EU|SEPA|DE89370400440532013000|ALICE SMITH")
);
assert_ne!(a, payment_hash("EU|SEPA|DE89370400440532013000|BOB SMITH"));
}
#[test]
fn is_valid_payment_hash_rejects_wrong_length_uppercase_and_non_hex() {
let good = "a".repeat(64);
assert!(is_valid_payment_hash(&good));
assert!(!is_valid_payment_hash(&"a".repeat(63)));
assert!(!is_valid_payment_hash(&"a".repeat(65)));
assert!(!is_valid_payment_hash(&"A".repeat(64)));
assert!(!is_valid_payment_hash(&"g".repeat(64)));
assert!(!is_valid_payment_hash(""));
}
#[test]
fn buyer_mode_serializes_to_snake_case() {
for (mode, wire) in [
(BuyerMode::Reputation, "\"reputation\""),
(BuyerMode::FullPrivacy, "\"full_privacy\""),
] {
assert_eq!(serde_json::to_string(&mode).unwrap(), wire);
assert_eq!(serde_json::from_str::<BuyerMode>(wire).unwrap(), mode);
}
}
#[test]
fn unknown_buyer_mode_does_not_break_the_history_payload() {
let json = format!(
r#"{{"payment_hash":"{}","buyer_mode":"mode_from_the_future",
"successful_trades":1,"distinct_counterparties":1,
"experienced_counterparties":0,"first_success_at":1,"last_success_at":1}}"#,
"a".repeat(64)
);
let history: PaymentHistory = serde_json::from_str(&json).unwrap();
assert_eq!(history.buyer_mode, BuyerMode::Unknown);
}
#[test]
fn unavailable_history_has_zero_counters_and_full_privacy_mode() {
let h = PaymentHistory::unavailable("b".repeat(64));
assert_eq!(h.buyer_mode, BuyerMode::FullPrivacy);
assert_eq!(
(
h.successful_trades,
h.distinct_counterparties,
h.experienced_counterparties
),
(0, 0, 0)
);
assert_eq!((h.first_success_at, h.last_success_at), (None, None));
}
#[test]
fn payment_history_ignores_unknown_keys_from_newer_daemons() {
let json = format!(
r#"{{"payment_hash":"{}","buyer_mode":"reputation","successful_trades":2,
"distinct_counterparties":2,"experienced_counterparties":1,
"first_success_at":10,"last_success_at":20,"field_from_the_future":true}}"#,
"c".repeat(64)
);
let h: PaymentHistory = serde_json::from_str(&json).unwrap();
assert_eq!(h.successful_trades, 2);
}
}