use bigdecimal::BigDecimal;
use sha2::{Digest, Sha256};
pub trait ConfirmAction {
fn purpose(&self) -> &'static str;
fn canonical(&self, user_sub: &str) -> String;
fn canonical_hash_hex(&self, user_sub: &str) -> String {
hex::encode(Sha256::digest(self.canonical(user_sub).as_bytes()))
}
}
#[derive(Debug, Clone)]
pub struct Withdrawal {
pub amount: BigDecimal,
pub currency_id: i64,
pub blockchain_id: i64,
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),
)
}
}
#[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(),
)
}
}
#[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,
self.iban.replace(' ', "").to_uppercase(),
)
}
}
#[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(),
)
}
}
#[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())
}
}
#[derive(Debug, Clone)]
pub struct ChangeTwoFactorMethod {
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(),
)
}
}
#[derive(Debug, Clone)]
pub struct ChangeEmail {
pub email: String,
}
impl ConfirmAction for ChangeEmail {
fn purpose(&self) -> &'static str {
"change_email"
}
fn canonical(&self, user_sub: &str) -> String {
format!(
"v1|change_email|{}|{}",
user_sub,
self.email.trim().to_lowercase(),
)
}
}
#[derive(Debug, Clone)]
pub struct ChangePhone {
pub phone: String,
}
impl ConfirmAction for ChangePhone {
fn purpose(&self) -> &'static str {
"change_phone"
}
fn canonical(&self, user_sub: &str) -> String {
format!(
"v1|change_phone|{}|{}",
user_sub,
normalize_phone(&self.phone),
)
}
}
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
}
}
fn sorted_join(items: &[String]) -> String {
let mut v: Vec<&str> = items.iter().map(|s| s.trim()).collect();
v.sort_unstable();
v.join(",")
}
pub fn canonical_amount(value: &BigDecimal) -> String {
to_plain_string(&value.normalized())
}
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(),
}
}
#[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(),
))
);
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),
};
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");
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";
#[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));
}
#[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")
);
}
#[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));
}
#[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));
}
#[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));
}
#[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");
}
}