AuditAid Showcase

simpleRPG security audit

Audited by AuditAid · 846 LoC · June 26, 2026

Download PDF report

Security Audit Report — OnChain RPG Battle

Prepared for: Confidential Client
Prepared by: AuditAid — Tucker Sossaman
Date: June 26, 2026
Version: 1.0


Executive Summary

AuditAid performed a full smart contract security review of the OnChain RPG Battle codebase: the production contract OnChainRpgBattle.sol (deployed on Sepolia) and the legacy reference simpleRPGGame.sol. The game combines ETH-paid PvE/PvP combat, guild rankings, and ERC-721 achievement NFTs.

Findings at a glance

| Severity | Count | Summary | |----------|-------|---------| | Critical | 1 | Legacy-only: anyone can inflate EXP/levels via public expUp() | | High | 1 | Block-derived randomness is predictable and repeats within a single transaction | | Medium | 5 | Registration bypass, zombie HP state, SCW NFT claims, two legacy-only issues | | Low / Info | 11 | Appendix items (reentrancy hygiene, overpayment, admin withdraw, etc.) |

The production contract should not be treated as production-ready for real-value gameplay until High and Medium issues are addressed—especially randomness (H-01) and registration lifecycle (M-01). The legacy contract must not be deployed; it contains a Critical progression bypass.

Code quality scored 62/100 after fuzz/invariant tests were added. Unit test line coverage on v1 is 85%, but branch coverage is only 36%, leaving guild and edge-case paths undertested.


Scope

| In scope | Out of scope | |----------|--------------| | target/src/OnChainRpgBattle.sol | Tests, scripts, vendor lib/ | | target/legacy/simpleRPGGame.sol | Third-party OpenZeppelin internals |

Context documents read: target/README.md, target/legacy/README.md.


Methodology

Multi-tier AuditAid pipeline: static analysis (Slither), parallel manual/economic/integration/token review, Tier 2c state and interaction exploration, adversarial red-team pass, and Foundry invariant fuzzing. All Critical/High paths were validated with executable PoCs where feasible.

Build: Foundry at target/, Solidity 0.8.18+, optimizer 200 runs, IR pipeline enabled.


Findings

Critical.01 — Legacy expUp() is public and bypasses all progression rules

Source ID: C-01
Severity: Critical
Location: legacy/simpleRPGGame.sol — function expUp() — lines 126–145
Status: Open

Scope: Legacy contract only. Production v1 correctly uses internal expUp.

What the problem is

On the legacy contract, any wallet can call expUp() with an arbitrary experience value and instantly level up—without paying battle fees or winning fights.

How it works

The v1 contract restricts expUp to internal calls from combat paths. The legacy contract exposes it as public, so any caller can mutate their own players[msg.sender] stats.

Step-by-step exploit

  1. Attacker calls registerPlayer with the minimum fee.
  2. Attacker calls expUp(1000000) (or any large value).
  3. Attacker’s level, HP, attack, and defense jump far above honest players.
  4. Attacker enters PvP or PvE with illegitimate stats.

Impact

Complete breakdown of progression economics on any deployment using legacy bytecode.

Why this severity

This is a direct, single-transaction unauthorized state change with no preconditions beyond calling a public function.

Recommendation

Change expUp to internal to match v1, or remove legacy from deployment scope entirely.

Code reference

    function expUp(uint256 _exp) public returns(bool){
        bool lvUp = false;
        players[msg.sender].exp += _exp;
        if (players[msg.sender].exp >= players[msg.sender].nextLv) {
            // ... level-up stat mutations ...
        }
        return lvUp;
    }

PoC: Confirmed — target/test/LegacyAuditPoC.t.sol (PASS)


High.01 — Combat randomness is predictable and identical within each transaction

Source ID: H-01
Severity: High
Location: OnChainRpgBattle.sol / simpleRPGGame.solrandomNumber() and all combat consumers
Status: Open

What the problem is

All critical hits, damage variance, and ETH loot drops depend on a “random” value derived from block fields and msg.sender. These inputs do not change during a single transaction, so every roll in one battle returns the same number. Attackers can simulate outcomes off-chain and only submit transactions when results favor them.

How it works

randomNumber() hashes block.timestamp, block.prevrandao, block.number, and msg.sender. Within one transaction, repeated calls return identical values. Loot uses randomNumber() > 6; crits and damage embed the same function.

Step-by-step exploit

  1. Attacker registers and funds the contract.
  2. Off-chain, attacker simulates randomNumber() for upcoming blocks.
  3. Attacker submits battle() only when simulation shows favorable crits and loot.
  4. Attacker repeats to extract ETH loot above fair odds (up to 0.001 ETH per dragon kill on v1).

Impact

Systematic unfairness; selective extraction from the shared ETH fee pool. Undermines the core game loop despite the README disclaimer about non-production randomness—the same-transaction determinism is an additional, exploitable flaw.

Why this severity

Exploitation is realistic for any motivated player with simulation tooling; impact is repeated economic extraction, not a one-off UX bug. Per-kill loot caps prevent Critical classification.

Recommendation

Integrate verifiable randomness (e.g., Chainlink VRF) with per-round request/fulfill, or at minimum mix a per-call nonce into the hash so rolls differ within a transaction.

Code reference

    function randomNumber() public view returns (uint256) {
        uint256 random =
            uint256(keccak256(abi.encodePacked(block.timestamp, block.prevrandao, msg.sender, block.number)));
        return ((random % 100) + 1) / 10;
    }

PoC: Confirmed — target/test/AuditPoC.t.sol:test_PoC_randomNumber_sameValueWithinTx (PASS)


