road-runner-common 0.22.0

Shared Rust utilities for exchange ecosystem backend services.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
//! Typed registry of confirmable sensitive actions. Each action defines the
//! canonical, domain-separated string that both the minter (cex-auth) and the
//! enforcing service hash to the same value — "what you see is what you sign".
//!
//! The canonical format is a versioned contract: `v1|{purpose}|{sub}|{fields…}`,
//! SHA-256, hex. Never reorder or reinterpret fields under `v1`; add a new action
//! (new `purpose`) or bump the prefix instead. Fields that are not security-
//! relevant (free-text labels, notes, idempotency keys) are deliberately excluded
//! to avoid canonicalization drift.

use bigdecimal::BigDecimal;
use sha2::{Digest, Sha256};

/// A confirmable sensitive action. Implementors are shared verbatim between the
/// minter and every enforcer so the hash can never drift.
pub trait ConfirmAction {
    /// Stable action id carried as the token `purpose`.
    fn purpose(&self) -> &'static str;

    /// The canonical string hashed by [`Self::canonical_hash_hex`]. Kept separate
    /// so tests can pin the exact bytes.
    fn canonical(&self, user_sub: &str) -> String;

    /// `hex(SHA-256(canonical))` — the value carried in the token's `tx_hash_hex`.
    fn canonical_hash_hex(&self, user_sub: &str) -> String {
        hex::encode(Sha256::digest(self.canonical(user_sub).as_bytes()))
    }
}

/// Crypto withdrawal. Byte-identical to the pre-shared cex-auth/cex-ledger twin:
/// `v1|withdrawal|{sub}|{currency_id}|{blockchain_id}|{send_type}|{recipient}|{amount}`.
/// `note`/`idempotency_key` are excluded by contract.
#[derive(Debug, Clone)]
pub struct Withdrawal {
    pub amount: BigDecimal,
    pub currency_id: i64,
    pub blockchain_id: i64,
    /// Wire value: `EXCHANGE_ID` | `PHONE` | `EMAIL` | `ADDRESS`.
    pub send_type: String,
    pub recipient: String,
}

impl ConfirmAction for Withdrawal {
    fn purpose(&self) -> &'static str {
        "withdrawal"
    }
    fn canonical(&self, user_sub: &str) -> String {
        format!(
            "v1|withdrawal|{}|{}|{}|{}|{}|{}",
            user_sub,
            self.currency_id,
            self.blockchain_id,
            self.send_type.trim(),
            self.recipient.trim(),
            canonical_amount(&self.amount),
        )
    }
}

/// Add a crypto withdrawal address to the user's book. The security-relevant
/// fields are the destination (`chain`, `address`, `memo`/destination-tag); the
/// cosmetic `label` is excluded.
#[derive(Debug, Clone)]
pub struct AddWithdrawAddress {
    pub chain: String,
    pub address: String,
    pub memo: Option<String>,
}

impl ConfirmAction for AddWithdrawAddress {
    fn purpose(&self) -> &'static str {
        "add_withdraw_address"
    }
    fn canonical(&self, user_sub: &str) -> String {
        format!(
            "v1|add_withdraw_address|{}|{}|{}|{}",
            user_sub,
            self.chain.trim(),
            self.address.trim(),
            self.memo.as_deref().unwrap_or("").trim(),
        )
    }
}

/// Add a fiat bank account (withdrawal target). IBAN is the security-relevant
/// field; the cosmetic `label` is excluded.
#[derive(Debug, Clone)]
pub struct AddBankAccount {
    pub iban: String,
}

impl ConfirmAction for AddBankAccount {
    fn purpose(&self) -> &'static str {
        "add_bank_account"
    }
    fn canonical(&self, user_sub: &str) -> String {
        format!(
            "v1|add_bank_account|{}|{}",
            user_sub,
            // IBANs are case-insensitive and space-formatted on the wire.
            self.iban.replace(' ', "").to_uppercase(),
        )
    }
}

