Skip to content

Commit e5821eb

Browse files
authored
Merge pull request Emeka000#1071 from shakurJJ/fix/fee-overflow-address-length-custody-index
fix: address gas DoS, fee overflow, and address length issues
2 parents 56e76dc + 83e12c1 commit e5821eb

6 files changed

Lines changed: 189 additions & 29 deletions

File tree

contracts/src/constants.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,15 @@ pub const MAX_UNIT_ID_LENGTH: u32 = 64;
9696
/// Used for custody event IDs and other cryptographic identifiers.
9797
pub const HEX_HASH_LENGTH: usize = 64;
9898

99+
// ── DELIVERY ADDRESS VALIDATION ────────────────────────────────────────────────
100+
101+
/// Maximum length for a delivery_address string stored in BloodRequest.
102+
///
103+
/// Prevents callers from bloating on-chain storage with multi-KB strings while
104+
/// still accommodating real-world addresses. Every read of BloodRequest pays
105+
/// for the full record size, so this cap keeps storage costs bounded.
106+
pub const MAX_DELIVERY_ADDRESS_LENGTH: u32 = 200;
107+
99108
// ── SUPER ADMIN NOMINATION ────────────────────────────────────────────────────
100109

101110
/// Nomination expiry window in seconds (24 hours).

contracts/src/lib.rs

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ pub enum Error {
7373
DuplicateApproval = 31,
7474
EscrowNotReleasable = 32,
7575
InvalidFeePayload = 33,
76+
/// delivery_address string exceeds MAX_DELIVERY_ADDRESS_LENGTH.
77+
DeliveryAddressTooLong = 34,
7678
}
7779

