Skip to content
Open
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@ target
*.rs.bk
.DS_Store
.env
temp_auto_push.bat
.env.*
/target/
/target_local/
.md-*
hidden
.gitignore
branch_structure.json
12 changes: 10 additions & 2 deletions contracts/.gas-baseline.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"_schema_version": 1,
"_description": "CPU instructions and memory bytes baseline per contract entrypoint. Generated by scripts/gas-regression.sh. Edit ONLY via that script or with an explicit maintainer override.",
"_override_label": "gas-override",
__override_label": "gas-override",
"callora-vault": {
"init": {
"cpu": 119549,
Expand All @@ -16,7 +16,7 @@
"mem": 3236
},
"batch_deduct": {
"cpu": 343235,
"cpu": 240265,
"mem": 3236
},
"add_address": {
Expand Down Expand Up @@ -121,6 +121,14 @@
"cpu": 600000,
"mem": 55000
},
"record_deduction": {
"cpu": 180000,
"mem": 18000
},
"record_deductions": {
"cpu": 420000,
"mem": 32000
},
"withdraw_developer_balance": {
"cpu": 420000,
"mem": 40000
Expand Down
12 changes: 9 additions & 3 deletions contracts/settlement/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ use soroban_sdk::contracterror;
/// | 48 | DuplicateRequestId | Deduction request ID has already been recorded |
/// | 49 | LengthMismatch | Paired batch vectors have different lengths |
/// | 50 | InvalidCursor | Batch cursor is invalid or listing cursor is past the end |
/// | 51 | TooManyResults | Unpaginated developer-balance view exceeds its cap |
/// | 51 | DuplicateRequestIdInBatch | Deduction request ID is repeated within the batch |
/// | 52 | ExceedsUnallocated | Credit exceeds unallocated deducted funds |
/// | 53 | TooManyResults | Unpaginated developer-balance view exceeds its cap |
#[contracterror]
#[derive(Clone, Copy, Debug, PartialEq)]
#[repr(u32)]
Expand Down Expand Up @@ -127,7 +129,11 @@ pub enum SettlementError {
LengthMismatch = 49,
/// #1135: `cursor > developers.len()` or `limit == 0`.
InvalidCursor = 50,
/// Deduction request ID is repeated within a single batch.
DuplicateRequestIdInBatch = 51,
/// Credit would exceed the unallocated deducted funds held by settlement.
ExceedsUnallocated = 52,
/// `get_all_developer_balances` index exceeds
/// `MAX_DEVELOPER_BALANCES_PAGE_SIZE`; use a paginated view instead.
TooManyResults = 51,
}
TooManyResults = 53,
}
42 changes: 40 additions & 2 deletions contracts/settlement/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,28 @@ pub fn emit_deduction_recorded(env: &Env, data: DeductionRecordedEvent) {
env.events().publish((event_deduction_recorded(env),), data);
}

/// Emit one `deduction_recorded` event per item in a batch settlement.
///
/// **What**: Publishes the same `deduction_recorded` event that
/// `emit_deduction_recorded` produces, once for each item, preserving event
/// parity with N single `record_deduction` calls.
///
/// **How**: Iterates `items` and calls `emit_deduction_recorded` for each
/// `DeductionRecordedEvent` in order.
///
/// **Why**: `record_deductions` batches writes but must keep the observable
/// event stream identical to the per-item path.
///
/// # Arguments
/// * `env` - Soroban environment handle.
/// * `events` - Ordered per-item deduction events to publish.
pub fn emit_deduction_recorded_batch(env: &Env, events: &soroban_sdk::Vec<DeductionRecordedEvent>) {
for data in events.iter() {
emit_deduction_recorded(env, data);
}
}


/// Returns the Symbol for the `"balance_credited"` event topic.
///
/// **What**: Returns the canonical symbol for balance credited events.
Expand Down Expand Up @@ -359,7 +381,6 @@ pub fn event_price_set(env: &Env) -> Symbol {
pub fn event_price_removed(env: &Env) -> Symbol {
Symbol::new(env, "price_removed")
}

// ─── Emit helpers ────────────────────────────────────────────────────────────

/// Emit `"price_set"` when an offering price is created or changed.
Expand Down Expand Up @@ -811,6 +832,23 @@ mod tests {
);
}

#[test]
fn test_emit_deduction_recorded_batch_empty_is_noop() {
let env = Env::default();
let events: soroban_sdk::Vec<DeductionRecordedEvent> = soroban_sdk::Vec::new(&env);
emit_deduction_recorded_batch(&env, &events);
assert_eq!(env.events().all().len(), 0);
}

#[test]
fn test_event_allocation_recorded_bytes() {
let env = Env::default();
assert_eq!(
event_allocation_recorded(&env),
Symbol::new(&env, "allocation_recorded")
);
}

#[test]
fn test_event_payment_received_bytes() {
let env = Env::default();
Expand Down Expand Up @@ -979,4 +1017,4 @@ mod tests {
Symbol::new(&env, "upgrade_cancelled")
);
}
}
}
233 changes: 232 additions & 1 deletion contracts/settlement/src/test_record_deduction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ extern crate std;