/// Create a programmatic API key. Scopes (what the key can do) and the IP
/// allowlist are security-relevant and order-independent, so both are sorted
/// before hashing; `name` and `expires_in_days` are included as-is.
#[derive(Debug, Clone)]
pub struct CreateApiKey {
    pub name: String,
    pub scopes: Vec<String>,
    pub ip_allowlist: Vec<String>,
    pub expires_in_days: Option<i64>,
}

impl ConfirmAction for CreateApiKey {
    fn purpose(&self) -> &'static str {
        "create_api_key"
    }
    fn canonical(&self, user_sub: &str) -> String {
        format!(
            "v1|create_api_key|{}|{}|{}|{}|{}",
            user_sub,
            self.name.trim(),
            sorted_join(&self.scopes),
            sorted_join(&self.ip_allowlist),
            self.expires_in_days.map(|d| d.to_string()).unwrap_or_default(),
        )
    }
}

/// Rotate an existing API key's secret. Bound to the key id being rotated.
#[derive(Debug, Clone)]
pub struct RotateApiKey {
    pub api_key_id: String,
}

impl ConfirmAction for RotateApiKey {
    fn purpose(&self) -> &'static str {
        "rotate_api_key"
    }
    fn canonical(&self, user_sub: &str) -> String {
        format!("v1|rotate_api_key|{}|{}", user_sub, self.api_key_id.trim())
    }
}

/// Change the account's active second-factor method (authenticator / SMS / email).
/// The selected method is the only security-relevant field, so the token binds it:
/// a confirmation minted for `email` cannot be replayed to switch the account to
/// `sms`. `v1|change_two_factor_method|{sub}|{method}`.
#[derive(Debug, Clone)]
pub struct ChangeTwoFactorMethod {
    /// Wire value: `totp` | `sms` | `email`.
    pub method: String,
}

impl ConfirmAction for ChangeTwoFactorMethod {
    fn purpose(&self) -> &'static str {
        "change_two_factor_method"
    }
    fn canonical(&self, user_sub: &str) -> String {
        format!(
            "v1|change_two_factor_method|{}|{}",
            user_sub,
            self.method.trim().to_lowercase(),
        )
    }
}

/// Replacing the account's e-mail address. Bound to the **new** address, so a
/// confirmation minted for one address cannot be replayed to move the account to
/// another. `v1|change_email|{sub}|{email}`.
///
/// The step-up this hash guards proves *who is asking*. It deliberately does not
/// prove the new address belongs to them — that is a separate challenge sent to the
/// address itself, and the change only lands once both have passed.
#[derive(Debug, Clone)]
pub struct ChangeEmail {
    /// The address being moved to, as the member typed it.
    pub email: String,
}

impl ConfirmAction for ChangeEmail {
    fn purpose(&self) -> &'static str {
        "change_email"
    }
    fn canonical(&self, user_sub: &str) -> String {
        // Case-folded and trimmed so the hash does not depend on how the member
        // capitalised what they typed. The local part is technically
        // case-sensitive in the RFC, but no real mail provider treats it so, and a
        // hash that changed with capitalisation would break the confirmation for
        // the same address.
        format!(
            "v1|change_email|{}|{}",
            user_sub,
            self.email.trim().to_lowercase(),
        )
    }
}

/// Replacing the account's phone number. Bound to the **new** number, for the same
/// reason [`ChangeEmail`] is bound to the new address.
/// `v1|change_phone|{sub}|{phone}`.
#[derive(Debug, Clone)]
pub struct ChangePhone {
    /// The number being moved to, as the member typed it.
    pub phone: String,
}

impl ConfirmAction for ChangePhone {
    fn purpose(&self) -> &'static str {
        "change_phone"
    }
    fn canonical(&self, user_sub: &str) -> String {
        // Digits and a leading `+` only: the same number written `+90 555 111 22 33`
        // or `+905551112233` must hash identically, or the confirmation breaks on
        // formatting the member cannot see.
        format!(
            "v1|change_phone|{}|{}",
            user_sub,
            normalize_phone(&self.phone),
        )
    }
}

