road-runner-common 0.11.0

Shared Rust utilities for exchange ecosystem backend services.
Documentation
//! 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())
    }
}

/// 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 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|||");
    }
}