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
33 changes: 33 additions & 0 deletions reporting/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,40 @@ Aggregates financial health data from the remittance_split, savings_goals, bill_
- Admin-only archival and cleanup of old reports
- Storage TTL management (instance: ~30 days, archive: ~180 days)

<<<<<<< feature/reporting-address-config-integrity
## Dependency contract address integrity

Reporting stores five downstream contract IDs (`remittance_split`, `savings_goals`,
`bill_payments`, `insurance`, `family_wallet`) set via `configure_addresses`.

**Validation (on every `configure_addresses` call)**:

- **No self-reference** — None of the five addresses may equal the reporting
contract’s own address. Pointing a role at this contract would create ambiguous
cross-contract calls and break the intended “one deployment per role” model.
- **Pairwise uniqueness** — All five values must differ. Two roles must not share
the same contract ID, or aggregation would silently read the wrong deployment
twice (audit and correctness risk).

**`verify_dependency_address_set`** exposes the same checks without writing
storage and without requiring authorization. Use it from admin UIs or scripts to
pre-validate a bundle before submitting a configuration transaction.

**Error**: `InvalidDependencyAddressConfiguration` (`6`) when the proposed set
is rejected.

**Security notes**:

- Validation is **O(1)** (fixed five slots, constant comparisons).
- This does **not** prove each address is the *correct* Remitwise deployment for
its role (that requires off-chain governance / deployment manifests). It only
enforces **structural** integrity: distinct callees and no reporting
self-loop.
- Soroban/Stellar contract IDs are not an EVM-style “zero address”; “malformed”
in this layer means duplicate or self-reference as above.
=======
## Quickstart
>>>>>>> main

