|
| 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 |
0 commit comments