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
144 changes: 144 additions & 0 deletions contracts/split/src/math.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,4 +146,148 @@ mod tests {
Err(ContractError::ArithmeticOverflow)
);
}

// -----------------------------------------------------------------------
// denormalize_amount standalone tests
// -----------------------------------------------------------------------

/// d=7: identity — normalized value is returned unchanged.
#[test]
fn denormalize_7_decimal_is_identity() {
assert_eq!(denormalize_amount(1_000_000, 7).unwrap(), 1_000_000);
assert_eq!(denormalize_amount(0, 7).unwrap(), 0);
}

/// Round-trip for d=0: token with no decimals (whole units only).
/// normalize: raw * 10^7 → denormalize: / 10^7 → raw
#[test]
fn denormalize_roundtrip_0() {
let raw = 42i128;
let normalized = normalize_amount(raw, 0).unwrap();
let back = denormalize_amount(normalized, 0).unwrap();
assert_eq!(back, raw);
}

/// Round-trip for d=7: canonical scale, no conversion needed.
#[test]
fn denormalize_roundtrip_7() {
let raw = 5_000_000i128;
let normalized = normalize_amount(raw, 7).unwrap();
let back = denormalize_amount(normalized, 7).unwrap();
assert_eq!(back, raw);
}

/// Round-trip for d=8: one decimal place above canonical.
/// normalize: raw / 10 → denormalize: * 10 → raw
#[test]
fn denormalize_roundtrip_8() {
// Use a value divisible by 10 so the divide-then-multiply round-trip is exact.
let raw = 1_000_000_000i128; // 10 tokens at 8-decimal precision
let normalized = normalize_amount(raw, 8).unwrap();
let back = denormalize_amount(normalized, 8).unwrap();
assert_eq!(back, raw);
}

/// Overflow: scaling a very large normalized value up (decimals > 7) wraps
/// past i128::MAX and must return Err(ArithmeticOverflow).
#[test]
fn denormalize_overflow_large_value() {
// With decimals=18 the factor is 10^11. i128::MAX / 10^11 ≈ 1.7e27,
// so any value above that threshold will overflow on checked_mul.
let huge: i128 = i128::MAX / 10 + 1; // definitely overflows * 10^11
assert_eq!(
denormalize_amount(huge, 18),
Err(ContractError::ArithmeticOverflow)
);
}

// -----------------------------------------------------------------------
// Boundary conditions required by acceptance criteria
// -----------------------------------------------------------------------

/// decimals == 0: normalize must scale raw up by exactly 10^7.
#[test]
fn normalize_0_decimal_scales_up_by_10_pow_7() {
assert_eq!(normalize_amount(1, 0).unwrap(), 10_000_000);
assert_eq!(normalize_amount(0, 0).unwrap(), 0);
assert_eq!(normalize_amount(3, 0).unwrap(), 30_000_000);
}

/// decimals == 0: denormalize must scale canonical amount down by exactly 10^7.
#[test]
fn denormalize_0_decimal_scales_down_by_10_pow_7() {
assert_eq!(denormalize_amount(10_000_000, 0).unwrap(), 1);
assert_eq!(denormalize_amount(0, 0).unwrap(), 0);
assert_eq!(denormalize_amount(30_000_000, 0).unwrap(), 3);
}

/// decimals == CANONICAL_DECIMALS (7): normalize returns raw unchanged, including i128::MAX.
#[test]
fn normalize_canonical_decimals_is_identity_including_max() {
assert_eq!(
normalize_amount(i128::MAX, CANONICAL_DECIMALS).unwrap(),
i128::MAX
);
}

/// decimals == CANONICAL_DECIMALS (7): denormalize returns normalized unchanged, including i128::MAX.
#[test]
fn denormalize_canonical_decimals_is_identity_including_max() {
assert_eq!(
denormalize_amount(i128::MAX, CANONICAL_DECIMALS).unwrap(),
i128::MAX
);
}

/// decimals == 18: denormalize scales canonical units up by 10^11.
#[test]
fn denormalize_18_decimal_scales_up_by_10_pow_11() {
// 10_000_000 canonical (1 token at 7 decimals) -> 1_000_000_000_000_000_000 (1 token at 18 decimals)
assert_eq!(
denormalize_amount(10_000_000, 18).unwrap(),
1_000_000_000_000_000_000
);
assert_eq!(denormalize_amount(0, 18).unwrap(), 0);
}

/// raw < 0: normalize returns Err(ArithmeticOverflow) regardless of decimals.
#[test]
fn normalize_negative_raw_always_errors() {
// identity path (decimals == 7)
assert_eq!(normalize_amount(-1, 7), Err(ContractError::ArithmeticOverflow));
// upscale path (decimals < 7)
assert_eq!(normalize_amount(-1, 0), Err(ContractError::ArithmeticOverflow));
assert_eq!(normalize_amount(-1, 6), Err(ContractError::ArithmeticOverflow));
// downscale path (decimals > 7)
assert_eq!(normalize_amount(-1, 18), Err(ContractError::ArithmeticOverflow));
}

/// Very large raw values overflow when upscaling and must return Err(ArithmeticOverflow).
#[test]
fn normalize_i128_max_overflows_on_upscale() {
// decimals == 0: factor = 10^7; i128::MAX * 10^7 overflows u128 as well.
assert_eq!(
normalize_amount(i128::MAX, 0),
Err(ContractError::ArithmeticOverflow)
);
// decimals == 6: factor = 10^1; i128::MAX * 10 overflows.
assert_eq!(
normalize_amount(i128::MAX, 6),
Err(ContractError::ArithmeticOverflow)
);
}

