Skip to content

Latest commit

 

History

History
318 lines (232 loc) · 10.7 KB

File metadata and controls

318 lines (232 loc) · 10.7 KB

Double-Payout Bug Analysis - Formal Verification Task #2

Executive Summary

After comprehensive analysis of the Checkmate-Escrow escrow contract, no concrete double-payout vulnerability exists in the current mainline code paths. However, a critical design issue was identified in resolve_dispute_by_vote that violates the "state-before-transfer" safety pattern observed elsewhere in the codebase.

Status: The critical issue is MITIGATED by existing state checks, but represents a design inconsistency and future attack surface that should be corrected.


Detailed Analysis

Entry Point 1: submit_result() with Immediate Payout (dispute_period = 0)

Code Flow:

submit_result(match_id, winner) {
  // Line 747: State validation
  if m.state != MatchState::Active { return Err(...) }
  
  // Line 751-756: State persisted BEFORE external call
  m.state = MatchState::Completed;
  m.completed_ledger = Some(env.ledger().sequence());
  env.storage().persistent().set(&DataKey::Match(match_id), &m);
  
  // Line 780: External payout call happens AFTER state persisted
  Self::execute_payout(&env, &m, &winner)?;
}

Safety Assessment:SAFE

Why:

  • State is persisted to storage BEFORE execute_payout() is called
  • Any re-entry attempt via malicious token will read the already-updated state (Completed) from storage
  • Second submit_result() call will fail at the state check (line 747)
  • Pattern: Checks-Effects-Interactions (CEI) with state mutation before external interaction

Entry Point 2: submit_result() with Deferred Payout (dispute_period > 0)

Code Flow:

submit_result(match_id, winner) {
  // Line 747: State validation (must be Active)
  if m.state != MatchState::Active { return Err(...) }
  
  // Line 763: State transitioned to PendingResult
  m.state = MatchState::PendingResult;
  
  // Lines 793-801: PendingWinner and ResultDeadline stored
  env.storage().persistent().set(&DataKey::PendingWinner(match_id), &winner);
  env.storage().persistent().set(&DataKey::ResultDeadline(match_id), &deadline);
  
  // NO payout happens here
}

Safety Assessment:SAFE

Why:

  • Payout is explicitly deferred (state = PendingResult, not Completed)
  • Payout only executes via finalize_match() after dispute period elapses
  • finalize_match() enforces its own state check (PendingResult) before calling execute_payout()

Entry Point 3: finalize_match() - Delayed Payout Execution

Code Flow:

finalize_match(match_id) {
  // Line 1442: State validation
  if m.state != MatchState::PendingResult { return Err(...) }
  
  // Line 1481: execute_payout called BEFORE state update
  Self::execute_payout(&env, &m, &winner)?;
  
  // Line 1493: State updated AFTER payout
  m.state = MatchState::Completed;
  env.storage().persistent().set(&DataKey::Match(match_id), &m);
}

Safety Assessment: ⚠️ MITIGATED but INCONSISTENT PATTERN

Critical Issue Found:

  • State is updated AFTER execute_payout() is called (line 1481 → 1493)
  • This violates the CEI pattern used in submit_result()
  • If execute_payout() were to fail or be re-entered, the state would not be updated

Mitigation (current):

  • Soroban transactions are atomic all-or-nothing
  • If execute_payout() (token transfer) fails, entire transaction reverts
  • State check at line 1442 prevents re-entry on second call

Residual Risk:

  • LOW in current implementation (atomic transaction model)
  • MEDIUM if code changes to allow non-atomic operations or if fallback logic added

Recommendation:

finalize_match(match_id) {
  if m.state != MatchState::PendingResult { return Err(...) }
  
  // Update state BEFORE external call (consistency with submit_result)
  m.state = MatchState::Completed;
  m.completed_ledger = Some(env.ledger().sequence());
  env.storage().persistent().set(&DataKey::Match(match_id), &m);
  
  // Then execute payout
  Self::execute_payout(&env, &m, &winner)?;
}

Entry Point 4: resolve_dispute_by_vote() - Dispute Resolution Payout

Code Flow:

resolve_dispute_by_vote(dispute_id) {
  // Line 1698: Dispute state validation
  if dispute.state != DisputeState::Active { return Err(...) }
  
  // Line 1703: Match state validation
  if m.state != MatchState::PendingResult { return Err(...) }
  
  // Line 1728: execute_payout called BEFORE state update
  Self::execute_payout(&env, &m, &winner)?;
  
  // Line 1732-1739: State updated AFTER payout
  m.state = MatchState::Completed;
  env.storage().persistent().set(&DataKey::Match(match_id), &m);
}

Safety Assessment: ⚠️ MITIGATED but INCONSISTENT PATTERN

Critical Issue Found:

  • Same pattern violation as finalize_match(): state updated AFTER payout
  • Additionally, both Dispute state AND Match state are updated after payout

Mitigation (current):

  • Dispute state check at line 1698 prevents second call on same dispute
  • Match state check at line 1703 prevents second call on same match
  • Soroban atomicity prevents partial execution

Residual Risk:

  • LOW in current implementation
  • MEDIUM if combined with malicious token or future changes

Recommendation: Same as finalize_match() - update states BEFORE execute_payout()


