ic-query 0.45.9

Internet Computer query library for CloudEngine, NNS, SNS, ICRC, system canisters, and public network metadata
Documentation
//! Module: icrc::live::account_transactions::cursor
//!
//! Responsibility: validate and compare arbitrary-size account-transaction cursors.
//! Does not own: collection state, index wire decoding, transport, or reports.
//! Boundary: keeps public cursor normalization and snapshot ordering on canonical decimal text.

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