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.
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
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 callingexecute_payout()
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:
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)?;
}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:
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()
The contract has two different patterns for state mutation:
State Check → State Mutation → Storage Persist → External Call (execute_payout)
State Check → External Call (execute_payout) → State Mutation → Storage Persist
Why This Matters:
- Code maintainability: Inconsistent patterns increase cognitive load and bug risk
- Future refactoring: If someone extracts a common pattern, they might incorrectly refactor Pattern B
- Defense in depth: Pattern A provides better protection against unknown token attack vectors
| 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 |
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.
Why does bounty issue mention double-payout?
Possible origins:
- Earlier codebase version: Bug may have existed in v0.x, now fixed
- Oracle contract path: If Oracle contract has payout logic, may be separate concern
- Design review comment: Theoretical scenario that's now mitigated
- Dispute path oversight: The state-before-transfer issue in
resolve_dispute_by_vote()is the closest real finding
Files to modify: lib.rs
Locations:
finalize_match()around line 1481resolve_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
Locations:
finalize_match()after line 1493resolve_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
#[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]
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]
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)
}Status: ✅ NO ACTIVE DOUBLE-PAYOUT VULNERABILITY
The contract is safe from double-payout attacks due to:
- Proper state validation before payout in all paths
- Atomic transaction model of Soroban
- 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.