Skip to content

Commit 7ee738e

Browse files
authored
Merge pull request #195 from truthixify/fix-178
Fixes a race condition in trigger_trap() that allowed traps to be triggered multiple times simultaneously.
2 parents 38d6dd5 + ff69c0e commit 7ee738e

4 files changed

Lines changed: 88 additions & 14 deletions

File tree

‎contract/src/models/enemy.cairo‎

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -68,18 +68,20 @@ pub impl EnemyImpl of EnemySystem {
6868
return Result::Err(errors::InvalidSpeed);
6969
}
7070

71-
Result::Ok(Enemy {
72-
id,
73-
enemy_type,
74-
health,
75-
speed,
76-
x,
77-
y,
78-
is_alive: true,
79-
coin_reward,
80-
xp_reward,
81-
reward_claimed: false
82-
})
71+
Result::Ok(
72+
Enemy {
73+
id,
74+
enemy_type,
75+
health,
76+
speed,
77+
x,
78+
y,
79+
is_alive: true,
80+
coin_reward,
81+
xp_reward,
82+
reward_claimed: false,
83+
},
84+
)
8385
}
8486

8587
fn take_damage(self: @Enemy, amount: u32) -> Enemy {

‎contract/src/models/trap.cairo‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,14 @@ pub impl TrapImpl of TrapTrait {
5959
}
6060

6161
fn trigger(ref self: Trap) -> u16 {
62+
// Check if the trap is active before modifying its state.
63+
// If it's not active, we immediately return 0, preventing any side effects.
6264
if !self.is_active {
6365
return 0;
6466
}
6567

66-
self.is_active = false; // Trap is consumed after triggering
68+
// If it is active, deactivate it and return its damage.
69+
self.is_active = false;
6770
self.damage
6871
}
6972

‎contract/src/store.cairo‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,21 @@ pub impl StoreImpl of StoreTrait {
294294
let mut trap = self.read_trap(trap_id);
295295
assert(trap.is_active == true, 'Trap not active');
296296
assert(TrapImpl::check_trigger(@trap, enemy_pos) == true, 'Enemy not in range');
297+
298+
// The TrapImpl::trigger function will set is_active to false and return the damage.
299+
// If it was already inactive (e.g., another transaction got there first), it returns 0.
297300
let damage = TrapImpl::trigger(ref trap);
301+
302+
// If damage is 0, it means the trap was not active when TrapImpl::trigger was called.
303+
// This could happen if a concurrent transaction successfully triggered and updated
304+
// the global state to inactive before this transaction's TrapImpl::trigger was executed.
305+
// In this scenario, we just return 0, as no new damage should be dealt.
306+
if damage == 0 {
307+
return 0;
308+
}
309+
310+
// This transaction successfully triggered the trap and received non-zero damage.
311+
// Write the now-inactive trap state back to the world.
298312
self.write_trap(@trap);
299313
damage
300314
}

‎contract/src/tests/test_store.cairo‎

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1474,5 +1474,60 @@ mod tests {
14741474
// Attempt to spend 50 gems which is more than the balance; should panic
14751475
store.spend_gems(player_address, 50_u64);
14761476
}
1477-
}
14781477

1478+
#[test]
1479+
#[should_panic(expected: 'Trap not active')]
1480+
fn test_store_trigger_trap_concurrent_access() {
1481+
let player_system_contract_address = create_test_player_system();
1482+
let world = create_test_world(player_system_contract_address);
1483+
let mut store_tx1: Store = StoreTrait::new(world.clone()); // Simulate transaction 1
1484+
let mut store_tx2: Store = StoreTrait::new(world.clone()); // Simulate transaction 2
1485+
1486+
let trap = create_sample_trap();
1487+
store_tx1.place_trap(trap); // Place trap using one store instance
1488+
1489+
let enemy_pos = Vec2 { x: 12_u32, y: 16_u32 }; // Within radius
1490+
1491+
// Scenario: Both transactions read the active trap, then try to trigger
1492+
// Transaction 1 triggers
1493+
let damage_tx1 = store_tx1.trigger_trap(1_u32, enemy_pos);
1494+
1495+
// Transaction 2 tries to trigger, but the trap is already inactive due to TX1
1496+
let damage_tx2 = store_tx2.trigger_trap(1_u32, enemy_pos);
1497+
1498+
// Assertions
1499+
assert(damage_tx1 == 50_u16, 'TX1 should deal 50 damage');
1500+
assert!(damage_tx2 == 0_u16, "TX2 should deal 0 damage due to race condition protection");
1501+
1502+
let final_trap_state = store_tx1.read_trap(1_u32);
1503+
assert!(final_trap_state.is_active == false, "Trap should be consumed after one trigger");
1504+
1505+
// Verify that the second store instance also reflects the inactive state
1506+
let final_trap_state_tx2 = store_tx2.read_trap(1_u32);
1507+
assert!(final_trap_state_tx2.is_active == false, "TX2 view should also show inactive trap");
1508+
}
1509+
1510+
#[test]
1511+
#[should_panic(expected: 'Trap not active')]
1512+
fn test_store_trigger_trap_already_inactive_returns_zero() {
1513+
let player_system_contract_address = create_test_player_system();
1514+
let world = create_test_world(player_system_contract_address);
1515+
let mut store: Store = StoreTrait::new(world);
1516+
1517+
let mut trap = create_sample_trap();
1518+
store.place_trap(trap);
1519+
1520+
let enemy_pos = Vec2 { x: 12_u32, y: 16_u32 }; // Within radius
1521+
1522+
// First trigger works
1523+
let damage1 = store.trigger_trap(1_u32, enemy_pos);
1524+
assert!(damage1 == 50_u16, "First trigger should deal damage");
1525+
1526+
// Second trigger (trap is already inactive) should return 0, no panic
1527+
let damage2 = store.trigger_trap(1_u32, enemy_pos);
1528+
assert!(damage2 == 0_u16, "Second trigger on inactive trap should return 0");
1529+
1530+
let final_trap = store.read_trap(1_u32);
1531+
assert(final_trap.is_active == false, 'Trap should remain inactive');
1532+
}
1533+
}

0 commit comments

Comments
 (0)