Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions contracts/validators/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ remain stable (guarded by a code-stability test).
| `require_non_negative_amount` | `(i128) -> Result<i128, ValidatorError>` | `AmountNegative` |
| `checked_add_amount` | `(i128, i128) -> Result<i128, ValidatorError>` | `Overflow` |
| `require_in_range` | `(i128, i128, i128) -> Result<i128, ValidatorError>` | `OutOfRange` |
| `capabilities` | `(&Env) -> u64` | — (read-only capability bitmap view) |

The new `capabilities()` view returns a stable `u64` bitmask for the validator
features exposed by the current crate version. Clients can compare bitmasks
across upgrades to detect capability deltas without needing to inspect the
contract implementation directly.

`MAX_VALIDATED_STRING_LEN` (256) is the maximum byte length accepted by the
string validators.
Expand Down
91 changes: 14 additions & 77 deletions contracts/validators/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
#![no_std]

mod errors;
mod validators;
mod views;

pub use errors::ValidatorError;
pub use validators::{
checked_add_amount, is_visible_ascii_metadata, normalize_visible_ascii, require_in_range,
require_non_negative_amount, require_positive_amount, MAX_VALIDATED_STRING_LEN,
};
pub use views::{
capabilities, ALL_CAPABILITIES, CAP_AMOUNT_VALIDATION, CAP_CHECKED_ARITHMETIC,
CAP_RANGE_VALIDATION, CAP_STRING_VALIDATION,
};

//! Reusable, semantic input validators for the Callora contracts.
//!
//! This crate exists to replace generic panics and opaque `Result<_, ()>`
Expand All @@ -14,80 +28,3 @@
//! - Acceptance iff `s` is non-empty, length ≤ [`MAX_VALIDATED_STRING_LEN`],
//! every byte is in `0x20..=0x7E`, and neither the first nor last byte is
//! ASCII space (`0x20`).

use soroban_sdk::String;

/// Error type for validation failures in [`normalize_visible_ascii`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ValidationError {
/// String is empty.
Empty,
/// String exceeds maximum length.
TooLong,
/// String contains non-visible ASCII characters.
InvalidCharacter,
/// String has leading or trailing spaces.
InvalidSpacing,
}

/// Maximum byte length accepted by [`normalize_visible_ascii`].
pub const MAX_VALIDATED_STRING_LEN: u32 = 256;

/// Normalize a bounded metadata string to its canonical on-chain form.
///
/// Accepted strings are non-empty visible ASCII with no leading or trailing
/// spaces. This rejects C0/DEL controls, zero-width and bidi controls, and
/// Unicode confusables by construction. Because ASCII has no decomposed forms,
/// the returned value is NFC-normalized and byte-stable.
///
/// # Errors
/// Returns `Err(ValidationError)` when the string is empty, exceeds
/// [`MAX_VALIDATED_STRING_LEN`], contains a non-visible-ASCII byte, or has
/// leading/trailing ASCII space.
pub fn normalize_visible_ascii(
s: &String,
) -> Result<[u8; MAX_VALIDATED_STRING_LEN as usize], ValidationError> {
let len = s.len();
if len == 0 {
return Err(ValidationError::Empty);
}
if len > MAX_VALIDATED_STRING_LEN {
return Err(ValidationError::TooLong);
}

let mut buf = [0u8; MAX_VALIDATED_STRING_LEN as usize];
s.copy_into_slice(&mut buf[..len as usize]);
let bytes = &buf[..len as usize];

if bytes[0] == b' ' || bytes[len as usize - 1] == b' ' {
return Err(ValidationError::InvalidSpacing);
}

for &b in bytes {
if !(0x20..=0x7e).contains(&b) {
return Err(ValidationError::InvalidCharacter);
}
}

Ok(buf)
}

/// Return whether a bounded metadata string is accepted by the on-chain policy.
pub fn is_visible_ascii_metadata(s: &String) -> bool {
normalize_visible_ascii(s).is_ok()
}