7880
// Alias for issue/docs terminology.
@@ -556,6 +558,8 @@ pub enum DataKey {
556558
HospitalUnits(Address),
557559
/// Per-unit pending custody event index: unit_id -> String (event_id of the active Pending custody event)
558560
UnitCustodyIndex(u64),
561+
/// Per-unit custody events list: unit_id -> Vec<String> (all event_ids ever created for this unit)
562+
UnitCustodyEvents(u64),
559563
/// Custody trail page: (unit_id, page_number) -> Vec<String> (max 20 event IDs)
560564
UnitTrailPage(u64, u32),
561565
/// Custody trail metadata: unit_id -> TrailMetadata
@@ -581,9 +585,10 @@ pub use storage_lifecycle::{
581585

582586
// Re-export constants for internal use
583587
pub(crate) use constants::{
584-
HEX_HASH_LENGTH, MAX_BATCH_EXPIRY_SIZE, MAX_BATCH_SIZE, MAX_EVENTS_PER_PAGE, MAX_QUANTITY_ML,
585-
MAX_REQUEST_ML, MAX_SHELF_LIFE_DAYS, MAX_UNIT_ID_LENGTH, MIN_QUANTITY_ML, MIN_REQUEST_ML,
586-
MIN_SHELF_LIFE_DAYS, NOMINATION_EXPIRY_SECONDS, SECONDS_PER_DAY, TRANSFER_EXPIRY_SECONDS,
588+
HEX_HASH_LENGTH, MAX_BATCH_EXPIRY_SIZE, MAX_BATCH_SIZE, MAX_DELIVERY_ADDRESS_LENGTH,
589+
MAX_EVENTS_PER_PAGE, MAX_QUANTITY_ML, MAX_REQUEST_ML, MAX_SHELF_LIFE_DAYS, MAX_UNIT_ID_LENGTH,
590+
MIN_QUANTITY_ML, MIN_REQUEST_ML, MIN_SHELF_LIFE_DAYS, NOMINATION_EXPIRY_SECONDS,
591+
SECONDS_PER_DAY, TRANSFER_EXPIRY_SECONDS,
587592
};
588593

589594
/// Pending SuperAdmin nomination entry.
@@ -1393,6 +1398,19 @@ impl HealthChainContract {
13931398
let index_key = DataKey::UnitCustodyIndex(unit_id);
13941399
env.storage().persistent().set(&index_key, &event_id);
13951400

1401+
// Maintain per-unit custody events list so archive_custody_events can find all events
1402+
// for this unit in O(k) (k = events per unit) instead of scanning the full CUSTODY_EVENTS map
1403+
let unit_events_key = DataKey::UnitCustodyEvents(unit_id);
1404+
let mut unit_event_ids: Vec<String> = env
1405+
.storage()
1406+
.persistent()
1407+
.get(&unit_events_key)
1408+
.unwrap_or(Vec::new(&env));
1409+
unit_event_ids.push_back(event_id.clone());
1410+
env.storage()
1411+
.persistent()
1412+
.set(&unit_events_key, &unit_event_ids);
1413+
13961414
let old_status = unit.status;
13971415
unit.status = BloodStatus::InTransit;
13981416
unit.transfer_timestamp = Some(current_time);
@@ -2484,6 +2502,10 @@ impl HealthChainContract {
24842502
return Err(Error::InvalidDeliveryAddress);
24852503
}
24862504

2505+
if delivery_address.len() > MAX_DELIVERY_ADDRESS_LENGTH {
2506+
return Err(Error::DeliveryAddressTooLong);
2507+
}
2508+
24872509
let current_time = env.ledger().timestamp();
24882510
if required_by <= current_time {
24892511
return Err(Error::InvalidRequiredBy);
@@ -5204,6 +5226,31 @@ mod test {
52045226
);
52055227
}
52065228

5229+
#[test]
5230+
#[should_panic(expected = "Error(Contract, #34)")]
5231+
fn test_create_request_delivery_address_too_long() {
5232+
let env = Env::default();
5233+
let (_, _, hospital, client) = setup_contract_with_hospital(&env);
5234+
5235+
env.mock_all_auths();
5236+
let current_time = env.ledger().timestamp();
5237+
let required_by = current_time + 3600;
5238+
5239+
// 201 bytes — one byte over MAX_DELIVERY_ADDRESS_LENGTH (200)
5240+
let addr_bytes = [b'a'; 201];
5241+
let addr_str = core::str::from_utf8(&addr_bytes).unwrap();
5242+
let long_addr = String::from_str(&env, addr_str);
5243+
5244+
client.create_request(
5245+
&hospital,
5246+
&BloodType::OPositive,
5247+
&200,
5248+
&UrgencyLevel::High,
5249+
&required_by,
5250+
&long_addr,
5251+
);
5252+
}
5253+
52075254
#[test]
52085255
#[should_panic(expected = "Error(Contract, #14)")]
52095256
fn test_create_request_empty_delivery_address() {

contracts/src/payments.rs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -346,9 +346,12 @@ impl PendingApproval {
346346
}
347347

348348
impl FeeStructure {
349-
/// Calculates total fees
350-
pub fn total(&self) -> i128 {
351-
self.service_fee + self.network_fee + self.performance_bonus + self.fixed_fee
349+
/// Calculates total fees, returning None if any intermediate sum overflows i128.
350+
pub fn total(&self) -> Option<i128> {
351+
self.service_fee
352+
.checked_add(self.network_fee)?
353+
.checked_add(self.performance_bonus)?
354+
.checked_add(self.fixed_fee)
352355
}
353356

354357
/// Validates fee structure
@@ -365,7 +368,7 @@ impl FeeStructure {
365368

366369
/// Calculates net amount after deducting fees
367370
pub fn calculate_net_amount(&self, gross_amount: i128) -> Result<i128, PaymentError> {
368-
let total_fees = self.total();
371+
let total_fees = self.total().ok_or(PaymentError::Overflow)?;
369372
if total_fees > gross_amount {
370373
return Err(PaymentError::FeesExceedAmount);
371374
}
@@ -386,4 +389,5 @@ pub enum PaymentError {
386389
EscrowNotReleasable,
387390
InvalidMultiSigConfig,
388391
DuplicateApproval,
392+
Overflow,
389393
}

contracts/src/storage_lifecycle.rs

Lines changed: 28 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,19 @@ pub fn archive_custody_events(env: &Env, unit_id: u64) -> Result<bool, Error> {
325325
return Ok(false);
326326
}
327327

328+
// Use the per-unit index to find only this unit's event_ids — O(k) where k = events
329+
// for this unit, avoiding an O(n) scan over all custody events across all units.
330+
let unit_events_key = DataKey::UnitCustodyEvents(unit_id);
331+
let event_ids: Vec<SorobanString> = env
332+
.storage()
333+
.persistent()
334+
.get(&unit_events_key)
335+
.unwrap_or(Vec::new(env));
336+
337+
if event_ids.is_empty() {
338+
return Ok(false);
339+
}
340+
328341
let mut custody_events: Map<SorobanString, CustodyEvent> = env
329342
.storage()
330343
.persistent()
@@ -334,36 +347,30 @@ pub fn archive_custody_events(env: &Env, unit_id: u64) -> Result<bool, Error> {
334347
let mut confirmed: u32 = 0;
335348
let mut cancelled: u32 = 0;
336349
let mut last_event_at: u64 = 0;
337-
let mut keys_to_remove: Vec<SorobanString> = Vec::new(env);
338350

339-
for (event_id, event) in custody_events.iter() {
340-
if event.unit_id != unit_id {
341-
continue;
351+
for i in 0..event_ids.len() {
352+
let event_id = event_ids.get(i).unwrap();
353+
if let Some(event) = custody_events.get(event_id.clone()) {
354+
match event.status {
355+
CustodyStatus::Confirmed => confirmed += 1,
356+
CustodyStatus::Cancelled => cancelled += 1,
357+
CustodyStatus::Pending | CustodyStatus::Recovered => {}
358+
}
359+
if event.initiated_at > last_event_at {
360+
last_event_at = event.initiated_at;
361+
}
362+
custody_events.remove(event_id);
342363
}
343-
match event.status {
344-
CustodyStatus::Confirmed => confirmed += 1,
345-
CustodyStatus::Cancelled => cancelled += 1,
346-
CustodyStatus::Pending | CustodyStatus::Recovered => {}
347-
}
348-
if event.initiated_at > last_event_at {
349-
last_event_at = event.initiated_at;
350-
}
351-
keys_to_remove.push_back(event_id);
352364
}
353365

354-
if keys_to_remove.is_empty() {
355-
return Ok(false);
356-
}
357-
358-
for i in 0..keys_to_remove.len() {
359-
let key = keys_to_remove.get(i).unwrap();
360-
custody_events.remove(key);
361-
}
362366
env.storage()
363367
.persistent()
364368
.set(&CUSTODY_EVENTS, &custody_events);
365369
bump_persistent(env, &CUSTODY_EVENTS);
366370

371+
// Clear the per-unit index now that its events have been archived
372+
env.storage().persistent().remove(&unit_events_key);
373+
367374
let summary = ArchivedCustodySummary {
368375
total_confirmed: confirmed,
369376
total_cancelled: cancelled,

contracts/src/test_payments.rs

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -437,7 +437,7 @@ fn fee_calculation_is_correct() {
437437
fixed_fee: 0,
438438
};
439439

440-
assert_eq!(fees.total(), 20);
440+
assert_eq!(fees.total(), Some(20));
441441
assert_eq!(fees.calculate_net_amount(1_000).unwrap(), 980);
442442
}
443443

@@ -1064,3 +1064,49 @@ fn test_create_payment_fails_with_unauthorized_backend_auth() {
10641064
assert_eq!(e, crate::Error::Unauthorized);
10651065
}
10661066
}
1067+
1068+
// ======================================================
1069+
// FeeStructure::total() overflow tests (#941)
1070+
// ======================================================
1071+
1072+
#[test]
1073+
fn fee_structure_total_returns_correct_sum() {
1074+
let env = Env::default();
1075+
let fee = FeeStructure {
1076+
policy_id: Symbol::new(&env, "p"),
1077+
service_fee: 100,
1078+
network_fee: 50,
1079+
performance_bonus: 25,
1080+
fixed_fee: 10,
1081+
};
1082+
assert_eq!(fee.total(), Some(185));
1083+
}
1084+
1085+
#[test]
1086+
fn fee_structure_total_returns_none_on_i128_overflow() {
1087+
let env = Env::default();
1088+
let fee = FeeStructure {
1089+
policy_id: Symbol::new(&env, "p"),
1090+
service_fee: i128::MAX / 2,
1091+
network_fee: i128::MAX / 2,
1092+
performance_bonus: 3,
1093+
fixed_fee: 0,
1094+
};
1095+
assert_eq!(fee.total(), None);
1096+
}
1097+
1098+
#[test]
1099+
fn fee_structure_calculate_net_errors_on_overflow() {
1100+
let env = Env::default();
1101+
let fee = FeeStructure {
1102+
policy_id: Symbol::new(&env, "p"),
1103+
service_fee: i128::MAX / 2,
1104+
network_fee: i128::MAX / 2,
1105+
performance_bonus: 3,
1106+
fixed_fee: 0,
1107+
};
1108+
assert_eq!(
1109+
fee.calculate_net_amount(1_000_000),
1110+
Err(PaymentError::Overflow)
1111+
);
1112+
}

contracts/src/test_storage_layout.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -842,3 +842,50 @@ fn test_storage_layout_fingerprint_regression_guard() {
842842
"Storage layout compatibility changed: duplicate key symbols detected. Add migration guardrails before changing key names."
843843
);
844844
}
845+
846+
// ── #944: archive_custody_events per-unit index ─────────────────────────────
847+
848+
#[test]
849+
fn test_initiate_transfer_populates_unit_custody_events_index() {
850+
use crate::{BloodComponent, BloodType, DataKey, HealthChainContract, HealthChainContractClient};
851+
use soroban_sdk::{testutils::Address as _, Address, Env, String, Vec};
852+
853+
let env = Env::default();
854+
env.mock_all_auths();
855+
856+
let contract_id = env.register(HealthChainContract, ());
857+
let client = HealthChainContractClient::new(&env, &contract_id);
858+
859+
let admin = Address::generate(&env);
860+
let bank = Address::generate(&env);
861+
let hospital = Address::generate(&env);
862+
863+
client.initialize(&admin);
864+
client.register_blood_bank(&bank);
865+
client.register_hospital(&hospital);
866+
867+
let expiration = env.ledger().timestamp() + 86400 * 10;
868+
let unit_id = client.register_blood(
869+
&bank,
870+
&BloodType::OPositive,
871+
&BloodComponent::WholeBlood,
872+
&450,
873+
&expiration,
874+
&None,
875+
);
876+
client.allocate_blood(&bank, &unit_id, &hospital);
877+
let event_id = client.initiate_transfer(&bank, &unit_id);
878+
879+
// Verify UnitCustodyEvents index was populated with the event_id
880+
env.as_contract(&contract_id, || {
881+
let key = DataKey::UnitCustodyEvents(unit_id);
882+
let stored: Vec<String> = env
883+
.storage()
884+
.persistent()
885+
.get(&key)
886+
.expect("UnitCustodyEvents index must be populated after initiate_transfer");
887+
888+
assert_eq!(stored.len(), 1);
889+
assert_eq!(stored.get(0).unwrap(), event_id);
890+
});
891+
}

0 commit comments

Comments
 (0)