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