fix(batch_claim): make batch_claim roll back atomically on late failure - #1095
Open
DammyAji wants to merge 1 commit into
Open
fix(batch_claim): make batch_claim roll back atomically on late failure#1095DammyAji wants to merge 1 commit into
DammyAji wants to merge 1 commit into
Conversation
…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
|
@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! 🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1046
Summary
Implements atomic rollback for multi-claim batches in
contracts/batch_claim/src/lib.rs. Before this change,batch_claimmutated state entry-by-entry inside a loop. A failure on entry N left entries 0..N-1 permanently consumed - tombstones set totrue- without any value being released, violating the atomicity acceptance criterion and leaving claimants unable to retry their claims.Problem
The original
batch_claimstructure processed each claimant inside a singleforloop 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 eventsA typed
Err(...)return from the validation of entry N does not revert the writes already committed for entries 0..N-1. Those claimants'claim_idconsumed tombstones are permanently set totrueand their records marked settled - the funds are locked forever with no recourse. The admin cannot re-issue thoseclaim_ids because the tombstones block re-issuance too.Solution: Two-Phase Execution
batch_claimnow 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:
claimant.require_auth()- authorization precondition first, before any storage probeClaimRecord- returnsClaimNotFoundif missingclaim_idmatches stored record - returnsClaimIdMismatchClaimConsumed) - returnsClaimIdAlreadyUsedif already spentrecord.settledflag - returnsAlreadySettledOverflowValidated 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:
ClaimConsumed(claim_id) = truewithCONSUMED_TOMBSTONE_BUMPTTL - write-before-settle invariant preservedrecord.settled = true, persist updatedClaimRecordwithPERSISTENT_BUMPTTLclaim_consumedeventclaims_settledeventThis guarantees either all claims in the batch settle or none do.
Acceptance Criteria - Each Addressed
require_auth,ClaimNotFound,ClaimIdMismatch,ClaimIdAlreadyUsed,AlreadySettled- all evaluated before the firststorage.set()callBatchTooLargechecked before any per-entry work;Overflowdetected in Phase 1; zero-id rejection inadd_claim;MAX_PENDING_AMOUNTShard capStructural 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_reservedwas absent, causing everything below it to be parsed as still inside that function.2.
extend_claim_consumed_ttl- accidentally embedded insideclaim_id_reservedBecause
claim_id_reservedwas never closed,extend_claim_consumed_ttlwas parsed as a nested item rather than a siblingpub fn. Both functions are now properly separated and terminated.3.
test_owner_views_for_unknown_id- missing closing}in test moduleThe 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_unconsumedThe canonical regression for this issue. Sets up c1/id1, c2/id2, and c3 with no registered claim. Submits all three in one batch. Asserts:
Err(ClaimNotFound)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_batchProves rollback does not corrupt state and a clean retry succeeds:
Err(ClaimNotFound)Security and Failure-Mode Analysis
Authorization order:
claimant.require_auth()is the first call per entry in Phase 1, before anystorage.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) = trueis always written beforerecord.settled = true. This preserves the concurrency-safety property.No new public surface:
ValidatedEntryis an internalstructwith 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.rsis modified. No other contracts, noCargo.tomlchanges, no CI workflow changes, no dependency additions.Validation Results
cargo fmt -p callora-batch-claim -- --checkcargo clippyandcargo testFiles Changed
contracts/batch_claim/src/lib.rsbatch_claimimplementation, three structural brace fixes, two new regression tests for issue #1046