/// Pure reference predicate mirroring [`normalize_visible_ascii`] over raw bytes.
///
/// Used by property tests to check the on-chain validator against an independent
/// oracle that does not touch the Soroban host.
pub fn bytes_are_visible_ascii(bytes: &[u8]) -> bool {
let len = bytes.len();
if len == 0 || len > MAX_VALIDATED_STRING_LEN as usize {
return false;
}
if bytes[0] == b' ' || bytes[len - 1] == b' ' {
return false;
}
bytes.iter().all(|b| (0x20..=0x7e).contains(b))
}
23 changes: 22 additions & 1 deletion contracts/validators/src/test_validators.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ use crate::validators::{
checked_add_amount, is_visible_ascii_metadata, normalize_visible_ascii, require_in_range,
require_non_negative_amount, require_positive_amount, MAX_VALIDATED_STRING_LEN,
};
use crate::ValidatorError;
use crate::{
capabilities, CAP_AMOUNT_VALIDATION, CAP_CHECKED_ARITHMETIC, CAP_RANGE_VALIDATION,
CAP_STRING_VALIDATION, ValidatorError,
};
use soroban_sdk::{Env, String};

// --- normalize_visible_ascii -------------------------------------------------
Expand Down Expand Up @@ -168,3 +171,21 @@ fn empty_range_rejects_everything() {
// min > max is an empty range.
assert_eq!(require_in_range(5, 10, 1), Err(ValidatorError::OutOfRange));
}

// --- capability bitmap -------------------------------------------------------

#[test]
fn capabilities_exposes_supported_validator_features() {
let env = Env::default();
let caps = capabilities(&env);

assert_ne!(caps, 0);
assert_eq!(
caps,
CAP_STRING_VALIDATION
| CAP_AMOUNT_VALIDATION
| CAP_CHECKED_ARITHMETIC
| CAP_RANGE_VALIDATION
);
assert_eq!(caps & !((1u64 << 4) - 1), 0);
}
53 changes: 53 additions & 0 deletions contracts/validators/src/views.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
use soroban_sdk::Env;

/// Bit 0 — String validation: clients can rely on the visible-ASCII metadata
/// validators exposed by [`crate::normalize_visible_ascii`] and
/// [`crate::is_visible_ascii_metadata`].
pub const CAP_STRING_VALIDATION: u64 = 1 << 0;

/// Bit 1 — Amount validation: clients can use the positive/non-negative amount
/// preconditions exposed by [`crate::require_positive_amount`] and
/// [`crate::require_non_negative_amount`].
pub const CAP_AMOUNT_VALIDATION: u64 = 1 << 1;

/// Bit 2 — Checked arithmetic: clients can use the overflow-safe checked
/// addition helper [`crate::checked_add_amount`].
pub const CAP_CHECKED_ARITHMETIC: u64 = 1 << 2;

/// Bit 3 — Range validation: clients can use the inclusive range checks from
/// [`crate::require_in_range`].
pub const CAP_RANGE_VALIDATION: u64 = 1 << 3;

/// Bits 4–63 are reserved for future validator capabilities and remain clear.
pub const ALL_CAPABILITIES: u64 = CAP_STRING_VALIDATION
| CAP_AMOUNT_VALIDATION
| CAP_CHECKED_ARITHMETIC
| CAP_RANGE_VALIDATION;

/// Return the validator capability bitmap.
///
/// This is a pure read-only view and does not inspect or mutate storage. The
/// returned bitmask is stable for the current validator feature set so clients
/// can detect capability deltas across upgrades.
pub fn capabilities(_env: &Env) -> u64 {
ALL_CAPABILITIES
}

#[cfg(test)]
mod tests {
use super::*;
use soroban_sdk::Env;

#[test]
fn capabilities_equals_all_mask() {
let env = Env::default();
assert_eq!(capabilities(&env), ALL_CAPABILITIES);
}

#[test]
fn reserved_bits_are_clear() {
let env = Env::default();
let caps = capabilities(&env);
assert_eq!(caps & !((1u64 << 4) - 1), 0);
}
}
Loading