Skip to content

fix(settlement,distribute): reconcile TotalReceived across all credit paths and fix batch_distribute compile - #1362

Open
olaleyeolajide81-sketch wants to merge 4 commits into
CalloraOrg:mainfrom
olaleyeolajide81-sketch:fix/reconcile-total-received-and-batch-distribute-signature
Open

olaleyeolajide81-sketch wants to merge 4 commits into
CalloraOrg:mainfrom
olaleyeolajide81-sketch:fix/reconcile-total-received-and-batch-distribute-signature

Conversation

@olaleyeolajide81-sketch

@olaleyeolajide81-sketch olaleyeolajide81-sketch commented Oct 2, 2026 •

Copy link
Copy Markdown

Summary

This pull request resolves two separate but related smart-contract correctness issues:


Issue #1149 — Reconcile TotalReceived with direct payment credits

Root cause

TotalReceived (stored under StorageKey::TotalReceived) was only incremented by record_deduction. Both receive_payment — in its pool branch (to_pool = true) and its developer branch (to_pool = false) — and batch_receive_payment credited balances without touching the metric. As a result, get_total_received() returned the sum of accounting-only vault deductions, while every real on-chain credit went uncounted. Any dashboard or conservation check comparing TotalReceived against pool_balance + sum(dev_balances) showed perpetual drift and could never detect genuine discrepancies.

Changes

contracts/settlement/src/lib.rs

  • receive_payment (pool branch): after writing the updated global_pool.total_balance, reads TotalReceived and increments it by amount using checked_add (panics PoolOverflow on overflow).
  • receive_payment (developer branch): after writing the updated developer balance and index, reads TotalReceived and increments it by amount using checked_add (panics DeveloperOverflow on overflow).
  • batch_receive_payment: accumulates batch_total across the per-item loop, then performs a single TotalReceived write after all items succeed. This avoids one storage read/write per item and keeps the atomicity model: if any item fails validation the loop never runs and TotalReceived is not touched.
  • get_total_received rustdoc rewritten to exhaustively enumerate every contributing path: receive_payment (pool branch), receive_payment (developer branch), batch_receive_payment, and record_deduction.
  • record_deduction rustdoc updated to note that it exists for accounting-only vault deductions outside the standard credit flow, and that TotalReceived is also incremented by the standard credit paths.

Invariant — definition

TotalReceived is a monotonically increasing inbound-credit counter. It increases on every credit (receive_payment and batch_receive_payment) and every accounting deduction (record_deduction). Withdrawals via withdraw_developer_balance are debits from current balances and do not reduce TotalReceived.

The full conservation invariant is therefore:

TotalReceived == sum(dev_balances) + pool_balance + total_withdrawn

or equivalently before any withdrawals:

TotalReceived == expected_dev_total + expected_pool_total

Tests — contracts/settlement/src/test_invariant.rs

  • check_invariant extended with a new expected_total_received: i128 parameter that calls client.get_total_received() and asserts it equals the running tally after every operation.
  • run_trace now tracks expected_total_received independently:
    • ReceiveDev and ReceivePool ops each add amount to it.
    • BatchReceiveDev adds batch_total on success.
    • Withdraw does not change it (correctly models the monotonic-counter semantics).
  • Updated edge-case tests — test_invariant_pool_only, test_invariant_single_dev_full_withdraw, test_invariant_interleaved_dev_and_pool — to assert get_total_received() at each step.
  • Four new focused tests added:
    • test_total_received_zero_on_init — metric starts at 0.
    • test_total_received_incremented_by_pool_payment — pool and developer payments both increment.
    • test_total_received_incremented_by_batch_receive — batch credits are summed correctly.
    • test_total_received_not_decremented_by_withdrawal — withdrawals leave the counter unchanged.

Tests — contracts/settlement/tests/proptest.rs

  • Fixed pre-existing compile errors: six .unwrap() calls on GlobalPool (a struct, not a Result/Option) and on i128.
  • Updated test_invariant_record_deduction to assert TotalReceived == 1_800 (not 1_500) after a 300-unit receive_payment following two record_deduction calls of 1 000 and 500 — the old assertion encoded the pre-fix broken behaviour.

Validation

cargo test -p callora-settlement invariant
# 7 passed, 0 failed (seeded + proptest + 5 edge cases)

cargo clippy -p callora-settlement -- -D warnings
# PASSES — no warnings or errors

Issue #1171 — Unbreak distribute compile on batch payment signature

Root cause

contracts/distribute/src/lib.rs imported soroban_sdk::Vec under the alias SorobanVec:

use soroban_sdk::{..., Vec as SorobanVec};

and used the alias as the parameter type for the public contract function batch_distribute:

