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
19 changes: 11 additions & 8 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
# TODO: Add distribute_usdc UntrustedTokenContract and Nonce Tests

## Steps
- [x] Step 1: Add `test_distribute_usdc_rejects_untrusted_token` to `remittance_split/src/test.rs`
- [x] Step 2: Add `test_distribute_usdc_nonce_advances_and_marks_used_on_success` to `remittance_split/src/test.rs`
- [x] Step 3: Add `test_distribute_usdc_nonce_unchanged_on_failure` to `remittance_split/src/test.rs`
- [x] Step 4: Run `cargo test -p remittance_split` and verify all tests pass *(Rust toolchain not present in this environment; tests verified by manual review against existing patterns)*

- [x] Implement deterministic tie-break for Top-N bills/savings reports (ID ascending on equal amounts/targets) in reporting/src/lib.rs
- [ ] Add Top-N tests for:
- [ ] deterministic ordering across repeated calls
- [ ] tie-break rule when amounts/targets are equal
- [ ] cap enforcement at MAX_ITEMS_PER_REPORT and MAX_ITEMS_PER_REPORT+1
- [ ] fewer than N and zero items
- [ ] partial-data degradation (DataAvailability::Partial) under dependency pagination cap
- [x] Add/Update documentation under docs/ describing Top-N ordering contract + tie-break rule
- [ ] Run `cargo test -p reporting get_top -- --nocapture`
- [ ] Run `cargo test -p reporting`
- [ ] Run clippy for reporting (per repo standard)


54 changes: 54 additions & 0 deletions docs/savings-goals-tag-removal-idempotency.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Savings Goals: `remove_tags_from_goal` Idempotency + Tag Index Cleanup

This document records the expected semantics for tag removal in `SavingsGoalContract`.

## What is guaranteed

For a goal identified by `goal_id` and owned by `caller`, `remove_tags_from_goal(caller, goal_id, tags)` has the following guarantees:

1. **Owner-only authorization**
- Calls must be authorized by the goal owner.
- Non-owners must fail authentication.

2. **Canonicalization matches add path**
- Input `tags` are validated and canonicalized using the shared helper
`remitwise_common::canonicalize_tags`.
- Mixed-case / otherwise non-canonical inputs remove the correct
canonical tag entries.

3. **Absent tags are a no-op**
- Removing a tag that is not present on the goal must **not** error.
- The goal’s tag set and any tag index entries for other tags remain unchanged.

4. **Idempotent removal (remove same tag twice)**
- If the first call removes a tag, a second call removing the same
(canonical) tag must be a no-op.
- The second call must not reintroduce any removed tag into:
- `goal.tags`
- the `(owner, tag) -> Vec<goal_id>` tag index.

5. **Last-tag removal produces a valid empty tag set**
- Removing the final remaining tag leaves the goal with an empty
`goal.tags` set.

6. **No dangling tag index entries**
- After a successful removal, the goal must not appear under the removed tag in
`get_goals_by_tag`.
- If the last goal ID is removed from an index entry, the underlying
storage entry for that `(owner, tag)` key is deleted.

## Why this matters

Tag indexes are used by off-chain clients to implement efficient goal
search and filtering. Any stale or dangling index entries can cause clients to
observe “ghost” goals under removed tags.

## Tests

The following tests cover the guarantees above and must remain passing:

- `test_remove_tags_absent_tag_is_noop_and_does_not_touch_index`
- `test_remove_tags_same_tag_twice_is_idempotent_and_index_clean`
- `test_remove_last_tag_leaves_empty_tags_and_cleans_index`
- Owner-only auth failure tests for `remove_tags_from_goal`

50 changes: 50 additions & 0 deletions docs/top-n-report-ordering.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Top-N report ordering contract (deterministic)

This document defines the ordering contract for the reporting contract’s Top-N endpoints:

- `get_top_bills_report`
- `get_top_savings_report`

## Determinism requirement

For the same inputs (same dependency data set, same report period, same configured dependency addresses), the contract must return results in a reproducible order across:

- repeated calls
- different networks
- different ledger environments

This contract guarantees that tie cases cannot produce non-deterministic output.

## Bills Top-N ordering

`get_top_bills_report` returns up to `MAX_ITEMS_PER_REPORT` `Bill` items.

Sorting rule:

1. Primary sort: `amount` **descending**
2. Tie-break (when `amount` is equal): `id` **ascending**

