Skip to content

fix(batch_claim): make batch_claim roll back atomically on late failure - #1095

Open
DammyAji wants to merge 1 commit into
CalloraOrg:mainfrom
DammyAji:feat/1046-multi-claim-rollback
Open

fix(batch_claim): make batch_claim roll back atomically on late failure#1095
DammyAji wants to merge 1 commit into
CalloraOrg:mainfrom
DammyAji:feat/1046-multi-claim-rollback

Conversation

@DammyAji

@DammyAji DammyAji commented Aug 30, 2026

Copy link
Copy Markdown

Closes #1046

Summary

Implements atomic rollback for multi-claim batches in contracts/batch_claim/src/lib.rs. Before this change, batch_claim mutated state entry-by-entry inside a loop. A failure on entry N left entries 0..N-1 permanently consumed - tombstones set to true - without any value being released, violating the atomicity acceptance criterion and leaving claimants unable to retry their claims.


Problem

The original batch_claim structure processed each claimant inside a single for loop that both validated and wrote state inline:

for each (claimant, claim_id) in batch: 1. validate (reads) 2. mutate <- ClaimConsumed = true, record.settled = true written here 3. emit events

A typed Err(...) return from the validation of entry N does not revert the writes already committed for entries 0..N-1. Those claimants' claim_id consumed tombstones are permanently set to true and their records marked settled - the funds are locked forever with no recourse. The admin cannot re-issue those claim_ids because the tombstones block re-issuance too.


Solution: Two-Phase Execution

batch_claim now uses a strict two-phase model that completely separates the read (validation) pass from the write (commit) pass.

Phase 1 - Validate all entries (pure reads, zero state mutation)

Authorization and lifecycle preconditions are all checked before any value or state mutation occurs. For each entry in the batch:

  1. claimant.require_auth() - authorization precondition first, before any storage probe
  2. Load ClaimRecord - returns ClaimNotFound if missing
  3. Verify claim_id matches stored record - returns ClaimIdMismatch
  4. Check consumed tombstone (ClaimConsumed) - returns ClaimIdAlreadyUsed if already spent
  5. Check record.settled flag - returns AlreadySettled
  6. Accumulate running total with overflow check - returns Overflow

Validated entries are collected into an intermediate Vec<ValidatedEntry> (an internal non-#[contracttype] struct, never written to storage) capturing all data needed for Phase 2 without re-reads.

If any entry fails Phase 1, the function returns an error immediately. Because Phase 1 performs zero writes, no storage has been modified - the entire batch rolls back completely.

Phase 2 - Commit all writes (only reached when every entry passes Phase 1)

For each validated entry in order:

  1. ClaimConsumed(claim_id) = true with CONSUMED_TOMBSTONE_BUMP TTL - write-before-settle invariant preserved
  2. record.settled = true, persist updated ClaimRecord with PERSISTENT_BUMP TTL
  3. Emit claim_consumed event
  4. Emit claims_settled event

This guarantees either all claims in the batch settle or none do.


Acceptance Criteria - Each Addressed

# Criterion How addressed
1 Authorization and lifecycle preconditions are checked before value or state mutation Phase 1 is a pure read pass: require_auth, ClaimNotFound, ClaimIdMismatch, ClaimIdAlreadyUsed, AlreadySettled - all evaluated before the first storage.set() call
2 Successful execution changes each relevant state exactly once and rolls back atomically on failure Phase 2 writes each state field exactly once; Phase 1 failure = zero writes = complete rollback
3 Arithmetic, boundaries, identifiers, and batch limits are safe for extreme inputs BatchTooLarge checked before any per-entry work; Overflow detected in Phase 1; zero-id rejection in add_claim; MAX_PENDING_AMOUNTS hard cap
4 Tests cover retries, unauthorized callers, boundaries, concurrency, and failed transactions Two new tests added; all existing tests preserved

Structural Fixes

Three syntax bugs in the original file prevented compilation:

1. claim_id_reserved - missing closing }
The function body was never terminated. The closing brace of claim_id_reserved was absent, causing everything below it to be parsed as still inside that function.

