mostro_core/payer.rs
1//! Payment-sender declaration and payment-account history (anti-triangulation).
2//!
3//! A buyer commits to the fiat account it will pay from by sending only a
4//! hash of the canonicalised account details ([`PayerDeclaration`]); the
5//! plaintext travels buyer → seller over the peer channel and never reaches
6//! Mostro. Mostro answers the seller with aggregate counters for that
7//! `(buyer, payment_hash)` pair ([`PaymentHistory`]).
8//!
9//! The canonicalisation rules are a client contract defined in the protocol
10//! book (`payer_declaration.md`); this module only fixes the hash
11//! construction so every client derives the same value from the same
12//! canonical string.
13
14use crate::prelude::*;
15use sha2::{Digest, Sha256};
16use uuid::Uuid;
17
18/// Domain-separation prefix hashed in front of the canonical payment data.
19///
20/// It makes the resulting value useless as an identifier outside this
21/// protocol. Every client must use exactly this prefix, or the same account
22/// yields different hashes and its history silently fragments.
23pub const PAYMENT_HASH_DOMAIN: &str = "mostro-payer-v1|";
24
25/// Length, in hex characters, of a valid [`PayerDeclaration::payment_hash`].
26pub const PAYMENT_HASH_HEX_LEN: usize = 64;
27
28/// Hash an already-canonicalised payment-data string.
29///
30/// Returns `sha256(PAYMENT_HASH_DOMAIN || canonical)` as 64 lowercase hex
31/// characters. The caller is responsible for canonicalising first (method
32/// prefix, Unicode NFKC, uppercase, separators stripped from identifiers);
33/// see the protocol chapter for the per-method rules. The hash must never
34/// include an order id, trade key, timestamp or salt, which would make it
35/// unique per trade and defeat the history. This is the reputation-mode
36/// construction; a full-privacy buyer uses [`order_bound_payment_hash`].
37pub fn payment_hash(canonical: &str) -> String {
38 lower_hex(
39 Sha256::new()
40 .chain_update(PAYMENT_HASH_DOMAIN.as_bytes())
41 .chain_update(canonical.as_bytes())
42 .finalize()
43 .as_slice(),
44 )
45}
46
47/// Domain-separation prefix of the order-bound declaration a full-privacy
48/// buyer sends (see [`order_bound_payment_hash`]).
49pub const PAYMENT_HASH_ORDER_DOMAIN: &str = "mostro-payer-order-v1|";
50
51/// The declaration hash of a full-privacy buyer, bound to one order.
52///
53/// Returns `sha256(PAYMENT_HASH_ORDER_DOMAIN || order_id || "|" ||
54/// canonical)` as 64 lowercase hex characters, with `order_id` in its
55/// lowercase hyphenated form. A full-privacy buyer has no history to build,
56/// and reusing [`payment_hash`] would give the same value on every order and
57/// let the node link its trade keys. The value still commits the buyer to an
58/// account for this order: the seller recomputes it from the plaintext.
59pub fn order_bound_payment_hash(order_id: &Uuid, canonical: &str) -> String {
60 lower_hex(
61 Sha256::new()
62 .chain_update(PAYMENT_HASH_ORDER_DOMAIN.as_bytes())
63 .chain_update(order_id.hyphenated().to_string().as_bytes())
64 .chain_update(b"|")
65 .chain_update(canonical.as_bytes())
66 .finalize()
67 .as_slice(),
68 )
69}
70
71fn lower_hex(bytes: &[u8]) -> String {
72 const HEX: &[u8; 16] = b"0123456789abcdef";
73 let mut out = String::with_capacity(bytes.len() * 2);
74 for b in bytes {
75 out.push(HEX[usize::from(b >> 4)] as char);
76 out.push(HEX[usize::from(b & 0x0f)] as char);
77 }
78 out
79}
80
81/// `true` when `hash` is exactly 64 lowercase hexadecimal characters.
82///
83/// Uppercase hex is rejected on purpose: the protocol defines a single
84/// encoding, so two spellings of the same digest can never become two
85/// history rows.
86pub fn is_valid_payment_hash(hash: &str) -> bool {
87 hash.len() == PAYMENT_HASH_HEX_LEN
88 && hash.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
89}
90
91/// Buyer's commitment to the fiat payer identity for one order.
92///
93/// Carried by [`crate::message::Action::DeclarePayer`] (buyer → Mostro) and
94/// [`crate::message::Action::PayerDeclared`] (Mostro → buyer ack and
95/// Mostro → seller forward). It holds only the hash; the plaintext goes
96/// buyer → seller off-band.
97///
98/// Deserialization accepts any string: neither serde nor
99/// [`crate::message::MessageKind::verify`] checks the hash format. A daemon
100/// must call [`is_valid_payment_hash`] on receipt and answer
101/// [`crate::error::CantDoReason::InvalidPaymentHash`] when it fails.
102#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
103pub struct PayerDeclaration {
104 /// 64 lowercase hex characters. A reputation-mode buyer declares
105 /// [`payment_hash`], `sha256("mostro-payer-v1|" || canonical)`, which is
106 /// stable across orders and builds history. A full-privacy buyer declares
107 /// [`order_bound_payment_hash`], `sha256("mostro-payer-order-v1|" ||
108 /// order_id || "|" || canonical)`, so the node cannot link its orders. A
109 /// seller checking the plaintext uses the construction that matches the
110 /// `buyer_mode` reported in [`PaymentHistory`] (either one for a mode it
111 /// does not know).
112 pub payment_hash: String,
113}
114
115impl PayerDeclaration {
116 /// Build a declaration from an already computed hash.
117 pub fn new(payment_hash: String) -> Self {
118 Self { payment_hash }
119 }
120}
121
122/// Whether the buyer's history is meaningful for this order.
123#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)]
124#[serde(rename_all = "snake_case")]
125pub enum BuyerMode {
126 /// The buyer trades with an identity key: the counters are meaningful.
127 Reputation,
128 /// The buyer trades in Full Privacy Mode: Mostro has no cross-trade
129 /// continuity for it, so the counters are always zero. Clients must
130 /// render this as "history unavailable", never as "new account".
131 FullPrivacy,
132 /// Catch-all for modes this build of `mostro-core` does not know yet,
133 /// so a newer daemon cannot break the whole `PaymentHistory` payload.
134 /// Clients should treat it like [`BuyerMode::FullPrivacy`]. Deserialize
135 /// only in practice: it serializes as `"unknown"`, but a daemon never
136 /// emits it on purpose.
137 #[serde(other)]
138 Unknown,
139}
140
141/// Aggregate, order-scoped history of a `(buyer, payment_hash)` pair.
142///
143/// Carried by [`crate::message::Action::PaymentHistory`], pushed by Mostro to
144/// the seller right after `fiat-sent-ok` and returned on a seller query.
145/// Counters only ever reflect trades that reached `success` without a
146/// dispute.
147#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
148pub struct PaymentHistory {
149 /// Echo of the buyer-committed hash, so the seller's client can compare
150 /// it with the hash it computes from the plaintext it received: with
151 /// [`payment_hash`] when `buyer_mode` is [`BuyerMode::Reputation`], with
152 /// [`order_bound_payment_hash`] when it is [`BuyerMode::FullPrivacy`].
153 /// For [`BuyerMode::Unknown`] the client cannot know which construction
154 /// a newer buyer used: it accepts a match under either one rather than
155 /// picking one and reporting a false mismatch.
156 pub payment_hash: String,
157 /// Whether the counters are meaningful. See [`BuyerMode`].
158 pub buyer_mode: BuyerMode,
159 /// Number of orders that reached `success` for `(buyer, payment_hash)`.
160 pub successful_trades: u32,
161 /// Number of distinct sellers among those orders.
162 pub distinct_counterparties: u32,
163 /// How many of those distinct counterparties were already *experienced*
164 /// at the time of their successful trade with this pair. The thresholds
165 /// are node policy, advertised on the info event.
166 pub experienced_counterparties: u32,
167 /// Unix seconds of the first successful trade; `None` when
168 /// `successful_trades == 0`.
169 pub first_success_at: Option<i64>,
170 /// Unix seconds of the last successful trade; `None` when
171 /// `successful_trades == 0`.
172 pub last_success_at: Option<i64>,
173}
174
175impl PaymentHistory {
176 /// History for a buyer in Full Privacy Mode: every counter is zero and
177 /// [`buyer_mode`](Self::buyer_mode) says the history is unavailable.
178 pub fn unavailable(payment_hash: String) -> Self {
179 Self {
180 payment_hash,
181 buyer_mode: BuyerMode::FullPrivacy,
182 successful_trades: 0,
183 distinct_counterparties: 0,
184 experienced_counterparties: 0,
185 first_success_at: None,
186 last_success_at: None,
187 }
188 }
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194
195 #[test]
196 fn order_bound_hash_matches_the_protocol_vector() {
197 // `printf '%s' 'mostro-payer-order-v1|ede61c96-4c13-4519-bf3a-dcf7f1e9d842|EU|SEPA|DE89370400440532013000|ALICE SMITH' | sha256sum`
198 let order = Uuid::parse_str("EDE61C96-4C13-4519-BF3A-DCF7F1E9D842").unwrap();
199 let canonical = "EU|SEPA|DE89370400440532013000|ALICE SMITH";
200 let hash = order_bound_payment_hash(&order, canonical);
201 assert_eq!(
202 hash,
203 "1f0616c3f355282b7fb73b54f172be84d023f1f63a8a7d7822c4d51f83528628"
204 );
205 assert!(is_valid_payment_hash(&hash));
206 assert_ne!(hash, payment_hash(canonical), "never the reusable hash");
207 assert_ne!(
208 hash,
209 order_bound_payment_hash(&Uuid::new_v4(), canonical),
210 "different on every order"
211 );
212 }
213
214 #[test]
215 fn payment_hash_is_domain_separated_lowercase_sha256() {
216 // Arrange: computed independently with
217 // `printf '%s' 'mostro-payer-v1|EU|SEPA|DE89370400440532013000|ALICE SMITH' | sha256sum`.
218 let expected = "ee06af92c95429e7cb0cf8428636199a71a01e32bab7a8526d226161f0de9903";
219 // Act
220 let hash = payment_hash("EU|SEPA|DE89370400440532013000|ALICE SMITH");
221 // Assert
222 assert_eq!(hash.len(), PAYMENT_HASH_HEX_LEN);
223 assert!(is_valid_payment_hash(&hash));
224 assert_eq!(hash, expected);
225 }
226
227 #[test]
228 fn payment_hash_differs_without_the_domain_prefix() {
229 let canonical = "AR|CVU|0000003100012345678901|27123456789";
230 let bare: String = Sha256::digest(canonical.as_bytes())
231 .iter()
232 .map(|b| format!("{b:02x}"))
233 .collect();
234 assert_ne!(payment_hash(canonical), bare);
235 }
236
237 #[test]
238 fn payment_hash_is_deterministic_and_input_sensitive() {
239 let a = payment_hash("EU|SEPA|DE89370400440532013000|ALICE SMITH");
240 assert_eq!(
241 a,
242 payment_hash("EU|SEPA|DE89370400440532013000|ALICE SMITH")
243 );
244 assert_ne!(a, payment_hash("EU|SEPA|DE89370400440532013000|BOB SMITH"));
245 }
246
247 #[test]
248 fn is_valid_payment_hash_rejects_wrong_length_uppercase_and_non_hex() {
249 let good = "a".repeat(64);
250 assert!(is_valid_payment_hash(&good));
251 assert!(!is_valid_payment_hash(&"a".repeat(63)));
252 assert!(!is_valid_payment_hash(&"a".repeat(65)));
253 assert!(!is_valid_payment_hash(&"A".repeat(64)));
254 assert!(!is_valid_payment_hash(&"g".repeat(64)));
255 assert!(!is_valid_payment_hash(""));
256 }
257
258 #[test]
259 fn buyer_mode_serializes_to_snake_case() {
260 for (mode, wire) in [
261 (BuyerMode::Reputation, "\"reputation\""),
262 (BuyerMode::FullPrivacy, "\"full_privacy\""),
263 ] {
264 assert_eq!(serde_json::to_string(&mode).unwrap(), wire);
265 assert_eq!(serde_json::from_str::<BuyerMode>(wire).unwrap(), mode);
266 }
267 }
268
269 #[test]
270 fn unknown_buyer_mode_does_not_break_the_history_payload() {
271 let json = format!(
272 r#"{{"payment_hash":"{}","buyer_mode":"mode_from_the_future",
273 "successful_trades":1,"distinct_counterparties":1,
274 "experienced_counterparties":0,"first_success_at":1,"last_success_at":1}}"#,
275 "a".repeat(64)
276 );
277 let history: PaymentHistory = serde_json::from_str(&json).unwrap();
278 assert_eq!(history.buyer_mode, BuyerMode::Unknown);
279 }
280
281 #[test]
282 fn unavailable_history_has_zero_counters_and_full_privacy_mode() {
283 let h = PaymentHistory::unavailable("b".repeat(64));
284 assert_eq!(h.buyer_mode, BuyerMode::FullPrivacy);
285 assert_eq!(
286 (
287 h.successful_trades,
288 h.distinct_counterparties,
289 h.experienced_counterparties
290 ),
291 (0, 0, 0)
292 );
293 assert_eq!((h.first_success_at, h.last_success_at), (None, None));
294 }
295
296 #[test]
297 fn payment_history_ignores_unknown_keys_from_newer_daemons() {
298 let json = format!(
299 r#"{{"payment_hash":"{}","buyer_mode":"reputation","successful_trades":2,
300 "distinct_counterparties":2,"experienced_counterparties":1,
301 "first_success_at":10,"last_success_at":20,"field_from_the_future":true}}"#,
302 "c".repeat(64)
303 );
304 let h: PaymentHistory = serde_json::from_str(&json).unwrap();
305 assert_eq!(h.successful_trades, 2);
306 }
307}