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
13 changes: 11 additions & 2 deletions bill_payments/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ pub enum BillEvent {
}

#[contracttype]
#[derive(Clone)]
pub struct StorageStats {
pub active_bills: u32,
pub archived_bills: u32,
Expand Down Expand Up @@ -630,12 +631,16 @@ impl BillPayments {
Self::adjust_unpaid_total(&env, &bill_owner, amount);

// Emit event for audit trail
env.events().publish(
(symbol_short!("bill"), BillEvent::Created),
(next_id, bill_owner.clone(), bill_external_ref),
);
RemitwiseEvents::emit(
&env,
EventCategory::State,
EventPriority::Medium,
symbol_short!("created"),
(next_id, bill_owner, amount, due_date),
(next_id, bill_owner.clone(), amount, due_date),
);

Ok(next_id)
Expand Down Expand Up @@ -713,12 +718,16 @@ impl BillPayments {
}

// Emit event for audit trail
env.events().publish(
(symbol_short!("bill"), BillEvent::Paid),
(bill_id, caller.clone(), bill_external_ref),
);
RemitwiseEvents::emit(
&env,
EventCategory::Transaction,
EventPriority::High,
symbol_short!("paid"),
(bill_id, caller, paid_amount),
(bill_id, caller.clone(), paid_amount),
);

Ok(())
Expand Down
2 changes: 1 addition & 1 deletion integration_tests/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@ insurance = { path = "../insurance" }
orchestrator = { path = "../orchestrator" }

[dev-dependencies]
soroban-sdk = { version = "=21.7.7", features = ["testutils"] }
soroban-sdk = { version = "=21.7.7", features = ["testutils"] }
90 changes: 90 additions & 0 deletions savings_goals/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,48 @@ The Savings Goals contract allows users to create savings goals, add/withdraw fu
- Owner-controlled goal metadata tags
- Event emission for audit trails
- Storage TTL management
- **Batch atomic operations**: All-or-nothing fund additions to multiple goals with comprehensive validation

## Batch Atomicity

The `batch_add_to_goals` function provides atomic batch funding operations with the following guarantees:

### Atomicity Semantics
- **All-or-nothing execution**: Either all contributions succeed or none do
- **Upfront validation**: All inputs are validated before any storage modifications
- **Rollback on failure**: If any contribution fails, no changes are persisted

### Security Features
- **Overflow protection**: Prevents integer overflow in goal balances
- **Authorization checks**: Verifies caller owns all target goals
- **Amount validation**: Ensures positive contribution amounts
- **Size limits**: Maximum 50 goals per batch to prevent gas exhaustion

### Event Emission
- `BatchStartedEvent`: Emitted when batch processing begins
- `FundsAdded`: Emitted for each successful contribution
- `GoalCompleted`: Emitted when goals reach their targets
- `BatchCompletedEvent`: Emitted when all contributions succeed
- `BatchFailedEvent`: Emitted if batch processing fails

### Usage Example
```rust
// Prepare batch contributions
let contributions = Vec::from_array(&env, [
ContributionItem { goal_id: goal1_id, amount: 500 },
ContributionItem { goal_id: goal2_id, amount: 1000 },
ContributionItem { goal_id: goal3_id, amount: 250 },
]);

// Execute atomic batch
let processed_count = client.batch_add_to_goals(&user, &contributions)?;
assert_eq!(processed_count, 3); // All contributions succeeded
```

### Error Handling
- `BatchTooLarge`: Exceeds maximum batch size (50 goals)
- `BatchValidationFailed`: Invalid contribution data or authorization failure
- `InsufficientBalance`: (Future use - not currently triggered in batch context)
- Deterministic cursor pagination with owner-bound consistency checks

## Pagination Stability
Expand Down Expand Up @@ -104,6 +146,17 @@ pub struct SavingsGoal {
}
```

#### ContributionItem

```rust
pub struct ContributionItem {
pub goal_id: u32,
pub amount: i128,
}
```

Used in batch operations to specify goal contributions.

### Functions

#### `init(env)`
Expand Down Expand Up @@ -157,6 +210,23 @@ Withdraws funds from a savings goal.

**Panics:** If caller not owner, goal locked, insufficient balance, etc.

#### `batch_add_to_goals(env, caller, contributions) -> Result<u32, SavingsGoalsError>`

Atomically adds funds to multiple savings goals with all-or-nothing semantics.

**Parameters:**

- `caller`: Address of the caller (must own all goals)
- `contributions`: Vector of ContributionItem structs (max 50 items)

**Returns:** Number of successful contributions (same as input length on success)

**Errors:**
- `BatchTooLarge`: More than 50 contributions
- `BatchValidationFailed`: Invalid data or authorization failure

**Atomicity:** All contributions succeed or none do. Comprehensive validation occurs before any storage changes.

#### `lock_goal(env, caller, goal_id) -> bool`

Locks a goal to prevent withdrawals.
Expand Down Expand Up @@ -327,6 +397,26 @@ let remaining = savings_goals::withdraw_from_goal(
);
```

### Batch Funding

```rust
// Create multiple goals
let emergency_id = savings_goals::create_goal(env, user, "Emergency", 1000_0000000, future_date);
let vacation_id = savings_goals::create_goal(env, user, "Vacation", 2000_0000000, future_date);
let education_id = savings_goals::create_goal(env, user, "Education", 5000_0000000, future_date);

// Prepare batch contributions
let contributions = Vec::from_array(&env, [
ContributionItem { goal_id: emergency_id, amount: 500_0000000 },
ContributionItem { goal_id: vacation_id, amount: 1000_0000000 },
ContributionItem { goal_id: education_id, amount: 2000_0000000 },
]);

// Execute atomic batch - all succeed or none do
let processed = savings_goals::batch_add_to_goals(env, user, contributions)?;
assert_eq!(processed, 3);
```

### Querying Goals

```rust
Expand Down
115 changes: 100 additions & 15 deletions savings_goals/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ use remitwise_common::{EventCategory, EventPriority, RemitwiseEvents};
const GOAL_CREATED: Symbol = symbol_short!("created");
const FUNDS_ADDED: Symbol = symbol_short!("added");
const GOAL_COMPLETED: Symbol = symbol_short!("completed");
const BATCH_STARTED: Symbol = symbol_short!("batch_str");
const BATCH_COMPLETED: Symbol = symbol_short!("batch_end");
const BATCH_FAILED: Symbol = symbol_short!("batch_err");

#[derive(Clone)]
#[contracttype]
Expand Down Expand Up @@ -39,6 +42,35 @@ pub struct GoalCompletedEvent {
pub timestamp: u64,
}

#[derive(Clone)]
#[contracttype]
pub struct BatchStartedEvent {
pub caller: Address,
pub contribution_count: u32,
pub total_amount: i128,
pub timestamp: u64,
}

#[derive(Clone)]
#[contracttype]
pub struct BatchCompletedEvent {
pub caller: Address,
pub processed_count: u32,
pub total_amount: i128,
pub completed_goals: Vec<u32>,
pub timestamp: u64,
}

#[derive(Clone)]
#[contracttype]
pub struct BatchFailedEvent {
pub caller: Address,
pub attempted_count: u32,
pub failed_at_index: u32,
pub error_reason: Symbol,
pub timestamp: u64,
}

const INSTANCE_LIFETIME_THRESHOLD: u32 = 17280;
const INSTANCE_BUMP_AMOUNT: u32 = 518400;

Expand Down Expand Up @@ -97,6 +129,10 @@ pub enum SavingsGoalsError {
GoalLocked = 4,
InsufficientBalance = 5,
Overflow = 6,
BatchTooLarge = 7,
BatchValidationFailed = 8,
BatchProcessingFailed = 9,
InvalidContributionAmount = 10,
}

impl From<SavingsGoalsError> for soroban_sdk::Error {
Expand Down Expand Up @@ -126,6 +162,22 @@ impl From<SavingsGoalsError> for soroban_sdk::Error {
soroban_sdk::xdr::ScErrorType::Contract,
soroban_sdk::xdr::ScErrorCode::InvalidInput,
)),
SavingsGoalsError::BatchTooLarge => soroban_sdk::Error::from((
soroban_sdk::xdr::ScErrorType::Contract,
soroban_sdk::xdr::ScErrorCode::InvalidInput,
)),
SavingsGoalsError::BatchValidationFailed => soroban_sdk::Error::from((
soroban_sdk::xdr::ScErrorType::Contract,
soroban_sdk::xdr::ScErrorCode::InvalidInput,
)),
SavingsGoalsError::BatchProcessingFailed => soroban_sdk::Error::from((
soroban_sdk::xdr::ScErrorType::Contract,
soroban_sdk::xdr::ScErrorCode::InvalidAction,
)),
SavingsGoalsError::InvalidContributionAmount => soroban_sdk::Error::from((
soroban_sdk::xdr::ScErrorType::Contract,
soroban_sdk::xdr::ScErrorCode::InvalidInput,
)),
}
}
}
Expand Down Expand Up @@ -156,6 +208,9 @@ pub enum SavingsEvent {
ScheduleMissed,
ScheduleModified,
ScheduleCancelled,
BatchStarted,
BatchCompleted,
BatchFailed,
}

/// Snapshot for savings goals export/import (migration).
Expand Down Expand Up @@ -812,30 +867,67 @@ impl SavingsGoalContract {
if contributions.len() > MAX_BATCH_SIZE {
return Err(SavingsGoalsError::InvalidAmount);
}

let contribution_count = contributions.len() as u32;
if contribution_count == 0 {
return Ok(0);
}

// Load goals map once
let goals_map: Map<u32, SavingsGoal> = env
.storage()
.instance()
.get(&symbol_short!("GOALS"))
.unwrap_or_else(|| Map::new(&env));
for item in contributions.iter() {

// Phase 1: Comprehensive validation
let mut total_amount: i128 = 0;
let mut goal_ids = Vec::new(&env);

for (_index, item) in contributions.iter().enumerate() {
// Validate contribution amount
if item.amount <= 0 {
return Err(SavingsGoalsError::InvalidAmount);
}

// Check for overflow in total
total_amount = match total_amount.checked_add(item.amount) {
Some(v) => v,
None => return Err(SavingsGoalsError::BatchProcessingFailed),
};

// Validate goal exists and caller owns it
let goal = match goals_map.get(item.goal_id) {
Some(g) => g,
None => return Err(SavingsGoalsError::GoalNotFound),
};

if goal.owner != caller {
return Err(SavingsGoalsError::Unauthorized);
}
goal_ids.push_back(item.goal_id);
}

// Phase 2: Emit batch start event
let batch_start_event = BatchStartedEvent {
caller: caller.clone(),
contribution_count,
total_amount,
timestamp: env.ledger().timestamp(),
};
env.events().publish((BATCH_STARTED,), batch_start_event);
env.events().publish(
(symbol_short!("savings"), SavingsEvent::BatchStarted),
(caller.clone(), contribution_count, total_amount),
);

Self::extend_instance_ttl(&env);
let mut goals: Map<u32, SavingsGoal> = env
.storage()
.instance()
.get(&symbol_short!("GOALS"))
.unwrap_or_else(|| Map::new(&env));
let mut count = 0u32;

// Phase 3: Process all contributions in memory first
let mut updated_goals = goals_map.clone();
let mut completed_goals = Vec::new(&env);
let mut processed_count = 0u32;

for item in contributions.iter() {
let mut goal = match goals.get(item.goal_id) {
Some(g) => g,
Expand All @@ -855,7 +947,7 @@ impl SavingsGoalContract {
let funds_event = FundsAddedEvent {
goal_id: item.goal_id,
amount: item.amount,
new_total,
new_total: goal.current_amount,
timestamp: env.ledger().timestamp(),
};
RemitwiseEvents::emit(
Expand All @@ -878,13 +970,6 @@ impl SavingsGoalContract {
(symbol_short!("savings"), SavingsEvent::FundsAdded),
(item.goal_id, caller.clone(), item.amount),
);
if was_completed && !previously_completed {
env.events().publish(
(symbol_short!("savings"), SavingsEvent::GoalCompleted),
(item.goal_id, caller.clone()),
);
}
count += 1;
}
env.storage()
.instance()
Expand Down
Loading
Loading