Root Cause of Design Inconsistency

The contract has two different patterns for state mutation:

Pattern A: submit_result() (CORRECT - CEI)

State Check → State Mutation → Storage Persist → External Call (execute_payout)

Pattern B: finalize_match() + resolve_dispute_by_vote() (INCONSISTENT)

State Check → External Call (execute_payout) → State Mutation → Storage Persist

Why This Matters:

  1. Code maintainability: Inconsistent patterns increase cognitive load and bug risk
  2. Future refactoring: If someone extracts a common pattern, they might incorrectly refactor Pattern B
  3. Defense in depth: Pattern A provides better protection against unknown token attack vectors

Vulnerability Classification

VULN-1: State-Before-Transfer Inconsistency in Deferred Payout Paths

Property Value
Severity MEDIUM (currently mitigated, but design issue)
Type Design Pattern Violation (not an active vulnerability)
Locations finalize_match() line 1481, resolve_dispute_by_vote() line 1728
Current Status Mitigated by Soroban's atomic transaction model
Future Risk HIGH if model changes or non-atomic operations added
Confidence HIGH - code verified against spec

Formal Verification Results

Invariant: INV_NO_DOUBLE_PAYOUT

Definition: No player receives payout more than once per match across all execution paths

Verification Status:VERIFIED

Path Entry Point(s) State Check Payout Guard Result
Immediate submit_result (dispute_period=0) ✅ Before payout State=Completed persisted before call ✅ SAFE
Deferred submit_result + finalize_match ✅ Both enforce state checks Each blocks re-entry ✅ SAFE
Disputed (Upheld) submit_result + resolve_dispute_by_vote ✅ Both enforce state checks Dispute state + Match state checked ✅ SAFE
Disputed (Overturned) submit_result + resolve_dispute_by_vote ✅ Both enforce state checks Refund executes once ✅ SAFE
Re-entry (Malicious Token) submit_result ✅ State persisted before external call Can't re-enter active state ✅ PROTECTED

Conclusion: No double-payout vulnerability in current code. However, patterns should be harmonized.


Root Cause Summary

Why does bounty issue mention double-payout?

Possible origins:

  1. Earlier codebase version: Bug may have existed in v0.x, now fixed
  2. Oracle contract path: If Oracle contract has payout logic, may be separate concern
  3. Design review comment: Theoretical scenario that's now mitigated
  4. Dispute path oversight: The state-before-transfer issue in resolve_dispute_by_vote() is the closest real finding

Recommended Fixes

Fix 1: Harmonize State-Before-Transfer Pattern (CRITICAL)

Files to modify: lib.rs

Locations:

  • finalize_match() around line 1481
  • resolve_dispute_by_vote() around line 1728

Change Pattern B → Pattern A:

// Before (Pattern B - INCONSISTENT):
Self::execute_payout(&env, &m, &winner)?;
m.state = MatchState::Completed;
env.storage().persistent().set(...);

// After (Pattern A - CONSISTENT):
m.state = MatchState::Completed;
m.completed_ledger = Some(env.ledger().sequence());
env.storage().persistent().set(&DataKey::Match(match_id), &m);
Self::execute_payout(&env, &m, &winner)?;

Impact: Defensive design improvement; reduces re-entry surface

Fix 2: Clean Up Storage After Finalize (LOW PRIORITY)

Locations:

  • finalize_match() after line 1493
  • resolve_dispute_by_vote() after line 1739

Add cleanup:

// Delete dangling references to prevent future bugs
env.storage().persistent().remove(&DataKey::PendingWinner(match_id));
env.storage().persistent().remove(&DataKey::ResultDeadline(match_id));

Impact: Prevent storage leak and eliminate future attack surface


Testing Recommendations

Test Case: Double-Payout Attempt (Immediate Path)

#[test]
fn test_submit_result_twice_fails() {
    // Create Active match
    // Call submit_result(..., Player1)
    // Attempt submit_result(..., Player1) again
    // Expected: Error::InvalidState (state != Active anymore)
}

Test Case: Double-Payout Attempt (Deferred Path)

#[test]
fn test_finalize_twice_fails() {
    // Create Active match with dispute_period > 0
    // Call submit_result(...) → PendingResult
    // Call finalize_match(...) → Completed
    // Attempt finalize_match(...) again
    // Expected: Error::MatchNotInPendingResult
}

Test Case: Re-entry via Malicious Token

#[test]
fn test_malicious_token_reentry() {
    // Create Active match with dispute_period = 0
    // Oracle submits result (calls execute_payout)
    // Token fallback attempts to call submit_result again
    // Expected: Fallback fails (state already Completed)
}

Conclusion

Status:NO ACTIVE DOUBLE-PAYOUT VULNERABILITY

The contract is safe from double-payout attacks due to:

  1. Proper state validation before payout in all paths
  2. Atomic transaction model of Soroban
  3. State checks preventing re-entry

However: A design pattern inconsistency exists in deferred payout paths (finalize_match() and resolve_dispute_by_vote()) that should be corrected to follow the safer CEI pattern used in submit_result().

Recommendation: Apply Fix 1 (harmonize patterns) immediately; Fix 2 (cleanup storage) is optional but recommended.