/// Reduce a phone number to `+` and digits. Everything else — spaces, dashes,
/// parentheses — is presentation.
fn normalize_phone(raw: &str) -> String {
    let trimmed = raw.trim();
    let plus = trimmed.starts_with('+');
    let digits: String = trimmed.chars().filter(char::is_ascii_digit).collect();
    if plus {
        format!("+{digits}")
    } else {
        digits
    }
}

/// Sort a set of tokens and join with `,` for order-independent, deterministic
/// hashing. Empty sets render as the empty string.
fn sorted_join(items: &[String]) -> String {
    let mut v: Vec<&str> = items.iter().map(|s| s.trim()).collect();
    v.sort_unstable();
    v.join(",")
}

/// Java `BigDecimal.stripTrailingZeros().toPlainString()` semantics, so `"1.0"`,
/// `"1.00"` and `"1"` hash identically on both sides.
pub fn canonical_amount(value: &BigDecimal) -> String {
    to_plain_string(&value.normalized())
}

/// Java `BigDecimal.toPlainString()` — plain notation, never an exponent.
/// Kept byte-identical to cex-ledger's `shared/java_math::to_plain_string`.
fn to_plain_string(value: &BigDecimal) -> String {
    use bigdecimal::num_bigint::Sign;

    let (bigint, scale) = value.as_bigint_and_exponent();
    let negative = bigint.sign() == Sign::Minus;
    let digits = bigint.magnitude().to_string();
    let sign = if negative { "-" } else { "" };

    if scale <= 0 {
        return format!("{sign}{digits}{}", "0".repeat((-scale) as usize));
    }
    if (scale as usize) < digits.len() {
        let split = digits.len() - scale as usize;
        format!("{sign}{}.{}", &digits[..split], &digits[split..])
    } else {
        format!("{sign}0.{}{digits}", "0".repeat(scale as usize - digits.len()))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn withdrawal(amount: &str) -> Withdrawal {
        Withdrawal {
            amount: amount.parse().unwrap(),
            currency_id: 42,
            blockchain_id: 7,
            send_type: "ADDRESS".into(),
            recipient: "bc1qexampleaddressxyz".into(),
        }
    }

    /// Known-answer vector — MUST match the cex-auth/cex-ledger fixture so the
    /// existing withdrawal contract is preserved byte-for-byte.
    #[test]
    fn withdrawal_known_vector_is_unchanged() {
        let sub = "11111111-2222-3333-4444-555555555555";
        let hash = withdrawal("1.50").canonical_hash_hex(sub);
        assert_eq!(
            hash,
            hex::encode(Sha256::digest(
                "v1|withdrawal|11111111-2222-3333-4444-555555555555|42|7|ADDRESS|bc1qexampleaddressxyz|1.5"
                    .as_bytes(),
            ))
        );
        // Decimal normalization: equivalent spellings hash identically.
        assert_eq!(hash, withdrawal("1.5").canonical_hash_hex(sub));
        assert_eq!(hash, withdrawal("1.500").canonical_hash_hex(sub));
    }

    #[test]
    fn add_withdraw_address_canonical() {
        let a = AddWithdrawAddress {
            chain: "TRON".into(),
            address: " TXYZ ".into(),
            memo: None,
        };
        assert_eq!(a.canonical("u1"), "v1|add_withdraw_address|u1|TRON|TXYZ|");
    }

    #[test]
    fn add_bank_account_normalizes_iban() {
        let a = AddBankAccount { iban: "tr33 0006 1005".into() };
        assert_eq!(a.canonical("u1"), "v1|add_bank_account|u1|TR3300061005");
    }

    #[test]
    fn create_api_key_sorts_scopes_and_ips() {
        let a = CreateApiKey {
            name: "trading".into(),
            scopes: vec!["trade".into(), "read".into()],
            ip_allowlist: vec!["2.2.2.2".into(), "1.1.1.1".into()],
            expires_in_days: Some(30),
        };
        // scopes + ips sorted, order-independent.
        assert_eq!(
            a.canonical("u1"),
            "v1|create_api_key|u1|trading|read,trade|1.1.1.1,2.2.2.2|30"
        );
        let b = CreateApiKey {
            scopes: vec!["read".into(), "trade".into()],
            ip_allowlist: vec!["1.1.1.1".into(), "2.2.2.2".into()],
            ..a.clone()
        };
        assert_eq!(a.canonical("u1"), b.canonical("u1"));
    }

    #[test]
    fn change_two_factor_method_binds_lowercased_method() {
        let a = ChangeTwoFactorMethod { method: " SMS ".into() };
        assert_eq!(a.canonical("u1"), "v1|change_two_factor_method|u1|sms");
        // A token for one method must not hash-match another.
        let b = ChangeTwoFactorMethod { method: "email".into() };
        assert_ne!(a.canonical_hash_hex("u1"), b.canonical_hash_hex("u1"));
    }

    #[test]
    fn create_api_key_empty_optionals() {
        let a = CreateApiKey {
            name: "k".into(),
            scopes: vec![],
            ip_allowlist: vec![],
            expires_in_days: None,
        };
        assert_eq!(a.canonical("u1"), "v1|create_api_key|u1|k|||");
    }
}

#[cfg(test)]
mod contact_change_tests {
    use super::*;

    const SUB: &str = "user-1";

    /// The hash binds the confirmation to the address being moved to. Without that, a
    /// token minted for one address would move the account to any other.
    #[test]
    fn an_email_confirmation_cannot_be_replayed_for_another_address() {
        let mine = ChangeEmail { email: "me@example.com".into() };
        let theirs = ChangeEmail { email: "attacker@example.com".into() };
        assert_ne!(mine.canonical_hash_hex(SUB), theirs.canonical_hash_hex(SUB));
    }

    /// Nor for another account: the subject is part of the canonical string.
    #[test]
    fn a_confirmation_is_bound_to_one_member() {
        let action = ChangeEmail { email: "me@example.com".into() };
        assert_ne!(
            action.canonical_hash_hex(SUB),
            action.canonical_hash_hex("user-2")
        );
    }

    /// Capitalisation is not a different address. A hash that changed with it would
    /// break the confirmation for the address the member actually typed.
    #[test]
    fn email_capitalisation_does_not_change_the_hash() {
        let typed = ChangeEmail { email: "  Me@Example.COM ".into() };
        let plain = ChangeEmail { email: "me@example.com".into() };
        assert_eq!(typed.canonical_hash_hex(SUB), plain.canonical_hash_hex(SUB));
    }

    /// Neither is formatting a different number — the member cannot see which form the
    /// hash was minted over.
    #[test]
    fn phone_formatting_does_not_change_the_hash() {
        let spaced = ChangePhone { phone: "+90 555 111 22 33".into() };
        let dashed = ChangePhone { phone: "+90-555-111-2233".into() };
        let plain = ChangePhone { phone: "+905551112233".into() };

        assert_eq!(spaced.canonical_hash_hex(SUB), plain.canonical_hash_hex(SUB));
        assert_eq!(dashed.canonical_hash_hex(SUB), plain.canonical_hash_hex(SUB));
    }

    /// A different number is a different confirmation.
    #[test]
    fn a_different_number_is_a_different_confirmation() {
        let one = ChangePhone { phone: "+905551112233".into() };
        let two = ChangePhone { phone: "+905551112234".into() };
        assert_ne!(one.canonical_hash_hex(SUB), two.canonical_hash_hex(SUB));
    }

    /// The two purposes must not collide — a token for one must never satisfy the other.
    #[test]
    fn the_two_purposes_are_distinct() {
        assert_eq!(ChangeEmail { email: "a@b.c".into() }.purpose(), "change_email");
        assert_eq!(ChangePhone { phone: "+9055".into() }.purpose(), "change_phone");
    }
}