Medium.01 — Players can re-register, bypass revive fees, and mint achievements without re-grinding

Source ID: M-01
Severity: Medium
Location: OnChainRpgBattle.solregisterPlayer() lines 187–201; claimAchievement() lines 660–686
Status: Open

What the problem is

Nothing prevents a registered player from calling registerPlayer again. Re-registration resets combat stats to level 1 but does not clear kill counters (monsterSlayeds, uniquePlayersSlayed) or achievement flags. A dead player can also re-register for 0.0001 ETH instead of paying the 0.001 ETH revive fee.

How it works

registerPlayer unconditionally overwrites the Player struct. Auxiliary mappings live in separate storage slots and persist across re-registration.

Step-by-step exploit

  1. Player grinds 100 goblin kills (monsterSlayeds[player][1] >= 100).
  2. Player calls registerPlayer again — stats reset to level 1; kill count stays at 100.
  3. Player calls claimAchievement(1) and receives the Goblin Slayer NFT without meeting the grind on the current character.

Impact

Achievement NFT inflation; revive-fee bypass; accidental or griefing stat wipes.

Why this severity

Confirmed by PoC and failing invariant INV-04. Does not drain the full pool but breaks documented progression and NFT rules.

Recommendation

Add require(players[msg.sender].lv == 0, "Already registered") (or equivalent). On re-register, reset or bind auxiliary mappings. Force dead players through revive().

PoC: Confirmed — AuditPoC.t.sol:test_PoC_reregister_preservesMonsterSlayeds (PASS)


Medium.02 — Winning a mutual kill can leave a player “alive” with zero HP

Source ID: M-02
Severity: Medium
Location: OnChainRpgBattle.solbattle() lines 384–408
Status: Open

What the problem is

If the player kills the enemy and takes lethal damage in the same round, the code breaks out of the loop on the enemy-death branch before setting isAlive = false. The player ends with currentHp = 0 but isAlive = true—a “zombie” who can keep battling or be healed without using revive().

Step-by-step exploit

  1. Enter a battle where same-round mutual kill occurs (PRNG-dependent; easier with H-01 simulation).
  2. Observe currentHp == 0 and isAlive == true after the transaction.
  3. Call heal() or battle() without paying revive fee.

Impact

Death/revive fee economics bypass; inconsistent game state.

Why this severity

Breaks a core lifecycle invariant; composes with predictable randomness. Structural line-proof; dedicated Forge PoC recommended before mainnet.

Recommendation

After the combat loop, if currentHp == 0, set isAlive = false. Evaluate player death before awarding win rewards.


Medium.03 — Smart contract wallets cannot claim achievement NFTs

Source ID: M-03
Severity: Medium
Location: OnChainRpgBattle.solclaimAchievement() lines 660–686
Status: Open

What the problem is

Achievements are minted with OpenZeppelin _safeMint, which requires contract recipients to implement onERC721Received. Smart contract wallets (Safes, many AA wallets) without that hook cannot claim NFTs even after meeting kill requirements.

Impact

Documented product feature permanently unavailable to a significant wallet class; grind on that address cannot yield NFTs.

Why this severity

Real user-flow breakage for core feature, not theoretical.

Recommendation

Allow an explicit EOA recipient parameter, or reject contract addresses at registration with clear documentation.


Medium.04 — Legacy battle loops break when paying for more than 256 rounds

Source ID: M-04
Severity: Medium
Location: legacy/simpleRPGGame.solbattle() line 271
Status: Open — legacy only (v1 uses uint256)

The loop counter is uint8. When _battleRounds > 256, the counter wraps and the transaction runs out of gas. Fix: use uint256 as in v1.


Medium.05 — Legacy PvP has no level-difference protection

Source ID: M-05
Severity: Medium
Location: legacy/simpleRPGGame.solchallengePlayer()
Status: Open — legacy only

Production v1 enforces a 10-level PvP cap. Legacy allows max-level players to farm low-level targets. Port v1’s check or deprecate legacy.


Appendix — Additional Observations

| Source ID | Severity | Title | |-----------|----------|-------| | I-01 | Informational | Owner can withdraw full ETH balance (documented admin power) | | L-01 | Low | No reentrancy guard on loot payout / NFT mint (CEI holds; add guard) | | L-02 | Low | Overpaid ETH not refunded on payable functions | | L-03 | Low | Legacy resets EXP incorrectly on level-up | | L-04 | Low | PvP defender crit uses same roll as attacker | | L-05 | Low | Invalid enemy IDs accepted | | L-06 | Low | Guild top-5 ranking may be incomplete | | L-07 | Low | Anyone can joinGuild without owner approval | | I-02 | Informational | Block proposer can bias randomness (README acknowledged) | | I-03 | Informational | All tokens of same achievement share one metadata URI | | I-04 | Informational | IPFS base URI cannot be updated on-chain |


Recommendations Summary

  1. Do not deploy legacy — Critical expUp bypass (C-01).
  2. Replace PRNG with VRF or commit-reveal; fix same-tx roll collision (H-01).
  3. One-time registration with synchronized auxiliary state (M-01).
  4. Fix mutual-kill ordering to prevent zombie state (M-02).
  5. Support SCW claims or restrict to EOAs (M-03).
  6. Expand branch/integration tests before mainnet (quality scorecard: 62/100).

Residual Risk

  • Branch coverage on v1 remains 35.82%.
  • Owner retains full withdraw capability (documented centralization).
  • Client README acknowledges weak randomness; H-01 remains until VRF is integrated.

Disclaimer

This assessment reflects the codebase at the time of review. It is not a guarantee of future security. Deployment and economic decisions remain the client’s responsibility.

Tucker Sossaman
AuditAid

← All public reports