Skip to content

Commit 85775fa

Browse files
critesjoshclaude
andcommitted
feat: add forfeit_game function and contract review
Add forfeit functionality allowing players to forfeit in-progress games: - Add forfeit_game public function to main.nr - Add get_opponent helper method to Race struct - Add comprehensive tests for forfeit scenarios - Include contract security review in REVIEW.md Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent b70b996 commit 85775fa

File tree

5 files changed

+391
-1
lines changed

5 files changed

+391
-1
lines changed

REVIEW.md

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
# Aztec Contract Review: PodRacing
2+
3+
## Overall Assessment: **Good**
4+
5+
The contract is well-structured with clear documentation and follows most Aztec best practices. However, there are several security concerns and improvements worth addressing.
6+
7+
---
8+
9+
## ✅ Strengths
10+
11+
### Structure
12+
- Proper use of `#[aztec]` macro and storage attributes
13+
- Clean separation between contract (`main.nr`), data types (`race.nr`), and notes (`game_round_note.nr`)
14+
- Good use of constants (`TOTAL_ROUNDS`, `GAME_LENGTH`)
15+
- Excellent inline documentation explaining game flow
16+
17+
### Function Design
18+
- Correct use of `#[external("private")]` for sensitive operations (`play_round`, `finish_game`)
19+
- Internal functions properly marked with `#[only_self]`
20+
- Good public/private split for the commit-reveal pattern
21+
22+
### Privacy
23+
- Private notes correctly store player choices until reveal
24+
- `Owned<PrivateSet<...>>` ensures only the owner can read their notes
25+
- Point allocations hidden until `finish_game` is called
26+
27+
---
28+
29+
## ⚠️ Security Issues
30+
31+
### 1. **CRITICAL: `finalize_game` can be called multiple times**
32+
**Location:** `main.nr:222-232`
33+
34+
The `finalize_game` function doesn't prevent repeated calls. An attacker can call it multiple times to inflate their win count.
35+
36+
```rust
37+
// Current code - no protection against re-finalization
38+
#[external("public")]
39+
fn finalize_game(game_id: Field) {
40+
let game_in_progress = self.storage.races.at(game_id).read();
41+
let winner = game_in_progress.calculate_winner(self.context.block_number());
42+
let previous_wins = self.storage.win_history.at(winner).read();
43+
self.storage.win_history.at(winner).write(previous_wins + 1);
44+
// Game is NOT reset - can be finalized again!
45+
}
46+
```
47+
48+
**Fix:** Reset the game after finalization:
49+
```rust
50+
fn finalize_game(game_id: Field) {
51+
let game_in_progress = self.storage.races.at(game_id).read();
52+
let winner = game_in_progress.calculate_winner(self.context.block_number());
53+
let previous_wins = self.storage.win_history.at(winner).read();
54+
self.storage.win_history.at(winner).write(previous_wins + 1);
55+
56+
// Reset the game to prevent re-finalization
57+
self.storage.races.at(game_id).write(Race::empty());
58+
}
59+
```
60+
61+
### 2. **MEDIUM: No validation that player2 has joined before playing rounds**
62+
**Location:** `main.nr:100-131`, `race.nr:116-167`
63+
64+
A player can play rounds before player2 joins. The `increment_player_round` function doesn't check if the game has started.
65+
66+
**Fix:** Add validation in `increment_player_round`:
67+
```rust
68+
pub fn increment_player_round(self, player: AztecAddress, round: u8) -> Race {
69+
// Ensure game has started (player2 joined)
70+
assert(!self.player2.eq(AztecAddress::zero()), "Game has not started");
71+
assert(round < self.total_rounds + 1);
72+
// ... rest of function
73+
}
74+
```
75+
76+
### 3. **MEDIUM: No validation that player completed all rounds before finishing**
77+
**Location:** `main.nr:149-184`
78+
79+
A player can call `finish_game` before completing all 3 rounds. The loop assumes exactly `TOTAL_ROUNDS` notes exist, but there's no validation.
80+
81+
**Fix:** Add round completion check in `validate_finish_game_and_reveal`:
82+
```rust
83+
fn validate_finish_game_and_reveal(...) {
84+
let game_in_progress = self.storage.races.at(game_id).read();
85+
86+
// Validate player completed all rounds
87+
if player.eq(game_in_progress.player1) {
88+
assert(game_in_progress.player1_round == game_in_progress.total_rounds, "Must complete all rounds");
89+
} else {
90+
assert(game_in_progress.player2_round == game_in_progress.total_rounds, "Must complete all rounds");
91+
}
92+
// ... rest of function
93+
}
94+
```
95+
96+
### 4. **LOW: Tie-breaker always favors player2**
97+
**Location:** `race.nr:266-294`
98+
99+
When scores are tied on a track, player2 always wins. This is documented but creates an inherent disadvantage for player1.
100+
101+
**Consideration:** This may be intentional to offset first-mover advantage, but consider:
102+
- Making ties count for neither player
103+
- Using a different tie-breaker mechanism
104+
105+
### 5. **LOW: No check for game expiration in `join_game`**
106+
**Location:** `main.nr:83-90`
107+
108+
A player can join a game that has already expired (`end_block` passed).
109+
110+
**Fix:**
111+
```rust
112+
fn join_game(game_id: Field) {
113+
let maybe_existing_game = self.storage.races.at(game_id).read();
114+
assert(self.context.block_number() < maybe_existing_game.end_block, "Game has expired");
115+
let joined_game = maybe_existing_game.join(self.context.msg_sender().unwrap());
116+
self.storage.races.at(game_id).write(joined_game);
117+
}
118+
```
119+
120+
---
121+
122+
## 🔧 Code Quality Improvements
123+
124+
### 1. **Missing error messages on most assertions**
125+
Most `assert` statements lack descriptive error messages, making debugging difficult.
126+
127+
```rust
128+
// Current
129+
assert(track1 + track2 + track3 + track4 + track5 < 10);
130+
131+
// Better
132+
assert(track1 + track2 + track3 + track4 + track5 < 10, "Point allocation exceeds maximum of 9");
133+
```
134+
135+
### 2. **Unused `admin` storage variable**
136+
**Location:** `main.nr:42`
137+
138+
The `admin` is set in the constructor but never used. Either remove it or implement admin functions (pause, update settings, etc.).
139+
140+
### 3. **Double-reveal check is fragile**
141+
**Location:** `race.nr:175-181`
142+
143+
The check `sum of all tracks == 0` fails if a player legitimately allocates 0 points to all tracks in all rounds.
144+
145+
**Fix:** Add a dedicated `revealed` flag or check round count:
146+
```rust
147+
// Better approach - check if player completed rounds but hasn't revealed
148+
if player.eq(self.player1) {
149+
assert(self.player1_round == self.total_rounds, "Must complete all rounds first");
150+
assert(
151+
self.player1_track1_final == 0 &&
152+
self.player1_track2_final == 0 &&
153+
// ... etc (explicit zero check)
154+
, "Already revealed");
155+
}
156+
```
157+
158+
### 4. **Redundant `GameRoundNote::get()` method**
159+
**Location:** `game_round_note.nr:45-55`
160+
161+
This method just returns a copy of itself and appears unused.
162+
163+
---
164+
165+
## 📋 Summary Table
166+
167+
| Category | Issue | Severity | Status |
168+
|----------|-------|----------|--------|
169+
| Security | `finalize_game` re-entrancy | **Critical** | Needs fix |
170+
| Security | Play rounds before game starts | Medium | Needs fix |
171+
| Security | Finish without all rounds | Medium | Needs fix |
172+
| Security | Join expired game | Low | Consider |
173+
| Design | Tie always favors player2 | Low | Documented |
174+
| Quality | Missing error messages | Low | Improve |
175+
| Quality | Unused admin variable | Low | Remove/Use |
176+
| Quality | Fragile double-reveal check | Low | Improve |
177+
178+
---
179+
180+
## Recommendations Priority
181+
182+
1. **Immediately fix** the `finalize_game` re-entrancy by resetting the game after determining winner
183+
2. **Add validation** that game has started before playing rounds
184+
3. **Add validation** that all rounds are completed before revealing
185+
4. **Add error messages** to all assertions for better debugging
186+
5. **Consider** removing unused `admin` or implementing admin functionality

