Building Secure Smart Contract Leaderboards: A Comprehensive Guide
Table of Contents
Introduction
Architecting a Secure Leaderboard
Common Smart Contract Mistakes
Conclusion
Introduction
Leaderboards are used to track the top performing users based on specific metrics like scores, earnings, or contributions. In Blockchain, Smart contract leaderboards are a common feature in blockchain gaming, prediction markets, and DeFi reward systems; they are typically tied to incentives. However, many developers inadvertently expose critical functions that malicious actors can exploit. This tutorial series provides a comprehensive guide to building secure, manipulation-resistant leaderboards.
Why Leaderboard Security Matters
Many on-chain leaderboards distribute rewards in the form of tokens or NFTs, creating economic incentives to exploit them.
A tampered leaderboard destroys trust and discourages honest participation.
Exploits are permanent and public onchain.
Poorly designed scoring logic or permissions can lead to contract takeover or fund drains.
Architecting a Secure Leaderboard
Core Design Principles
Principle 1: Separation of Concerns
Following the paradigm of separation of concerns, always keep score tracking, leaderboard logic, and reward distribution in separate contracts or modules.
Bad Pattern: All Logic in One Contract
contract VulnerableLeaderboard {
mapping(address => uint256) public scores;
address[] public topPlayers;
function updateScore(uint256 newScore) public {
scores[msg.sender] = newScore;
updateLeaderboard();
distributeRewards(); // Dangerous!
}
}The issue here is that a single function call modifies state, updates ranking, and triggers payout. If a bug exists in one part, it compromises the whole system.
Good Pattern: Modular Design
Modular design in smart contracts means breaking your code into separate, focused components, each handling a specific responsibility (e.g., scoring, rewards, access control). This improves security, makes the code easier to maintain, and limits the impact of bugs.
contract ScoreTracker {
mapping(address => uint256) public scores;
event ScoreUpdated(address indexed player, uint256 score);
}This contract only tracks scores. It emits events for off-chain listeners or other contracts to respond to.
contract LeaderboardLogic {
IScoreTracker public scoreTracker;
address[] public topPlayers;
}This reads scores and maintains a sorted leaderboard view.
contract RewardDistributor {
ILeaderboardLogic public leaderboard;
mapping(address => bool) public claimed;
}This contract handles payout logic, with eligibility checks based on rankings.
Principle 2: Immutable Game Rules
In smart contracts, immutable variables are assigned once during deployment and help enforce rules that remain constant, increasing security and trust.
Define scoring rules that cannot be changed mid game by hardcoding scoring rules via immutable variables, ensuring they cannot be modified post deployment, protecting against rug pulls or mid game cheating.
contract GameLeaderboard {
uint256 public immutable POINTS_PER_ACTION;
uint256 public immutable MAX_DAILY_ACTIONS;
uint256 public immutable GAME_DURATION;
uint256 public immutable startTime;
constructor(uint256 _pointsPerAction, uint256 _maxDaily, uint256 _duration) {
POINTS_PER_ACTION = _pointsPerAction;
MAX_DAILY_ACTIONS = _maxDaily;
GAME_DURATION = _duration;
startTime = block.timestamp;
}
}Immutable values are set only once in the constructor. These values cannot be altered by admins, removing the temptation or risk of abuse.
Principle 3: Role-Based Access Control
Access control restricts who can call certain functions in a smart contract. It ensures that only authorized addresses (like an admin or game master) can perform sensitive actions, protecting the contract from misuse or attacks.
Implement proper access control for administrative functions. You can use AccessControl from OpenZeppelin to assign granular permissions.
import "@openzeppelin/contracts/access/AccessControl.sol";
contract SecureLeaderboard is AccessControl {
bytes32 public constant GAME_MASTER_ROLE = keccak256("GAME_MASTER_ROLE");
bytes32 public constant SCORE_UPDATER_ROLE = keccak256("SCORE_UPDATER_ROLE");
modifier onlyGameMaster() {
require(hasRole(GAME_MASTER_ROLE, msg.sender), "Not authorized");
_;
}
modifier onlyScoreUpdater() {
require(hasRole(SCORE_UPDATER_ROLE, msg.sender), "Not authorized");
_;
}
}Assign GAME_MASTER_ROLE to trusted deployer. Assign SCORE_UPDATER_ROLE to backend services or game logic bots. Use the modifier blocks to gate access clearly and explicitly.
Data Structure Design
Efficient Storage Patterns
Structs in Solidity are custom data types that group related variables. They help organize complex data, like player stats or game settings, into a single, readable format.
Use structs with primitive types sized for packing. This struct uses all 32 bytes in a storage slot efficiently. Static arrays of fixed-size entries are cheaper than dynamically resizing mappings or arrays.
contract EfficientLeaderboard {
struct Player {
uint128 score; // 16 bytes
uint64 lastUpdateTime; // 8 bytes
uint64 updateCount; // 8 bytes
}
mapping(address => Player) public players;
// Use packed arrays for top players
struct LeaderboardEntry {
address player;
uint128 score;
}
LeaderboardEntry[10] public topTen;
}This struct stores per-player metadata in a gas-efficient format.
uint128 score: 128 bits allows for extremely high score values (up to 3.4e38).uint64 lastUpdateTime: Stores a UNIX timestamp (covers ~584 billion years).uint64 updateCount: Tracks how many times the player has updated their score.
This struct pack neatly into a single 256 bit storage slot when accessed together, reducing gas costs during reads/writes. Solidity packs struct members in the order they are declared, so grouping smaller types together optimizes storage.
Each player (identified by their wallet address) maps to their own Player struct and allows O(1) access to any player’s data. Public getter auto generates players(address) function for easy querying.
Stores the current top 10 players by score, in a fixed size array. The LeaderboardEntry keeps only the minimal necessary data for each entry. Using [10] avoids dynamic resizing, making it more gas predictable than a dynamic array (LeaderboardEntry[]).
Optimization Tip: Avoid using address and uint256 together in the same struct unless needed. uint128 is chosen here to pack tightly with address (160 bits + 128 bits = 288 bits, spread over two slots). Still more efficient than 2 full 256 bit values.
This Smart Contract example utilizes fewer storage slots, resulting in lower gas consumption for read/write. The logic is cleaner. Separate structs for storage (Player) and leaderboard view (LeaderboardEntry) make updates and reads more efficient and readable. If using off chain tools like Protofire or Ormi Subgraphs, a static array like topTen is easier to index and watch.
Common Smart Contract Mistakes
Building secure smart contracts goes beyond functionality, you must also anticipate attacks and manipulation. Let’s walk through the most frequent mistakes and how to fix them.
1. Direct Score Manipulation
Vulnerable Pattern:
// NEVER DO THIS
contract BadLeaderboard {
mapping(address => uint256) public scores;
// Anyone can set any score
function setScore(uint256 _score) public {
scores[msg.sender] = _score;
}
// Even worse: anyone can set anyone's score
function setPlayerScore(address player, uint256 _score) public {
scores[player] = _score;
}
}This completely bypasses gameplay. An attacker can instantly set the highest score, win rewards, or trigger functions based on scores.
Secure Pattern:
contract SecureScoring {
mapping(address => uint256) public scores;
mapping(address => uint256) public lastActionTime;
uint256 constant ACTION_COOLDOWN = 1 hours;
function performAction() public {
require(block.timestamp >= lastActionTime[msg.sender] + ACTION_COOLDOWN, "Cooldown active");
// Validate action
uint256 pointsEarned = calculatePoints(msg.sender);
scores[msg.sender] += pointsEarned;
lastActionTime[msg.sender] = block.timestamp;
emit ActionPerformed(msg.sender, pointsEarned);
}
}Users can’t spam score updates and they must wait between actions. Points are earned through controlled onchain validation, not arbitrary input. Helps limit botting and abuse.
2. Reentrancy Vulnerabilities
Vulnerable Pattern:
contract ReentrancyVulnerable {
mapping(address => uint256) public scores;
mapping(address => uint256) public rewards;
function claimReward() public {
uint256 reward = calculateReward(scores[msg.sender]);
// External call before state update - VULNERABLE!
(bool success,) = msg.sender.call{value: reward}("");
require(success, "Transfer failed");
rewards[msg.sender] += reward;
scores[msg.sender] = 0;
}
}If msg.sender is a contract, it can call claimReward() again during the transfer. This leads to double claiming, draining contract funds.
Secure Pattern:
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract ReentrancySecure is ReentrancyGuard {
mapping(address => uint256) public scores;
mapping(address => uint256) public rewards;
function claimReward() public nonReentrant {
uint256 score = scores[msg.sender];
require(score > 0, "No score to claim");
uint256 reward = calculateReward(score);
// Update state before external call
scores[msg.sender] = 0;
rewards[msg.sender] += reward;
// External call last
(bool success,) = msg.sender.call{value: reward}("");
require(success, "Transfer failed");
emit RewardClaimed(msg.sender, reward);
}
}This uses ReentrancyGuard to block nested execution. State changes are made before any transfer, so reentry attempts are rejected.
3. Front-Running Vulnerabilities
Vulnerable Pattern:
contract FrontRunVulnerable {
uint256 public highScore;
address public leader;
uint256 public prize = 10 ether;
function submitScore(uint256 _score) public {
if (_score > highScore) {
highScore = _score;
leader = msg.sender;
// Immediate reward - can be front-run!
payable(msg.sender).transfer(prize);
}
}
}A malicious user can watch the mempool and submit a higher score before a legitimate user, stealing the reward.
Secure Pattern:
contract FrontRunSecure {
using CommitReveal for mapping(address => bytes32);
mapping(address => bytes32) private commitments;
mapping(address => uint256) public revealedScores;
enum Phase { Commit, Reveal, Ended }
Phase public currentPhase;
function commitScore(bytes32 _commitment) public {
require(currentPhase == Phase.Commit, "Not in commit phase");
commitments[msg.sender] = _commitment;
}
function revealScore(uint256 _score, uint256 _nonce) public {
require(currentPhase == Phase.Reveal, "Not in reveal phase");
require(keccak256(abi.encodePacked(_score, _nonce)) == commitments[msg.sender], "Invalid reveal");
revealedScores[msg.sender] = _score;
emit ScoreRevealed(msg.sender, _score);
}
}Commitments are hashed scores, submitted in advance. Revealing happens later, preventing prediction or copycats.
4. Integer Overflow/Underflow
Vulnerable Pattern (pre-Solidity 0.8.0):
contract OverflowVulnerable {
mapping(address => uint256) public scores;
function addScore(uint256 points) public {
scores[msg.sender] += points; // Can overflow!
}
function subtractScore(uint256 points) public {
scores[msg.sender] -= points; // Can underflow!
}
}Secure Pattern:
// Solidity 0.8+ has built in overflow protection
contract OverflowSecure {
mapping(address => uint256) public scores;
uint256 public constant MAX_SCORE = 1000000;
function addScore(uint256 points) public {
require(points <= MAX_SCORE, "Points too high");
require(scores[msg.sender] + points <= MAX_SCORE, "Score would exceed maximum");
scores[msg.sender] += points;
}
function subtractScore(uint256 points) public {
require(scores[msg.sender] >= points, "Insufficient score");
scores[msg.sender] -= points;
}
}Solidity 0.8.x and newer has built in overflow checks, but it’s good practice to still include business logic bounds like MAX_SCORE.
5. Timestamp Manipulation
Vulnerable Pattern:
contract TimestampVulnerable {
mapping(address => uint256) public dailyActions;
mapping(address => uint256) public lastActionDate;
function performDailyAction() public {
uint256 today = block.timestamp / 1 days;
if (lastActionDate[msg.sender] < today) {
dailyActions[msg.sender] = 0;
lastActionDate[msg.sender] = today;
}
require(dailyActions[msg.sender] < 5, "Daily limit reached");
dailyActions[msg.sender]++; // Miner can manipulate timestamp
}
}Miners/validators can manipulate timestamps by ±15 seconds. This may allow users to claim double daily rewards or reset cooldowns prematurely.
Secure Pattern:
contract TimestampSecure {
mapping(address => uint256) public actionCount;
mapping(address => uint256) public windowStart;
uint256 constant WINDOW_SIZE = 24 hours;
uint256 constant MAX_ACTIONS_PER_WINDOW = 5;
function performAction() public {
if (block.timestamp >= windowStart[msg.sender] + WINDOW_SIZE) {
windowStart[msg.sender] = block.timestamp;
actionCount[msg.sender] = 0;
}
require(actionCount[msg.sender] < MAX_ACTIONS_PER_WINDOW, "Window limit reached");
actionCount[msg.sender]++; // Process action
}
}Avoid using exact dates. Instead, calculate rolling windows (e.g., every 24h) and check how much time has passed.
Conclusion
Now that you’ve explored the architectural patterns, pitfalls, and defensive programming techniques for smart contract leaderboards, here’s how to continue building confidently:
Test Thoroughly
Use Hardhat or Foundry to write unit tests for every key function.
Simulate edge cases: reentrancy, cooldown bypass, overflow, and unauthorized access.
Consider adding fuzz testing to simulate unpredictable inputs.
Consider Formal Verification or Audits
Tools like MythX, Slither, or Foundry's invariant testing can help validate your assumptions. If your leaderboard has rewards or tokens at stake, invest in a third-party security audit.
Integrate Role-Based Game Mechanics
Let admins pause/resume the game.
Add claim reward limits and anti bot protections.
Emit events for off chain indexers (e.g. The Graph or Ormi).