No padding is applied. If fewer than `MAX_ITEMS_PER_REPORT` matching items exist, the report contains exactly those items.

## Savings Top-N ordering

`get_top_savings_report` returns up to `MAX_ITEMS_PER_REPORT` `SavingsGoal` items.

Sorting rule:

1. Primary sort: `target_amount` **descending**
2. Tie-break (when `target_amount` is equal): `id` **ascending**

No padding is applied. If fewer than `MAX_ITEMS_PER_REPORT` matching items exist, the report contains exactly those items.

## Gas boundedness and partial data

Both Top-N implementations are gas-bounded by maintaining a bounded in-memory top list (capped to `MAX_ITEMS_PER_REPORT`).

If dependency paging is capped and the reporting contract cannot retrieve all dependency items, the report MUST still:

- return a bounded number of items (never more than `MAX_ITEMS_PER_REPORT`)
- set `data_availability` to `DataAvailability::Partial`

The ordering contract above still applies to the returned (possibly truncated) top list.

59 changes: 47 additions & 12 deletions reporting/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,14 @@ pub struct FinancialHealthReport {
pub generated_at: u64,
}

/// Top-N bills by amount or due date

/// Top-N bills sorted deterministically.
///
/// Ordering contract (reproducible across calls/networks):
/// - Primary sort: `amount` descending
/// - Tie-break (when `amount` is equal): `id` ascending
///
/// Returned `items` are capped to [`MAX_ITEMS_PER_REPORT`] (no padding).
#[contracttype]
#[derive(Clone)]
pub struct TopNBillsReport {
Expand All @@ -180,7 +187,13 @@ pub struct TopNBillsReport {
pub data_availability: DataAvailability,
}

/// Top-N savings goals by target amount or progress
/// Top-N savings goals sorted deterministically.
///
/// Ordering contract (reproducible across calls/networks):
/// - Primary sort: `target_amount` descending
/// - Tie-break (when `target_amount` is equal): `id` ascending
///
/// Returned `items` are capped to [`MAX_ITEMS_PER_REPORT`] (no padding).
#[contracttype]
#[derive(Clone)]
pub struct TopNSavingsReport {
Expand All @@ -193,6 +206,7 @@ pub struct TopNSavingsReport {
pub data_availability: DataAvailability,
}


/// Contract addresses configuration
#[contracttype]
#[derive(Clone)]
Expand Down Expand Up @@ -1424,19 +1438,30 @@ impl ReportingContract {
total_amount += bill.amount;
total_count += 1;

// Sorted insertion for Top-N
// Sorted insertion for Top-N (bounded)
//
// Ordering contract (deterministic):
// 1) Primary: amount descending
// 2) Tie-break: bill id ascending
let mut inserted = false;
for i in 0..top_bills.len() {
let existing_bill_amount = match top_bills.get(i) {
Some(b) => b.amount,
None => 0,
let existing = top_bills.get(i).unwrap();
let should_insert = if bill.amount > existing.amount {
true
} else if bill.amount < existing.amount {
false
} else {
// Equal amounts → deterministic tie-break by id ascending
bill.id < existing.id
};
if bill.amount > existing_bill_amount {

if should_insert {
top_bills.insert(i, bill.clone());
inserted = true;
break;
}
}

if !inserted && top_bills.len() < MAX_ITEMS_PER_REPORT {
top_bills.push_back(bill);
} else if top_bills.len() > MAX_ITEMS_PER_REPORT {
Expand Down Expand Up @@ -1504,14 +1529,24 @@ impl ReportingContract {
total_saved += goal.current_amount;
total_count += 1;

// Sorted insertion for Top-N
// Sorted insertion for Top-N (bounded)
//
// Ordering contract (deterministic):
// 1) Primary: target amount descending
// 2) Tie-break: savings goal id ascending
let mut inserted = false;
for i in 0..top_goals.len() {
let existing_goal_target = match top_goals.get(i) {
Some(g) => g.target_amount,
None => 0,
let existing = top_goals.get(i).unwrap();
let should_insert = if goal.target_amount > existing.target_amount {
true
} else if goal.target_amount < existing.target_amount {
false
} else {
// Equal targets → deterministic tie-break by id ascending
goal.id < existing.id
};
if goal.target_amount > existing_goal_target {

if should_insert {
top_goals.insert(i, goal.clone());
inserted = true;
break;
Expand Down
Loading
Loading