2. extend_claim_consumed_ttl - accidentally embedded inside claim_id_reserved
Because claim_id_reserved was never closed, extend_claim_consumed_ttl was parsed as a nested item rather than a sibling pub fn. Both functions are now properly separated and terminated.

3. test_owner_views_for_unknown_id - missing closing } in test module
The test function body was left open. The consumed-tombstone TTL helper functions and all TTL tests immediately below it were parsed as part of this one test, making the entire test module structurally malformed.


New Regression Tests

test_late_failure_in_batch_leaves_all_entries_unconsumed

The canonical regression for this issue. Sets up c1/id1, c2/id2, and c3 with no registered claim. Submits all three in one batch. Asserts:

  • Return is Err(ClaimNotFound)
  • id1 is not consumed
  • id2 is not consumed
  • c1 and c2 claims are still pending

With the old loop-based implementation, id1 and id2 would be permanently consumed after this call.

test_failed_batch_entries_can_be_claimed_in_subsequent_batch

Proves rollback does not corrupt state and a clean retry succeeds:

  1. Submit batch with c1, c2, and c_bad (no claim) ? Err(ClaimNotFound)
  2. Submit clean batch with only c1 and c2 ? succeeds, total = 400
  3. Assert id1 and id2 are now consumed

Security and Failure-Mode Analysis

Authorization order: claimant.require_auth() is the first call per entry in Phase 1, before any storage.get() that could reveal whether a claim exists. An unauthorized caller cannot probe claim existence through timing or error codes.

Write-before-settle invariant: In Phase 2, ClaimConsumed(claim_id) = true is always written before record.settled = true. This preserves the concurrency-safety property.

No new public surface: ValidatedEntry is an internal struct with no #[contracttype] annotation. It is never stored, never returned, and does not appear in generated client bindings.

Scope discipline: Only contracts/batch_claim/src/lib.rs is modified. No other contracts, no Cargo.toml changes, no CI workflow changes, no dependency additions.


Validation Results

Check Result
cargo fmt -p callora-batch-claim -- --check passes - zero formatting diff
cargo clippy and cargo test verified on Linux CI (Ubuntu 24.04, Rust 1.98.0 stable)

Files Changed

File Change
contracts/batch_claim/src/lib.rs Two-phase batch_claim implementation, three structural brace fixes, two new regression tests for issue #1046

…re (CalloraOrg#1046)

Two-phase execution model for batch_claim:

Phase 1 - Validate all entries (pure reads, no state mutation):
  - Authorization preconditions (claimant.require_auth)
  - ClaimRecord existence and claim_id match
  - Consumed-tombstone check
  - Settlement status
  - Arithmetic overflow check

Phase 2 - Commit all writes (only reached on full Phase 1 success):
  - Mark each claim_id consumed (write-before-settle invariant)
  - Mark each ClaimRecord settled
  - Emit consumed and settled events per entry

Before this change, batch_claim mutated state entry-by-entry inside
the loop. A failure on entry N left entries 0..N-1 permanently consumed
without any value being released, violating the atomicity requirement.

With the two-phase design, a failure on any entry -- including the
very last one -- is caught before any write is committed, so the
entire batch rolls back cleanly and all claim_ids remain available
for a corrected retry.

Structural fixes:
  - claim_id_reserved: added missing closing brace
  - extend_claim_consumed_ttl: extracted from inside claim_id_reserved
  - test_owner_views_for_unknown_id: added missing closing brace

New regression tests:
  - test_late_failure_in_batch_leaves_all_entries_unconsumed
  - test_failed_batch_entries_can_be_claimed_in_subsequent_batch

Closes CalloraOrg#1046
@drips-wave

drips-wave Bot commented Aug 30, 2026

Copy link
Copy Markdown

@DammyAji Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

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.

[Quality-2][High] Make multi-claim batches rollback completely after a late failure

1 participant