use crate::{
CalloraSettlement, CalloraSettlementClient, DeductionRecordedEvent, SettlementError,
StorageKey, INSTANCE_BUMP_AMOUNT, PERSISTENT_BUMP_AMOUNT,
StorageKey, INSTANCE_BUMP_AMOUNT, MAX_BATCH_SIZE, PERSISTENT_BUMP_AMOUNT,
};
use soroban_sdk::testutils::storage::{Instance as _, Persistent as _};
use soroban_sdk::testutils::{Address as _, Events as _, Ledger as _};
Expand All @@ -26,6 +26,14 @@ fn has_marker(env: &Env, contract_id: &Address, request_id: u64) -> bool {
})
}

fn has_marker_batch(env: &Env, contract_id: &Address, request_id: u64) -> bool {
env.as_contract(contract_id, || {
env.storage()
.persistent()
.has(&StorageKey::DeductionRequest(request_id))
})
}

#[test]
fn record_deduction_rejects_non_positive_without_consuming_request_id() {
let (env, contract_id, _) = setup();
Expand Down Expand Up @@ -183,3 +191,226 @@ fn record_deduction_requires_vault_authorization() {
client.record_deduction(&100, &10);
assert_eq!(client.get_total_received(), 100);
}

#[test]
fn record_deductions_accepts_valid_items_and_updates_accounting() {
let (env, contract_id, vault) = setup();
let client = CalloraSettlementClient::new(&env, &contract_id);
let pool_before = client.get_global_pool();

let items = soroban_sdk::vec![&env, (10i128, 1u64), (20i128, 2u64), (30i128, 3u64)];
client.record_deductions(&items);

assert_eq!(client.get_total_received(), 60);
assert_eq!(client.get_global_pool(), pool_before);
for id in [1u64, 2, 3] {
assert!(has_marker_batch(&env, &contract_id, id));
}
assert_eq!(env.events().all().len(), 3);
assert_eq!(
env.auths(),
std::vec![(
vault.clone(),
soroban_sdk::testutils::AuthorizedInvocation {
function: soroban_sdk::testutils::AuthorizedFunction::Contract((
contract_id.clone(),
Symbol::new(&env, "record_deductions"),
(items.clone(),).into_val(&env),
)),
sub_invocations: std::vec![],
},
),]
);
}

#[test]
fn record_deductions_rejects_empty_and_oversized_batches() {
let (env, contract_id, _) = setup();
let client = CalloraSettlementClient::new(&env, &contract_id);

let empty: soroban_sdk::Vec<(i128, u64)> = soroban_sdk::vec![&env];
assert_eq!(
client.try_record_deductions(&empty),
Err(Ok(SettlementError::EmptyBatch.into()))
);
assert!(env.events().all().is_empty());
assert_eq!(client.get_total_received(), 0);

let mut too_many: soroban_sdk::Vec<(i128, u64)> = soroban_sdk::vec![&env];
for i in 0..(MAX_BATCH_SIZE + 1) {
too_many.push_back((1i128, i as u64));
}
assert_eq!(
client.try_record_deductions(&too_many),
Err(Ok(SettlementError::BatchTooLarge.into()))
);
assert!(env.events().all().is_empty());
assert_eq!(client.get_total_received(), 0);
}

#[test]
fn record_deductions_rejects_non_positive_amount_atomically() {
let (env, contract_id, _) = setup();
let client = CalloraSettlementClient::new(&env, &contract_id);

for bad in [0i128, -1, i128::MIN] {
let items = soroban_sdk::vec![&env, (10i128, 1u64), (bad, 2u64), (30i128, 3u64)];
assert_eq!(
client.try_record_deductions(&items),
Err(Ok(SettlementError::AmountNotPositive.into()))
);
assert!(env.events().all().is_empty());
assert_eq!(client.get_total_received(), 0);
for id in [1u64, 2, 3] {
assert!(!has_marker_batch(&env, &contract_id, id));
}
}
}

#[test]
fn record_deductions_rejects_duplicate_within_batch_atomically() {
let (env, contract_id, _) = setup();
let client = CalloraSettlementClient::new(&env, &contract_id);

let items = soroban_sdk::vec![&env, (10i128, 1u64), (20i128, 2u64), (30i128, 1u64)];
assert_eq!(
client.try_record_deductions(&items),
Err(Ok(SettlementError::DuplicateRequestId.into()))
);
assert!(env.events().all().is_empty());
assert_eq!(client.get_total_received(), 0);
for id in [1u64, 2] {
assert!(!has_marker_batch(&env, &contract_id, id));
}
}

#[test]
fn record_deductions_rejects_already_recorded_id_atomically() {
let (env, contract_id, _) = setup();
let client = CalloraSettlementClient::new(&env, &contract_id);
client.record_deduction(&5, &2);
let total_before = client.get_total_received();
let events_before = env.events().all().len();

let items = soroban_sdk::vec![&env, (10i128, 1u64), (20i128, 2u64)];
assert_eq!(
client.try_record_deductions(&items),
Err(Ok(SettlementError::DuplicateRequestId.into()))
);
assert_eq!(env.events().all().len(), events_before);
assert_eq!(client.get_total_received(), total_before);
assert!(!has_marker_batch(&env, &contract_id, 1));
}

#[test]
fn record_deductions_rejects_overflow_atomically() {
let (env, contract_id, _) = setup();
let client = CalloraSettlementClient::new(&env, &contract_id);
env.as_contract(&contract_id, || {
env.storage()
.instance()
.set(&StorageKey::TotalReceived, &(i128::MAX - 5));
});

let items = soroban_sdk::vec![&env, (3i128, 1u64), (3i128, 2u64)];
assert_eq!(
client.try_record_deductions(&items),
Err(Ok(SettlementError::PoolOverflow.into()))
);
assert!(env.events().all().is_empty());
assert_eq!(client.get_total_received(), i128::MAX - 5);
for id in [1u64, 2] {
assert!(!has_marker_batch(&env, &contract_id, id));
}
}

#[test]
fn record_deductions_requires_vault_authorization() {
let (env, contract_id, _) = setup();
let client = CalloraSettlementClient::new(&env, &contract_id);
env.mock_auths(&[]);
let items = soroban_sdk::vec![&env, (100i128, 10u64)];
assert!(client.try_record_deductions(&items).is_err());
assert!(env.events().all().is_empty());
assert_eq!(client.get_total_received(), 0);
assert!(!has_marker_batch(&env, &contract_id, 10));
}

#[test]
fn record_deductions_event_parity_with_single_calls() {
let (env, contract_id, _) = setup();
let client = CalloraSettlementClient::new(&env, &contract_id);

let items = soroban_sdk::vec![&env, (10i128, 1u64), (20i128, 2u64), (30i128, 3u64)];
client.record_deductions(&items);
let batch_events = env.events().all();
assert_eq!(batch_events.len(), 3);
for (idx, (amount, request_id)) in [(10i128, 1u64), (20, 2), (30, 3)].iter().enumerate() {
let (emitter, topics, data) = batch_events.get(idx as u32).unwrap();
assert_eq!(emitter, contract_id);
assert_eq!(
topics,
soroban_sdk::vec![&env, Symbol::new(&env, "deduction_recorded").into_val(&env)]
);
assert_eq!(
DeductionRecordedEvent::from_val(&env, &data),
DeductionRecordedEvent {
amount: *amount,
request_id: *request_id,
}
);
}
}

#[test]
fn record_deductions_bumps_persistent_markers_and_instance_ttl() {
let (env, contract_id, _) = setup();
let client = CalloraSettlementClient::new(&env, &contract_id);
let items = soroban_sdk::vec![&env, (1i128, 7u64), (1i128, 8u64)];
client.record_deductions(&items);
env.as_contract(&contract_id, || {
assert_eq!(
env.storage()
.persistent()
.get_ttl(&StorageKey::DeductionRequest(7)),
PERSISTENT_BUMP_AMOUNT
);
assert_eq!(
env.storage()
.persistent()
.get_ttl(&StorageKey::DeductionRequest(8)),
PERSISTENT_BUMP_AMOUNT
);
assert_eq!(env.storage().instance().get_ttl(), INSTANCE_BUMP_AMOUNT);
});
assert_eq!(client.get_total_received(), 2);
}

#[test]
fn record_deductions_accepts_max_batch_size() {
let (env, contract_id, _) = setup();
let client = CalloraSettlementClient::new(&env, &contract_id);
let mut items: soroban_sdk::Vec<(i128, u64)> = soroban_sdk::vec![&env];
for i in 0..MAX_BATCH_SIZE {
items.push_back((1i128, i as u64));
}
client.record_deductions(&items);
assert_eq!(client.get_total_received(), MAX_BATCH_SIZE as i128);
assert_eq!(env.events().all().len(), MAX_BATCH_SIZE);
}

#[test]
fn record_deductions_id_zero_follows_single_call_rule() {
let (env, contract_id, _) = setup();
let client = CalloraSettlementClient::new(&env, &contract_id);
let items = soroban_sdk::vec![&env, (5i128, 0u64)];
client.record_deductions(&items);
assert_eq!(client.get_total_received(), 5);
assert!(has_marker_batch(&env, &contract_id, 0));

assert_eq!(
client.try_record_deductions(&items),
Err(Ok(SettlementError::DuplicateRequestId.into()))
);
assert_eq!(client.get_total_received(), 5);
}
Loading
Loading