/// Very large normalized values overflow when upscaling in denormalize.
#[test]
fn denormalize_i128_max_overflows_on_upscale() {
// decimals == 18: factor = 10^11; i128::MAX * 10^11 overflows.
assert_eq!(
denormalize_amount(i128::MAX, 18),
Err(ContractError::ArithmeticOverflow)
);
// decimals == 0: factor = 10^7 in the multiply branch? No — decimals==0 < 7 so
// denormalize _divides_ by 10^7, which never overflows. Verify it succeeds instead.
// (The overflow case for denormalize/0 is not possible by construction.)
assert!(denormalize_amount(i128::MAX, 0).is_ok());
}
}
80 changes: 77 additions & 3 deletions contracts/split/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1655,11 +1655,13 @@ impl InvoiceStatus {
InvoiceStatus::PartiallyReleased => 6,
InvoiceStatus::Finalised => 7,
InvoiceStatus::Deleted => 8,
InvoiceStatus::PayoutInProgress => 9,
}
}

/// Decode from a single byte. Unknown byte values panic to prevent
/// silent data corruption from masked migration errors (#616).
/// Decode from a single byte. Unknown byte values fall back to
/// `InvoiceStatus::Pending` so that forward-compatibility reads and
/// corrupt/out-of-range bytes never produce an invalid variant.
pub fn from_u8(v: u8) -> Self {
match v {
0 => InvoiceStatus::Pending,
Expand All @@ -1671,7 +1673,8 @@ impl InvoiceStatus {
6 => InvoiceStatus::PartiallyReleased,
7 => InvoiceStatus::Finalised,
8 => InvoiceStatus::Deleted,
_ => panic!("unknown InvoiceStatus byte: {v}"),
9 => InvoiceStatus::PayoutInProgress,
_ => InvoiceStatus::Pending,
}
}
}
Expand Down Expand Up @@ -1789,3 +1792,74 @@ pub struct PaymentRecord {
pub ledger: u32,
}


#[cfg(test)]
mod tests {
use super::InvoiceStatus;

/// All ten variants must survive a to_u8 → from_u8 round-trip unchanged.
#[test]
fn invoice_status_round_trip_all_variants() {
let variants = [
InvoiceStatus::Pending,
InvoiceStatus::Released,
InvoiceStatus::Refunded,
InvoiceStatus::Expired,
InvoiceStatus::Cancelled,
InvoiceStatus::Disputed,
InvoiceStatus::PartiallyReleased,
InvoiceStatus::Finalised,
InvoiceStatus::Deleted,
InvoiceStatus::PayoutInProgress,
];

for variant in &variants {
let byte = variant.to_u8();
let restored = InvoiceStatus::from_u8(byte);
assert_eq!(
restored, *variant,
"round-trip failed for variant {:?}: to_u8()={} decoded back to {:?}",
variant, byte, restored
);
}
}

/// Each variant's discriminant must be unique — no two variants may map to
/// the same byte, which would cause silent data corruption in compact storage.
#[test]
fn invoice_status_discriminants_are_unique() {
let variants = [
InvoiceStatus::Pending,
InvoiceStatus::Released,
InvoiceStatus::Refunded,
InvoiceStatus::Expired,
InvoiceStatus::Cancelled,
InvoiceStatus::Disputed,
InvoiceStatus::PartiallyReleased,
InvoiceStatus::Finalised,
InvoiceStatus::Deleted,
InvoiceStatus::PayoutInProgress,
];

let mut seen = std::collections::HashSet::new();
for variant in &variants {
let byte = variant.to_u8();
assert!(
seen.insert(byte),
"discriminant collision: byte {} is used by more than one variant",
byte
);
}
}

/// An unknown byte value (e.g. 255) must map to InvoiceStatus::Pending
/// rather than panicking, so that future schema extensions and corrupt reads
/// degrade gracefully.
#[test]
fn invoice_status_unknown_byte_falls_back_to_pending() {
assert_eq!(InvoiceStatus::from_u8(255), InvoiceStatus::Pending);
// A few other out-of-range values for good measure.
assert_eq!(InvoiceStatus::from_u8(10), InvoiceStatus::Pending);
assert_eq!(InvoiceStatus::from_u8(100), InvoiceStatus::Pending);
}
}
35 changes: 35 additions & 0 deletions contracts/split/src/validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,41 @@ mod tests {
assert!(assert_unique_recipients(&env, &v.to_vec()).is_ok());
}

#[test]
fn single_recipient_passes() {
let env = Env::default();
let a = Address::generate(&env);
let mut v: Vec<Address> = Vec::new(&env);
v.push_back(a.clone());
assert!(assert_unique_recipients(&env, &v.to_vec()).is_ok());
}

#[test]
fn non_adjacent_duplicate_rejected() {
// duplicate at index 0 and 2 with a different address at index 1
let env = Env::default();
let a = Address::generate(&env);
let b = Address::generate(&env);
let mut v: Vec<Address> = Vec::new(&env);
v.push_back(a.clone()); // index 0
v.push_back(b.clone()); // index 1
v.push_back(a.clone()); // index 2 — non-adjacent duplicate of index 0
assert_eq!(
assert_unique_recipients(&env, &v.to_vec()),
Err(ContractError::DuplicateRecipient)
);
}

#[test]
fn ten_unique_recipients_passes() {
let env = Env::default();
let mut v: Vec<Address> = Vec::new(&env);
for _ in 0..10 {
v.push_back(Address::generate(&env));
}
assert!(assert_unique_recipients(&env, &v.to_vec()).is_ok());
}

// --- assert_bps_sum (issue #623) ---

#[test]
Expand Down