diff --git a/Cargo.lock b/Cargo.lock index 52912cb..8951c44 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -296,6 +296,14 @@ dependencies = [ "comebackhere-invoice-errors", "hex", "proptest", + "serde_json", + "soroban-sdk", +] + +[[package]] +name = "comebackhere-invoice-errors" +version = "0.1.0" +dependencies = [ "soroban-sdk", ] @@ -328,7 +336,6 @@ version = "1.0.0" dependencies = [ "comebackhere-compliance", "comebackhere-compliance-client", - "comebackhere-multisig", "comebackhere-treasury", "soroban-sdk", ] diff --git a/contracts/compliance/tests/operator_privilege_boundary_test.rs b/contracts/compliance/tests/operator_privilege_boundary_test.rs new file mode 100644 index 0000000..a769662 --- /dev/null +++ b/contracts/compliance/tests/operator_privilege_boundary_test.rs @@ -0,0 +1,131 @@ +use compliance::{ComplianceContract, ComplianceContractClient, ContractError}; +use soroban_sdk::{testutils::Address as _, Address, Env}; + +fn setup() -> (Env, Address, Address, ComplianceContractClient<'static>) { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let subject = Address::generate(&env); + let id = env.register_contract(None, ComplianceContract); + let client = ComplianceContractClient::new(&env, &id); + client.initialize(&admin); + (env, admin, subject, client) +} + +#[test] +fn operator_can_call_address_status() { + let (env, admin, subject, client) = setup(); + let operator = Address::generate(&env); + + // Allow a test subject + client.allow_address(&admin, &subject); + + // Set operator + client.set_operator(&admin, &operator); + + // Operator should be able to call address_status + let result = client.try_address_status(&operator, &subject); + assert!(result.is_ok()); +} + +#[test] +fn operator_rejected_from_allow_address() { + let (env, admin, subject, client) = setup(); + let operator = Address::generate(&env); + + // Set operator + client.set_operator(&admin, &operator); + + // Operator should NOT be able to call allow_address + let result = client.try_allow_address(&operator, &subject); + assert_eq!(result, Err(Ok(ContractError::Unauthorized))); +} + +#[test] +fn operator_rejected_from_block_address() { + let (env, admin, subject, client) = setup(); + let operator = Address::generate(&env); + + // Allow subject first + client.allow_address(&admin, &subject); + + // Set operator + client.set_operator(&admin, &operator); + + // Operator should NOT be able to call block_address + let result = client.try_block_address(&operator, &subject, &None); + assert_eq!(result, Err(Ok(ContractError::Unauthorized))); +} + +#[test] +fn operator_rejected_from_clear_address() { + let (env, admin, subject, client) = setup(); + let operator = Address::generate(&env); + + // Allow subject first + client.allow_address(&admin, &subject); + + // Set operator + client.set_operator(&admin, &operator); + + // Operator should NOT be able to call clear_address + let result = client.try_clear_address(&operator, &subject); + assert_eq!(result, Err(Ok(ContractError::Unauthorized))); +} + +#[test] +fn admin_can_call_all_operations() { + let (env, admin, subject, client) = setup(); + let operator = Address::generate(&env); + + // Set operator + client.set_operator(&admin, &operator); + + // Admin should still be able to call all operations + client.allow_address(&admin, &subject); + assert!(client.is_allowed(&subject)); + + let result = client.try_address_status(&admin, &subject); + assert!(result.is_ok()); + + client.block_address(&admin, &subject, &None); + assert!(!client.is_allowed(&subject)); + + client.clear_address(&admin, &subject); + assert!(client.is_allowed(&subject)); +} + +#[test] +fn operator_privilege_correctly_distinguished_in_multiple_operations() { + let (env, admin, subject1, client) = setup(); + let subject2 = Address::generate(&env); + let operator = Address::generate(&env); + + // Setup initial state + client.allow_address(&admin, &subject1); + client.allow_address(&admin, &subject2); + + // Set operator + client.set_operator(&admin, &operator); + + // Operator can read address_status + let result1 = client.try_address_status(&operator, &subject1); + assert!(result1.is_ok()); + + let result2 = client.try_address_status(&operator, &subject2); + assert!(result2.is_ok()); + + // Operator cannot perform admin operations (block, allow, clear) + assert!(client + .try_block_address(&operator, &subject1, &None) + .is_err()); + assert!(client.try_allow_address(&operator, &subject2).is_err()); + assert!(client.try_clear_address(&operator, &subject1).is_err()); + + // Admin can still perform all operations + client.block_address(&admin, &subject1, &None); + assert!(!client.is_allowed(&subject1)); + + client.clear_address(&admin, &subject1); + assert!(client.is_allowed(&subject1)); +} diff --git a/contracts/invoice/Cargo.toml b/contracts/invoice/Cargo.toml index b557588..c8c4abb 100644 --- a/contracts/invoice/Cargo.toml +++ b/contracts/invoice/Cargo.toml @@ -19,4 +19,5 @@ invoice-errors = { package = "comebackhere-invoice-errors", path = "../../crates [dev-dependencies] hex = "0.4" proptest = "1" +serde_json = "1" soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/invoice/tests/amount_validation_differential_test.rs b/contracts/invoice/tests/amount_validation_differential_test.rs new file mode 100644 index 0000000..40e424b --- /dev/null +++ b/contracts/invoice/tests/amount_validation_differential_test.rs @@ -0,0 +1,189 @@ +// Differential fuzzing test: compares Rust invoice amount validation against +// an independent Python reference implementation with the same test vectors. +// This catches bugs that might be shared between implementation and test if +// both were written in the same language with the same mental model. + +use invoice::{InvoiceContract, InvoiceContractClient, MaybeAddress, MaybeBytes}; +use serde_json::Value; +use soroban_sdk::{testutils::Address as _, Address, Env}; +use std::io::Write; +use std::process::Command; + +const USDC_FACTOR: i128 = 10_000_000; + +fn client() -> (Env, InvoiceContractClient<'static>) { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let id = env.register_contract(None, InvoiceContract); + let c = InvoiceContractClient::new(&env, &id); + c.initialize(&admin); + (env, c) +} + +/// Calls the Python reference implementation with the given amounts. +/// Returns (valid, error_name) where valid=true means no error, valid=false means error with error_name. +fn python_validate(amount_usdc: i128, gross_usdc: i128) -> (bool, Option) { + // Use string representation to avoid JSON number overflow issues with large i128 values + let input = format!( + r#"{{"amount_usdc": {}, "gross_usdc": {}}}"#, + amount_usdc, gross_usdc + ); + + let mut child = Command::new("python3") + .arg("scripts/reference_amount_validation.py") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .current_dir("/workspaces/COMEBACKHERE-contracts") + .spawn() + .expect("failed to spawn Python process"); + + { + let stdin = child.stdin.as_mut().expect("failed to open stdin"); + stdin + .write_all(input.as_bytes()) + .expect("failed to write to stdin"); + } + + let output = child.wait_with_output().expect("failed to wait on Python"); + let stdout = String::from_utf8_lossy(&output.stdout); + + let result: Value = serde_json::from_str(&stdout) + .expect(&format!("failed to parse Python output: {}", stdout)); + + let valid = result["valid"].as_bool().expect("missing 'valid' field"); + let error = if let Some(e) = result["error"].as_str() { + Some(e.to_string()) + } else if result["error"].is_null() { + None + } else { + Some(result["error"].to_string()) + }; + + (valid, error) +} + +/// Attempts to create an invoice with the given amounts via the Rust implementation. +/// Returns true if successful, false if rejected. +fn rust_validate(amount_usdc: i128, gross_usdc: i128) -> bool { + let (env, client) = client(); + let merchant = Address::generate(&env); + + client + .try_create_invoice( + &merchant, + &amount_usdc, + &gross_usdc, + &3600, + &MaybeBytes::None, + &MaybeBytes::None, + &0, + &MaybeAddress::None, + ) + .is_ok() +} + +#[test] +fn differential_fuzz_canonical_test_cases() { + // Test cases that must be handled the same way in both implementations. + let test_cases: &[(i128, i128)] = &[ + // Invalid: negative amounts + (-1, USDC_FACTOR), + (-USDC_FACTOR, USDC_FACTOR), + (i128::MIN, i128::MAX), + // Invalid: zero amounts + (0, 0), + (0, USDC_FACTOR), + // Invalid: gross < amount + (USDC_FACTOR, USDC_FACTOR - 1), + (2 * USDC_FACTOR, USDC_FACTOR), + // Invalid: below USDC_FACTOR precision + (1, 1), + (100, USDC_FACTOR), + (USDC_FACTOR - 1, USDC_FACTOR - 1), + (USDC_FACTOR - 1, USDC_FACTOR), + (USDC_FACTOR, USDC_FACTOR - 1), + // Valid: minimum valid amounts + (USDC_FACTOR, USDC_FACTOR), + (USDC_FACTOR, 2 * USDC_FACTOR), + // Valid: round numbers + (10 * USDC_FACTOR, 10 * USDC_FACTOR), + (100 * USDC_FACTOR, 100 * USDC_FACTOR), + (1000 * USDC_FACTOR, 1000 * USDC_FACTOR), + // Valid: large amounts + (i128::MAX / 2, i128::MAX / 2), + (i128::MAX, i128::MAX), + ]; + + for &(amount, gross) in test_cases { + let rust_ok = rust_validate(amount, gross); + let (python_ok, _python_error) = python_validate(amount, gross); + + assert_eq!( + rust_ok, python_ok, + "Differential mismatch for amount={} gross={}: Rust said {}, Python said {}", + amount, gross, rust_ok, python_ok + ); + } +} + +#[test] +fn differential_fuzz_boundary_cases() { + // Systematic boundary testing around USDC_FACTOR + let boundaries: &[i128] = &[ + USDC_FACTOR - 2, + USDC_FACTOR - 1, + USDC_FACTOR, + USDC_FACTOR + 1, + USDC_FACTOR + 2, + 2 * USDC_FACTOR - 1, + 2 * USDC_FACTOR, + 2 * USDC_FACTOR + 1, + ]; + + for &a in boundaries { + for &g in boundaries { + if a > 0 && g > 0 && g >= a { + // Skip cases that would be too slow to test with Python subprocess each time + // (but this is a boundary test, not exhaustive) + let rust_ok = rust_validate(a, g); + let (python_ok, python_error) = python_validate(a, g); + + assert_eq!( + rust_ok, python_ok, + "Differential mismatch at boundary: amount={} gross={}: Rust={}, Python={} ({:?})", + a, g, rust_ok, python_ok, python_error + ); + } + } + } +} + +#[test] +fn differential_fuzz_off_by_one() { + // Test values one off from precision boundaries + let values: &[i128] = &[ + USDC_FACTOR - 1, + USDC_FACTOR, + USDC_FACTOR + 1, + 10 * USDC_FACTOR - 1, + 10 * USDC_FACTOR, + 10 * USDC_FACTOR + 1, + ]; + + for &amount in values { + for &gross in values { + if amount > 0 && gross >= amount { + let rust_ok = rust_validate(amount, gross); + let (python_ok, python_error) = python_validate(amount, gross); + + assert_eq!( + rust_ok, python_ok, + "Off-by-one mismatch: amount={} gross={}: Rust={}, Python={} ({:?})", + amount, gross, rust_ok, python_ok, python_error + ); + } + } + } +} diff --git a/scripts/reference_amount_validation.py b/scripts/reference_amount_validation.py new file mode 100755 index 0000000..b3a4ba7 --- /dev/null +++ b/scripts/reference_amount_validation.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +""" +Reference implementation of invoice amount/precision validation rules. +Used for differential testing against the Rust implementation. +""" + +import sys +import json + +USDC_FACTOR = 10_000_000 + + +class ValidationError(Exception): + """Raised when validation fails""" + pass + + +def validate_amount(amount_usdc: int, gross_usdc: int) -> None: + """ + Implements require_positive_amount and require_usdc_precision. + Raises ValidationError if validation fails. + """ + # require_positive_amount: both must be positive, and gross >= amount + if amount_usdc <= 0 or gross_usdc < amount_usdc: + raise ValidationError("InvalidAmount") + + # require_usdc_precision: both must be >= USDC_FACTOR (1 USDC in stroops) + if amount_usdc < USDC_FACTOR or gross_usdc < USDC_FACTOR: + raise ValidationError("AmountPrecision") + + +def main(): + """ + Reads JSON from stdin with {amount_usdc, gross_usdc} pairs. + Outputs JSON {valid: true/false, error: null/"ErrorName"} + """ + try: + data = json.load(sys.stdin) + amount = data.get("amount_usdc") + gross = data.get("gross_usdc") + + if amount is None or gross is None: + print(json.dumps({"valid": False, "error": "MissingField"})) + sys.exit(0) + + try: + validate_amount(amount, gross) + print(json.dumps({"valid": True, "error": None})) + except ValidationError as e: + print(json.dumps({"valid": False, "error": str(e)})) + + except json.JSONDecodeError: + print(json.dumps({"valid": False, "error": "InvalidJSON"})) + sys.exit(1) + + +if __name__ == "__main__": + main()