```rust
// 1. Initialize
Expand Down
115 changes: 72 additions & 43 deletions reporting/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
#![no_std]
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))]
use soroban_sdk::{
contract, contractclient, contractimpl, contracttype, symbol_short, Address, Env, Map, Vec,
contract, contractclient, contracterror, contractimpl, contracttype, symbol_short, Address, Env,
Map, Vec,
};

use remitwise_common::Category;
Expand Down Expand Up @@ -147,54 +148,18 @@ pub struct ContractAddresses {
pub family_wallet: Address,
}

/// Events emitted by the reporting contract
#[contracttype]
#[derive(Clone, Copy)]
/// Errors returned by the reporting contract (`Result` arms and `try_` client helpers).
#[contracterror]
#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
#[repr(u32)]
pub enum ReportingError {
AlreadyInitialized = 1,
NotInitialized = 2,
Unauthorized = 3,
AddressesNotConfigured = 4,
NotAdminProposed = 5,
}

impl From<ReportingError> for soroban_sdk::Error {
fn from(err: ReportingError) -> Self {
match err {
ReportingError::AlreadyInitialized => soroban_sdk::Error::from((
soroban_sdk::xdr::ScErrorType::Contract,
soroban_sdk::xdr::ScErrorCode::InvalidAction,
)),
ReportingError::NotInitialized => soroban_sdk::Error::from((
soroban_sdk::xdr::ScErrorType::Contract,
soroban_sdk::xdr::ScErrorCode::MissingValue,
)),
ReportingError::Unauthorized => soroban_sdk::Error::from((
soroban_sdk::xdr::ScErrorType::Contract,
soroban_sdk::xdr::ScErrorCode::InvalidAction,
)),
ReportingError::AddressesNotConfigured => soroban_sdk::Error::from((
soroban_sdk::xdr::ScErrorType::Contract,
soroban_sdk::xdr::ScErrorCode::MissingValue,
)),
ReportingError::NotAdminProposed => soroban_sdk::Error::from((
soroban_sdk::xdr::ScErrorType::Contract,
soroban_sdk::xdr::ScErrorCode::InvalidAction,
)),
}
}
}

impl From<&ReportingError> for soroban_sdk::Error {
fn from(err: &ReportingError) -> Self {
(*err).into()
}
}

impl From<soroban_sdk::Error> for ReportingError {
fn from(_err: soroban_sdk::Error) -> Self {
ReportingError::Unauthorized
}
/// Dependency address set is not usable: duplicates or self-reference to this reporting contract.
InvalidDependencyAddressConfiguration = 6,
}

#[contracttype]
Expand Down Expand Up @@ -321,6 +286,66 @@ pub struct ReportingContract;

#[contractimpl]
impl ReportingContract {
// ---------------------------------------------------------------------
// Dependency address integrity
// ---------------------------------------------------------------------

/// Validates the five downstream contract addresses before they are persisted or used.
///
/// # Security assumptions
///
/// - **Self-reference**: No slot may equal [`Env::current_contract_address`]. Routing a role
/// back to this reporting contract would make cross-contract calls ambiguous and can break
/// tooling that assumes unique callees.
/// - **Pairwise uniqueness**: Each of `remittance_split`, `savings_goals`, `bill_payments`,
/// `insurance`, and `family_wallet` must refer to a **different** contract ID. Duplicate IDs
/// mean two logical roles silently talk to the same deployment (data integrity / audit risk).
/// Complexity: constant time (five slots, fixed number of equality checks).
fn validate_dependency_address_set(
env: &Env,
addrs: &ContractAddresses,
) -> Result<(), ReportingError> {
let reporting = env.current_contract_address();
let slots = [
&addrs.remittance_split,
&addrs.savings_goals,
&addrs.bill_payments,
&addrs.insurance,
&addrs.family_wallet,
];

for slot in slots {
if *slot == reporting {
return Err(ReportingError::InvalidDependencyAddressConfiguration);
}
}

for i in 0..slots.len() {
for j in (i + 1)..slots.len() {
if *slots[i] == *slots[j] {
return Err(ReportingError::InvalidDependencyAddressConfiguration);
}
}
}

Ok(())
}

/// Verify a [`ContractAddresses`] bundle using the same rules as [`ReportingContract::configure_addresses`].
///
/// Does **not** write storage and does **not** require authorization. Intended for admin UIs and
/// offline checks before submitting a configuration transaction.
///
/// # Errors
///
/// * [`ReportingError::InvalidDependencyAddressConfiguration`] — duplicates or self-reference.
pub fn verify_dependency_address_set(
env: Env,
addrs: ContractAddresses,
) -> Result<(), ReportingError> {
Self::validate_dependency_address_set(&env, &addrs)
}

/// Initialize the reporting contract with an admin address.
///
/// This function must be called only once. The provided admin address will
Expand Down Expand Up @@ -436,6 +461,8 @@ impl ReportingContract {
/// # Errors
/// * `NotInitialized` - If contract has not been initialized
/// * `Unauthorized` - If caller is not the admin
/// * [`ReportingError::InvalidDependencyAddressConfiguration`] - Duplicate addresses or
/// self-reference (this reporting contract used as a dependency).
///
/// # Panics
/// * If `caller` does not authorize the transaction
Expand Down Expand Up @@ -470,6 +497,8 @@ impl ReportingContract {
family_wallet,
};

Self::validate_dependency_address_set(&env, &addresses)?;

env.storage()
.instance()
.set(&symbol_short!("ADDRS"), &addresses);
Expand Down
149 changes: 142 additions & 7 deletions reporting/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,132 @@ fn test_configure_addresses_unauthorized() {
);
}

// ---------------------------------------------------------------------------
// Dependency address configuration integrity (Issue #309)
// ---------------------------------------------------------------------------

#[test]
fn test_configure_addresses_rejects_duplicate_slots() {
let env = create_test_env();
let contract_id = env.register_contract(None, ReportingContract);
let client = ReportingContractClient::new(&env, &contract_id);
let admin = Address::generate(&env);
client.init(&admin);

let a = Address::generate(&env);
let b = Address::generate(&env);
let c = Address::generate(&env);
let d = Address::generate(&env);

let result = client.try_configure_addresses(&admin, &a, &a, &b, &c, &d);
assert!(matches!(
result,
Err(Ok(ReportingError::InvalidDependencyAddressConfiguration))
));
assert!(client.get_addresses().is_none());
}

#[test]
fn test_configure_addresses_rejects_self_reference() {
let env = create_test_env();
let contract_id = env.register_contract(None, ReportingContract);
let client = ReportingContractClient::new(&env, &contract_id);
let admin = Address::generate(&env);
client.init(&admin);

let split = Address::generate(&env);
let savings = Address::generate(&env);
let bills = Address::generate(&env);
let insurance = Address::generate(&env);

let result = client.try_configure_addresses(
&admin,
&split,
&savings,
&bills,
&insurance,
&contract_id,
);
assert!(matches!(
result,
Err(Ok(ReportingError::InvalidDependencyAddressConfiguration))
));
}

#[test]
fn test_configure_invalid_does_not_overwrite_existing_addresses() {
let env = create_test_env();
let contract_id = env.register_contract(None, ReportingContract);
let client = ReportingContractClient::new(&env, &contract_id);
let admin = Address::generate(&env);
client.init(&admin);

let a = Address::generate(&env);
let b = Address::generate(&env);
let c = Address::generate(&env);
let d = Address::generate(&env);
let e = Address::generate(&env);

client.configure_addresses(&admin, &a, &b, &c, &d, &e);

let dup = client.try_configure_addresses(&admin, &a, &a, &c, &d, &e);
assert!(matches!(
dup,
Err(Ok(ReportingError::InvalidDependencyAddressConfiguration))
));

let stored = client.get_addresses().expect("prior config must remain");
assert_eq!(stored.remittance_split, a);
assert_eq!(stored.savings_goals, b);
assert_eq!(stored.bill_payments, c);
assert_eq!(stored.insurance, d);
assert_eq!(stored.family_wallet, e);
}

#[test]
fn test_verify_dependency_address_set_accepts_distinct_addresses() {
let env = create_test_env();
let contract_id = env.register_contract(None, ReportingContract);
let client = ReportingContractClient::new(&env, &contract_id);
let admin = Address::generate(&env);
client.init(&admin);

let addrs = ContractAddresses {
remittance_split: Address::generate(&env),
savings_goals: Address::generate(&env),
bill_payments: Address::generate(&env),
insurance: Address::generate(&env),
family_wallet: Address::generate(&env),
};
assert!(matches!(
client.try_verify_dependency_address_set(&addrs),
Ok(Ok(()))
));
}

#[test]
fn test_verify_dependency_address_set_rejects_duplicates() {
let env = create_test_env();
let contract_id = env.register_contract(None, ReportingContract);
let client = ReportingContractClient::new(&env, &contract_id);
let admin = Address::generate(&env);
client.init(&admin);

let x = Address::generate(&env);
let addrs = ContractAddresses {
remittance_split: x.clone(),
savings_goals: x,
bill_payments: Address::generate(&env),
insurance: Address::generate(&env),
family_wallet: Address::generate(&env),
};
let result = client.try_verify_dependency_address_set(&addrs);
assert!(matches!(
result,
Err(Ok(ReportingError::InvalidDependencyAddressConfiguration))
));
}

#[test]
fn test_get_remittance_summary() {
let env = Env::default();
Expand Down Expand Up @@ -382,7 +508,8 @@ mod failing_remittance_split {
}

#[test]
fn test_get_remittance_summary_partial_data() {
#[should_panic(expected = "Remote call failing to simulate Partial Data")]
fn test_get_remittance_summary_partial_data_remote_failure_propagates() {
let env = soroban_sdk::Env::default();
env.mock_all_auths();
let contract_id = env.register_contract(None, ReportingContract);
Expand All @@ -409,12 +536,8 @@ fn test_get_remittance_summary_partial_data() {
);

let total_amount = 10000i128;
let summary = client.get_remittance_summary(&user, &total_amount, &0, &0);

assert_eq!(summary.total_received, 10000);
assert_eq!(summary.category_breakdown.len(), 4); // Created empty via fallback
assert_eq!(summary.category_breakdown.get(0).unwrap().amount, 0);
assert_eq!(summary.data_availability, DataAvailability::Partial);
// Cross-contract panics from `remittance_split` are not swallowed; callers observe host failure.
let _summary = client.get_remittance_summary(&user, &total_amount, &0, &0);
}

#[test]
Expand Down Expand Up @@ -2047,6 +2170,17 @@ fn test_get_stored_report_missing_key_returns_none() {
let contract_id = env.register_contract(None, ReportingContract);
let client = ReportingContractClient::new(&env, &contract_id);
let admin = Address::generate(&env);
<<<<<<< feature/reporting-address-config-integrity
let user = Address::generate(&env);

env.mock_all_auths();
client.init(&admin);

// `mock_all_auths` would make any address "authorized"; clear custom auths so `require_auth` fails.
env.mock_auths(&[]);

client.get_remittance_summary(&user, &1000, &0, &100);
=======
client.init(&admin);

let user = Address::generate(&env);
Expand All @@ -2055,4 +2189,5 @@ fn test_get_stored_report_missing_key_returns_none() {
result.is_none(),
"missing report must return None, not panic"
);
>>>>>>> main
}
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@
},
"ext": "v0"
},
100000
518401
]
],
[
Expand Down Expand Up @@ -230,7 +230,7 @@
},
"ext": "v0"
},
100000
518401
]
]
]
Expand Down
Loading
Loading