src/main.nr

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,4 +230,55 @@ pub contract PodRacing {
230230
let previous_wins = self.storage.win_history.at(winner).read();
231231
self.storage.win_history.at(winner).write(previous_wins + 1);
232232
}
233+
234+
// Returns the current state of a game
235+
// Useful for frontends to display game progress
236+
#[external("utility")]
237+
unconstrained fn get_game_state(game_id: Field) -> pub Race {
238+
self.storage.races.at(game_id).read()
239+
}
240+
241+
// Returns the total career wins for a player
242+
#[external("utility")]
243+
unconstrained fn get_player_wins(player: AztecAddress) -> pub u64 {
244+
self.storage.win_history.at(player).read()
245+
}
246+
247+
// Allows player1 to cancel a game if player2 hasn't joined yet
248+
// Useful if no opponent shows up and player1 wants to reclaim the game slot
249+
#[external("public")]
250+
fn cancel_game(game_id: Field) {
251+
let game = self.storage.races.at(game_id).read();
252+
253+
// Only player1 can cancel
254+
assert(game.player1.eq(self.context.msg_sender().unwrap()));
255+
256+
// Can only cancel if no player2 has joined
257+
assert(game.player2.eq(AztecAddress::zero()));
258+
259+
// Reset the game slot by writing a zeroed Race
260+
self.storage.races.at(game_id).write(Race::empty());
261+
}
262+
263+
// Allows a player to forfeit an in-progress game
264+
// The opponent is automatically awarded the win
265+
// Can only be called after player2 has joined (game has started)
266+
#[external("public")]
267+
fn forfeit_game(game_id: Field) {
268+
let game = self.storage.races.at(game_id).read();
269+
let caller = self.context.msg_sender().unwrap();
270+
271+
// Game must have started (player2 joined)
272+
assert(!game.player2.eq(AztecAddress::zero()), "Game has not started yet");
273+
274+
// Get the opponent (also validates caller is a player)
275+
let winner = game.get_opponent(caller);
276+
277+
// Award the win to the opponent
278+
let previous_wins = self.storage.win_history.at(winner).read();
279+
self.storage.win_history.at(winner).write(previous_wins + 1);
280+
281+
// Reset the game slot
282+
self.storage.races.at(game_id).write(Race::empty());
283+
}
233284
}