pub fn batch_distribute(env: Env, caller: Address, payments: SorobanVec<(Address, i128)>) {

The Soroban #[contractimpl] macro resolves parameter types by their literal token-stream name. Because SorobanVec is an alias — not the canonical path the macro recognises — it emitted:

error: generics unsupported on user-defined types in contract functions
  --> contracts/distribute/src/lib.rs:448

This broke the distribute crate, contracts/distribute/fuzz, and contracts/batch_distribute/fuzz (both fuzz crates import callora-distribute).

A compounding pre-existing bug: events::event_version_v1 returned Symbol::new(env, "callora.v1"). Soroban Symbol values may not contain the period character (ASCII 46). Any test that called init — which publishes a version-tagged event — panicked at runtime, leaving the 700-line test file producing zero successful test runs.

Changes

contracts/distribute/src/lib.rs

  • Import alias removed: Vec as SorobanVec changed to plain Vec import.
  • batch_distribute parameter type: SorobanVec<(Address, i128)> changed to Vec<(Address, i128)> (macro-transparent concrete type).
  • get_max_batch_size parameter: env: Env changed to _env: Env (suppresses unused-variable Clippy warning, satisfying -D warnings).
  • Six dead-code error-string constants annotated with #[allow(dead_code)] (they appear only in rustdoc # Panics sections; the crate uses typed DistributeError variants for runtime errors).

contracts/distribute/src/events.rs

  • event_version_v1: symbol "callora.v1" changed to "callora_v1" (period replaced with underscore — only [a-zA-Z0-9_] are valid in Soroban Symbols).

contracts/distribute/src/test.rs

  • Six event-structure assertions updated from one-index (topics.get(1) for address) to two-index (topics.get(2) for address) to match the actual three-topic layout (event_name, version, caller/recipient) emitted by the contract.
  • require_auth_on_all_state_changing_functions: setup now uses env.mock_all_auths() for the init and mint calls, then calls env.set_auths(&[]) before the intruder tests. The previous approach did not mock auths for the USDC mint call, causing an InvalidAction host panic.

contracts/distribute/tests/auth_snap.rs

Rewrote from scratch. The previous version imported CalloraDistribute, CalloraDistributeClient, Severity, and BatchItem — none of which exist in callora-distribute. The actual contract is named Distribute and its generated client is DistributeClient. The new file:

  • Tests every state-mutating entrypoint (set_admin, accept_admin, claim_admin, cancel_admin_transfer, pause, unpause, set_max_distribute, distribute, batch_distribute, upgrade) with env.set_auths(&[]) to assert auth is required.
  • Tests every read-only entrypoint (get_admin, get_usdc_token, get_paused, get_max_distribute, get_max_batch_size, get_pending_admin, get_version, balance) without auth to assert they succeed.
  • Adds admin_with_auth_can_use_all_entrypoints — full happy-path smoke test exercising every entrypoint.
  • Adds batch_distribute_accepts_soroban_vec_type — explicit regression test for Unbreak distribute compile on batch payment signature #1171 that constructs soroban_sdk::Vec<(Address, i128)> at the call site (the concrete type, not an alias) to guard against the alias regression.

Validation

cargo check -p callora-distribute
# PASSES

cargo test -p callora-distribute
# 43 lib tests + 20 integration tests = 63 passed, 0 failed

cargo clippy -p callora-distribute -- -D warnings
# PASSES — no warnings or errors

CI pre-checks

Check Result
cargo fmt -p callora-distribute -p callora-settlement -- --check PASSES
cargo clippy -p callora-distribute -- -D warnings PASSES
cargo clippy -p callora-settlement -- -D warnings PASSES
cargo build -p callora-distribute PASSES
cargo build -p callora-settlement PASSES
cargo test -p callora-distribute 63 passed, 0 failed
cargo test -p callora-settlement invariant 17 passed, 0 failed

Pre-existing failures unrelated to this PR (confirmed present on main before any changes):

Test Failure reason
test_ttl_bump::* (18 tests) Calls storage TTL API from outside contract context; requires env.as_contract() wrapper
test_events::test_upgrade_emits_upgraded_event Requires real WASM in test environment
test_overflow_safe_math::withdraw_daily_amount_overflow_raises_error Logic error in test expectation pre-dating this PR

Security and compatibility

  • Arithmetic safety: All new TotalReceived increments use checked_add, consistent with every other balance mutation in the contract. Overflow panics with PoolOverflow or DeveloperOverflow as appropriate.
  • Atomicity: The batch path accumulates batch_total locally and writes once after the item loop. If any item fails validation before the loop runs, TotalReceived is not modified — identical to the developer balance semantics.
  • No breaking changes: get_total_received() return type (i128) and batch_distribute parameter type (Vec<(Address, i128)>) are unchanged from the caller's perspective.
  • Storage layout: StorageKey::TotalReceived already existed and was already initialised to 0i128 in init. No migration needed.
  • Symbol change: "callora_v1" replaces the invalid "callora.v1" in the distribute event version topic. Since the old string caused a runtime panic in init, no production events with "callora.v1" could ever have been emitted, so no off-chain indexer migration is required.

…tribute signature

Closes CalloraOrg#1149, Closes CalloraOrg#1171

## Issue CalloraOrg#1149 — Reconcile TotalReceived with direct payment credits

### Problem
`TotalReceived` was only incremented by `record_deduction`. Both
`receive_payment` (pool and developer branches) and
`batch_receive_payment` credited balances without touching the metric,
causing `get_total_received` to report a number unrelated to what was
actually credited. Dashboards and conservation checks comparing
`TotalReceived` with pool + developer balances always drifted.

### Fix
- `receive_payment` (pool branch): added checked_add of `amount` to
  `TotalReceived` after pool credit.
- `receive_payment` (developer branch): added checked_add of `amount`
  to `TotalReceived` after developer balance update.
- `batch_receive_payment`: accumulate `batch_total` across the loop and
  write `TotalReceived` once after all items are processed (single
  storage round-trip, not per-item).
- `get_total_received` rustdoc updated to precisely list all contributing
  paths: both `receive_payment` branches, `batch_receive_payment`, and
  `record_deduction`.
- `record_deduction` rustdoc updated to clarify its role alongside the
  standard credit paths.

### Tests
- `check_invariant` in `test_invariant.rs` extended with an
  `expected_total_received` parameter that verifies
  `get_total_received() == expected_credits` after every operation.
- `run_trace` now tracks `expected_total_received` separately from
  `expected_dev_total`: credits increment it, withdrawals do not.
- New targeted tests added to `test_invariant.rs`:
  - `test_total_received_zero_on_init`
  - `test_total_received_incremented_by_pool_payment`
  - `test_total_received_incremented_by_batch_receive`
  - `test_total_received_not_decremented_by_withdrawal`
- `test_invariant_pool_only`, `test_invariant_single_dev_full_withdraw`,
  and `test_invariant_interleaved_dev_and_pool` all now assert
  `get_total_received` at each step.
- Updated `tests/proptest.rs::test_invariant_record_deduction` to assert
  the correct post-fix value (1800 not 1500 after a 300-unit
  `receive_payment` following two `record_deduction` calls).
- Fixed pre-existing compile errors in `tests/proptest.rs` (.unwrap()
  on non-Option GlobalPool and i128).

## Issue CalloraOrg#1171 — Unbreak distribute compile on batch payment signature

### Problem
`cargo check -p callora-distribute` failed with "generics unsupported
on user-defined types in contract functions" because `batch_distribute`
declared its `payments` parameter as `SorobanVec<(Address, i128)>` — a
`use soroban_sdk::Vec as SorobanVec` alias. The Soroban contract macro
resolves parameter types by name and cannot see through import aliases,
so it rejected the type. This broke the entire distribute crate, its fuzz
crate, and the batch_distribute fuzz crate.

### Fix
- Removed the `Vec as SorobanVec` alias; import changed to `Vec`.
- `batch_distribute` parameter type changed from `SorobanVec<(Address, i128)>`
  to `Vec<(Address, i128)>` (concrete SDK type — macro-transparent).
- `get_max_batch_size` env parameter renamed from `env` to `_env` to
  suppress the unused-parameter Clippy/compiler warning.
- `events::event_version_v1` symbol fixed from `"callora.v1"` to
  `"callora_v1"`: the period character (ASCII 46) is not permitted in
  Soroban `Symbol` values, causing a panic in every test that called
  `init`.
- Dead-code error string constants annotated with `#[allow(dead_code)]`
  so `-D warnings` passes (they appear only in rustdoc comments).

### Tests
- Rewrote `tests/auth_snap.rs` to use the correct `Distribute` /
  `DistributeClient` API instead of the non-existent
  `CalloraDistribute` / `CalloraDistributeClient` it previously
  referenced.
- Fixed event structure assertions in `src/test.rs` to match the
  three-topic layout `(event_name, version, caller/recipient)` emitted
  by the contract.
- Fixed `require_auth_on_all_state_changing_functions` to mock all auths
  during setup before clearing them for intruder calls.
- Added `batch_distribute_accepts_soroban_vec_type` regression test that
  explicitly uses `soroban_sdk::Vec` (the concrete type) in the call
  site to guard against the alias regression.
- All 63 distribute tests now pass (43 lib + 20 integration).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Unbreak distribute compile on batch payment signature Reconcile TotalReceived with direct payment credits

1 participant