use crate::icrc::model::{IcrcAccountTransactionError, IcrcAccountTransactionRow};
use candid::Nat;
use std::{cmp::Ordering, str::FromStr};
pub(in crate::icrc) fn normalize_transaction_cursor(
value: &str,
) -> Result<String, IcrcAccountTransactionError> {
parse_transaction_cursor(value).map(|cursor| nat_text(&cursor))
}
pub(in crate::icrc) fn validate_canonical_account_transactions(
transactions: &[IcrcAccountTransactionRow],
) -> Result<(), String> {
let mut previous = None;
for transaction in transactions {
validate_transaction_cursor_text(&transaction.id).map_err(|error| error.to_string())?;
if transaction.id.len() > 1 && transaction.id.starts_with('0') {
return Err(format!(
"transaction id {} is not canonical decimal text",
transaction.id
));
}
if let Some(previous) = previous
&& compare_canonical_decimal(&transaction.id, previous).is_ge()
{
return Err("transactions are not unique newest-first rows".to_string());
}
previous = Some(transaction.id.as_str());
}
Ok(())
}
pub(super) fn parse_transaction_cursor(value: &str) -> Result<Nat, IcrcAccountTransactionError> {
validate_transaction_cursor_text(value)?;
Nat::from_str(value).map_err(|error| IcrcAccountTransactionError::InvalidCursor {
value: value.to_string(),
reason: error.to_string(),
})
}
fn validate_transaction_cursor_text(value: &str) -> Result<(), IcrcAccountTransactionError> {
if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) {
return Err(IcrcAccountTransactionError::InvalidCursor {
value: value.to_string(),
reason: "expected unsigned decimal text".to_string(),
});
}
Ok(())
}
pub(super) fn nat_text(value: &Nat) -> String {
value.0.to_str_radix(10)
}
pub(super) fn compare_canonical_decimal(left: &str, right: &str) -> Ordering {
left.len().cmp(&right.len()).then_with(|| left.cmp(right))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nat_text_is_plain_decimal_for_reusable_pagination_cursors() {
assert_eq!(nat_text(&Nat::from(779_513_u64)), "779513");
}
#[test]
fn account_transaction_cursor_accepts_nat_beyond_u64_and_canonicalizes_zeroes() {
assert_eq!(
normalize_transaction_cursor("18446744073709551616")
.expect("arbitrary candid Nat cursor"),
"18446744073709551616"
);
assert_eq!(
normalize_transaction_cursor("00042").expect("decimal cursor"),
"42"
);
}
#[test]
fn canonical_rows_require_unique_descending_arbitrary_size_ids() {
let rows = |ids: &[&str]| {
ids.iter()
.map(|id| {
serde_json::from_value::<IcrcAccountTransactionRow>(serde_json::json!({
"id": id,
"kind": "transfer",
"raw_transaction": {},
}))
.unwrap()
})
.collect::<Vec<_>>()
};
validate_canonical_account_transactions(&rows(&[
"18446744073709551617",
"18446744073709551616",
"9",
"0",
]))
.expect("canonical newest-first rows beyond u64");
for ids in [
vec!["18446744073709551616", "18446744073709551616"],
vec!["9", "10"],
vec!["00042"],
vec![""],
vec!["+1"],
vec!["-1"],
vec!["01"],
vec![" 1"],
vec!["1 "],
vec!["1.0"],
vec!["١"],
vec!["42"],
] {
assert!(validate_canonical_account_transactions(&rows(&ids)).is_err());
}
}
}