src/race.nr

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,28 @@ pub struct Race {
3838
}
3939

4040
impl Race {
41+
// Creates an empty/zeroed Race for resetting game slots
42+
pub fn empty() -> Race {
43+
Self {
44+
player1: AztecAddress::zero(),
45+
player2: AztecAddress::zero(),
46+
total_rounds: 0,
47+
player1_round: 0,
48+
player2_round: 0,
49+
player1_track1_final: 0,
50+
player1_track2_final: 0,
51+
player1_track3_final: 0,
52+
player1_track4_final: 0,
53+
player1_track5_final: 0,
54+
player2_track1_final: 0,
55+
player2_track2_final: 0,
56+
player2_track3_final: 0,
57+
player2_track4_final: 0,
58+
player2_track5_final: 0,
59+
end_block: 0,
60+
}
61+
}
62+
4163
// Creates a new game with player1 set, waiting for player2 to join
4264
// All track scores initialized to 0, rounds start at 0
4365
pub fn new(player1: AztecAddress, total_rounds: u8, end_block: u32) -> Race {
@@ -210,6 +232,19 @@ impl Race {
210232
ret.unwrap()
211233
}
212234

235+
// Returns the opponent's address for a given player
236+
// Asserts if the caller is not a player in this game
237+
pub fn get_opponent(self, player: AztecAddress) -> AztecAddress {
238+
if player.eq(self.player1) {
239+
self.player2
240+
} else if player.eq(self.player2) {
241+
self.player1
242+
} else {
243+
assert(false, "Caller is not a player in this game");
244+
AztecAddress::zero() // unreachable, but needed for return type
245+
}
246+
}
247+
213248
// Determines the game winner by comparing track scores
214249
// Winner is whoever won more tracks (best of 5)
215250
//

src/test/helpers.nr

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ pub global TEST_GAME_ID_7: Field = 7;
1414
pub global TEST_GAME_ID_8: Field = 8;
1515
pub global TEST_GAME_ID_9: Field = 9;
1616
pub global TEST_GAME_ID_10: Field = 10;
17+
pub global TEST_GAME_ID_11: Field = 11;
18+
pub global TEST_GAME_ID_12: Field = 12;
19+
pub global TEST_GAME_ID_13: Field = 13;
1720

1821
// Common point allocations for testing
1922
// Balanced strategy: distribute points evenly

0 commit comments

Comments
 (0)