Skip to main content

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}