Contract Source Code:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/token/ERC721/IERC721.sol";
import "./interfaces/INFTStats.sol";
import "./interfaces/ICollectionRegistry.sol";
import "./libraries/StatsCalculator.sol";
import "./libraries/StatValidation.sol";
import "@pythnetwork/entropy-sdk-solidity/IEntropy.sol";
import "@pythnetwork/entropy-sdk-solidity/IEntropyConsumer.sol";
/// @title NFTStats
/// @notice Manages individual NFT stats, experience, and leveling
contract NFTStats is INFTStats, Ownable, IEntropyConsumer {
// ------------------------- Immutable state variables -------------------------
ICollectionRegistry public immutable collectionRegistry;
IEntropy public constant entropy =
IEntropy(0x36825bf3Fbdf5a29E2d5148bfe7Dcf7B5639e320);
address public constant provider =
0x52DeaA1c84233F7bb8C8A45baeDE41091c616506;
bytes5 public constant STAT_VARIATION_BY_RARITY =
bytes5(
abi.encodePacked(
uint8(10),
uint8(20),
uint8(30),
uint8(50),
uint8(100)
)
);
// ------------------------- Constants for XP and leveling -------------------------
uint96 private constant BASE_XP_PER_LEVEL = 100;
uint96 private constant XP_MULTIPLIER = 150; // 150% increase per level
uint96 private constant BASE_STAT_INCREASE_PERCENT = 5; // 5% increase per level
uint32 private constant STARTING_LEVEL = 1;
// ------------------------- State variables -------------------------
mapping(address => mapping(uint256 => NFTStatsData)) private nftStats;
mapping(uint64 => PendingRoll) private pendingRolls;
mapping(address => bool) private authorizedContracts;
// ------------------------- Structs -------------------------
struct PendingRoll {
address collection;
uint256 tokenId;
}
// ------------------------- Modifiers -------------------------
modifier onlyAuthorized() {
require(
msg.sender == owner() || authorizedContracts[msg.sender],
"Not authorized"
);
_;
}
// ------------------------- Constructor -------------------------
constructor(address _collectionRegistry) Ownable(msg.sender) {
require(_collectionRegistry != address(0), "Invalid registry address");
collectionRegistry = ICollectionRegistry(_collectionRegistry);
}
// ------------------------- External functions - Admin -------------------------
/// @notice Set authorization for a contract to modify stats
/// @param contract_ Address of the contract
/// @param authorized Whether the contract should be authorized
function setContractAuthorization(
address contract_,
bool authorized
) external onlyOwner {
require(contract_ != address(0), "Invalid contract address");
authorizedContracts[contract_] = authorized;
}
// ------------------------- External functions - Core game mechanics -------------------------
/// @notice Initialize stats for an NFT based on its collection's base stats and a random number
/// @param collection Address of the NFT collection
/// @param tokenId Token ID of the NFT
function rollStats(address collection, uint256 tokenId) external {
require(
!nftStats[collection][tokenId].initialized,
"Already initialized"
);
require(
collectionRegistry.isWhitelisted(collection),
"Collection not whitelisted"
);
// Verify NFT ownership
try IERC721(collection).ownerOf(tokenId) returns (address) {
// NFT exists, proceed with initialization
} catch {
revert("NFT does not exist");
}
// Store pending roll using sequence number
bytes32 pseudoRandomNumber = keccak256(
abi.encode(block.timestamp, block.number, collection, tokenId)
);
// Get the required fee
uint128 requestFee = entropy.getFee(provider);
// Request entropy and trigger callback
uint64 sequenceNumber = entropy.requestWithCallback{value: requestFee}(
provider,
pseudoRandomNumber
);
// Store pending roll
pendingRolls[sequenceNumber] = PendingRoll({
collection: collection,
tokenId: tokenId
});
}
/// @notice Award XP for dungeon progress
/// @param collection Address of the NFT collection
/// @param tokenId Token ID of the NFT
/// @param xpAmount Amount of XP to award
/// @param roomsCleared Number of rooms cleared in this run
function awardXP(
address collection,
uint256 tokenId,
uint256 xpAmount,
uint256 roomsCleared
) external onlyAuthorized {
require(
nftStats[collection][tokenId].initialized,
"Stats not initialized"
);
NFTStatsData storage stats = nftStats[collection][tokenId];
// Single storage update for XP
uint256 newXP = stats.currentXP + xpAmount;
stats.currentXP = uint96(newXP);
stats.roomsCleared = uint32(stats.roomsCleared + roomsCleared);
emit XPGained(collection, tokenId, xpAmount, newXP);
}
/// @notice Process pending level ups for a character
/// @param collection Address of the NFT collection
/// @param tokenId Token ID of the NFT
function levelUp(address collection, uint256 tokenId) external {
require(
nftStats[collection][tokenId].initialized,
"Stats not initialized"
);
NFTStatsData storage stats = nftStats[collection][tokenId];
uint96 currentXP = stats.currentXP;
uint96 xpToNextLevel = stats.xpToNextLevel;
uint32 currentLevel = stats.level;
require(currentXP >= xpToNextLevel, "Insufficient XP");
// Calculate all level ups at once
uint256 statMultiplier = 0;
while (currentXP >= xpToNextLevel) {
currentXP -= xpToNextLevel;
currentLevel++;
statMultiplier++;
xpToNextLevel = getXPForNextLevel(currentLevel);
}
// Get base stats and calculate increases
(
uint64 vitalityIncrease,
uint64 strengthIncrease,
uint64 agilityIncrease,
uint64 defenseIncrease
) = getLevelUpStats(collection, tokenId);
// Apply multiplier for multiple level ups
vitalityIncrease = uint64(uint256(vitalityIncrease) * statMultiplier);
strengthIncrease = uint64(uint256(strengthIncrease) * statMultiplier);
agilityIncrease = uint64(uint256(agilityIncrease) * statMultiplier);
defenseIncrease = uint64(uint256(defenseIncrease) * statMultiplier);
// Calculate new stats
uint64[4] memory newStats = [
stats.vitality + vitalityIncrease,
stats.strength + strengthIncrease,
stats.agility + agilityIncrease,
stats.defense + defenseIncrease
];
// Validate new stats
StatValidation.validateClassStats(
collectionRegistry.getCollectionStats(collection).classType,
newStats
);
// Apply all updates in one SSTORE each
stats.vitality = newStats[0];
stats.strength = newStats[1];
stats.agility = newStats[2];
stats.defense = newStats[3];
stats.level = uint32(currentLevel);
stats.xpToNextLevel = uint96(xpToNextLevel);
stats.currentXP = uint96(currentXP);
emit LevelUp(collection, tokenId, currentLevel);
emit StatsBoosted(
collection,
tokenId,
newStats[0],
newStats[1],
newStats[2],
newStats[3]
);
}
/// @notice Record a dungeon run attempt
/// @param collection Address of the NFT collection
/// @param tokenId Token ID of the NFT
/// @param success Whether the run was successful
function recordRun(
address collection,
uint256 tokenId,
bool success
) external onlyAuthorized {
require(
nftStats[collection][tokenId].initialized,
"Stats not initialized"
);
NFTStatsData storage stats = nftStats[collection][tokenId];
stats.dungeonRuns += 1;
if (success) {
stats.successfulRuns += 1;
}
emit RunRecorded(
collection,
tokenId,
success,
stats.roomsCleared,
stats.currentXP
);
}
// ------------------------- External view functions -------------------------
/// @notice Get current stats for an NFT
/// @param collection Address of the NFT collection
/// @param tokenId Token ID of the NFT
/// @return NFTStatsData struct containing current stats
function getStats(
address collection,
uint256 tokenId
) external view returns (NFTStatsData memory) {
return nftStats[collection][tokenId];
}
/// @notice Check if an NFT has been initialized
/// @param collection Address of the NFT collection
/// @param tokenId Token ID of the NFT
/// @return bool True if NFT has been initialized
function isInitialized(
address collection,
uint256 tokenId
) external view returns (bool) {
return nftStats[collection][tokenId].initialized;
}
/// @notice Get secondary stats derived from core stats
/// @param vitality Character's vitality stat
/// @param strength Character's strength stat
/// @param agility Character's agility stat
/// @param defense Character's defense stat
/// @return criticalRate Chance to land critical hits (0-15)
/// @return dodgeChance Chance to dodge attacks (0-10)
/// @return blockRate Chance to block attacks (0-10)
/// @return initiative Determines turn order in combat (0-100)
function getSecondaryStats(
uint64 vitality,
uint64 strength,
uint64 agility,
uint64 defense
)
external
pure
returns (
uint8 criticalRate,
uint8 dodgeChance,
uint8 blockRate,
uint8 initiative
)
{
return
StatsCalculator.calculateSecondaryStats(
vitality,
strength,
agility,
defense
);
}
/// @notice Get the stat increases for a level up
/// @param collection Address of the NFT collection
/// @param tokenId Token ID of the NFT
/// @return vitalityIncrease Amount vitality increases
/// @return strengthIncrease Amount strength increases
/// @return agilityIncrease Amount agility increases
/// @return defenseIncrease Amount defense increases
function getLevelUpStats(
address collection,
uint256 tokenId
)
public
view
returns (
uint64 vitalityIncrease,
uint64 strengthIncrease,
uint64 agilityIncrease,
uint64 defenseIncrease
)
{
require(
nftStats[collection][tokenId].initialized,
"Stats not initialized"
);
NFTStatsData memory stats = nftStats[collection][tokenId];
vitalityIncrease = uint64(
(uint256(stats.vitality) * BASE_STAT_INCREASE_PERCENT) / 100
);
strengthIncrease = uint64(
(uint256(stats.strength) * BASE_STAT_INCREASE_PERCENT) / 100
);
agilityIncrease = uint64(
(uint256(stats.agility) * BASE_STAT_INCREASE_PERCENT) / 100
);
defenseIncrease = uint64(
(uint256(stats.defense) * BASE_STAT_INCREASE_PERCENT) / 100
);
}
// ------------------------- Public view functions -------------------------
/// @notice Calculate XP required for next level
/// @param currentLevel Current level of the NFT
/// @return uint256 XP required for next level
function getXPForNextLevel(
uint32 currentLevel
) public pure returns (uint96) {
return BASE_XP_PER_LEVEL * ((currentLevel * XP_MULTIPLIER) / 100);
}
// ------------------------- Internal functions -------------------------
/// @notice Required by IEntropyConsumer interface
function getEntropy() internal pure override returns (address) {
return address(entropy);
}
/// @notice Callback function for entropy service
function entropyCallback(
uint64 sequenceNumber,
address /* provider */,
bytes32 randomNumber
) internal override {
PendingRoll memory pendingRoll = pendingRolls[sequenceNumber];
// Get base stats from collection registry
ICollectionRegistry.CollectionStats
memory baseStats = collectionRegistry.getCollectionStats(
pendingRoll.collection
);
// Generate random variations using entropy
uint256 seed = uint256(randomNumber);
uint256 rarityRoll = uint256(seed & 0xFF) % 100;
uint8 rarityIndex;
if (rarityRoll < 2) {
rarityIndex = 4; // Legendary
} else if (rarityRoll < 10) {
rarityIndex = 3; // Epic
} else if (rarityRoll < 30) {
rarityIndex = 2; // Rare
} else if (rarityRoll < 50) {
rarityIndex = 1; // Uncommon
} else {
rarityIndex = 0; // Common
}
uint8 statVariation = uint8(STAT_VARIATION_BY_RARITY[rarityIndex]);
// Calculate randomized stats within variation range
uint64 vitalityVariation = uint64(
(uint256(baseStats.baseVitality) *
statVariation *
uint256(seed & 0xFF)) / (255 * 100)
);
uint64 strengthVariation = uint64(
(uint256(baseStats.baseStrength) *
statVariation *
uint256((seed >> 8) & 0xFF)) / (255 * 100)
);
uint64 agilityVariation = uint64(
(uint256(baseStats.baseAgility) *
statVariation *
uint256((seed >> 16) & 0xFF)) / (255 * 100)
);
uint64 defenseVariation = uint64(
(uint256(baseStats.baseDefense) *
statVariation *
uint256((seed >> 24) & 0xFF)) / (255 * 100)
);
// Calculate final stats
uint64[4] memory stats = [
baseStats.baseVitality + vitalityVariation,
baseStats.baseStrength + strengthVariation,
baseStats.baseAgility + agilityVariation,
baseStats.baseDefense + defenseVariation
];
// Validate stats for class type
StatValidation.validateClassStats(baseStats.classType, stats);
// Initialize NFT stats with randomized values
nftStats[pendingRoll.collection][pendingRoll.tokenId] = NFTStatsData({
vitality: stats[0],
strength: stats[1],
agility: stats[2],
defense: stats[3],
level: uint32(STARTING_LEVEL),
currentXP: 0,
xpToNextLevel: getXPForNextLevel(STARTING_LEVEL),
dungeonRuns: 0,
successfulRuns: 0,
roomsCleared: 0,
initialized: true,
rarity: Rarity(rarityIndex)
});
// Clean up pending roll
delete pendingRolls[sequenceNumber];
emit StatsInitialized(
pendingRoll.collection,
pendingRoll.tokenId,
stats[0],
stats[1],
stats[2],
stats[3]
);
}
// ------------------------- Fallback functions -------------------------
receive() external payable {}
}
// SPDX-License-Identifier: MIT
// OpenZeppelin Contracts (last updated v5.0.0) (access/Ownable.sol)
pragma solidity ^0.8.20;
import {Context} from "../utils/Context.sol";
/**
* @dev Contract module which provides a basic access control mechanism, where
* there is an account (an owner) that can be granted exclusive access to
* specific functions.
*
* The initial owner is set to the address provided by the deployer. This can
* later be changed with {transferOwnership}.
*
* This module is used through inheritance. It will make available the modifier
* `onlyOwner`, which can be applied to your functions to restrict their use to
* the owner.
*/
abstract contract Ownable is Context {
address private _owner;
/**
* @dev The caller account is not authorized to perform an operation.
*/
error OwnableUnauthorizedAccount(address account);
/**
* @dev The owner is not a valid owner account. (eg. `address(0)`)
*/
error OwnableInvalidOwner(address owner);
event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);
/**
* @dev Initializes the contract setting the address provided by the deployer as the initial owner.
*/
constructor(address initialOwner) {
if (initialOwner == address(0)) {
revert OwnableInvalidOwner(address(0));
}
_transferOwnership(initialOwner);
}
/**
* @dev Throws if called by any account other than the owner.
*/
modifier onlyOwner() {
_checkOwner();
_;
}
/**
* @dev Returns the address of the current owner.
*/
function owner() public view virtual returns (address) {
return _owner;
}
/**
* @dev Throws if the sender is not the owner.
*/
function _checkOwner() internal view virtual {
if (owner() != _msgSender()) {
revert OwnableUnauthorizedAccount(_msgSender());
}
}
/**
* @dev Leaves the contract without owner. It will not be possible to call
* `onlyOwner` functions. Can only be called by the current owner.
*
* NOTE: Renouncing ownership will leave the contract without an owner,
* thereby disabling any functionality that is only available to the owner.
*/
function renounceOwnership() public virtual onlyOwner {
_transferOwnership(address(0));
}
/**
* @dev Transfers ownership of the contract to a new account (`newOwner`).
* Can only be called by the current owner.
*/
function transferOwnership(address newOwner) public virtual onlyOwner {
if (newOwner == address(0)) {
revert OwnableInvalidOwner(address(0));
}
_transferOwnership(newOwner);
}
/**
* @dev Transfers ownership of the contract to a new account (`newOwner`).
* Internal function without access restriction.
*/
function _transferOwnership(address newOwner) internal virtual {
address oldOwner = _owner;
_owner = newOwner;
emit OwnershipTransferred(oldOwner, newOwner);
}
}
// SPDX-License-Identifier: MIT
// OpenZeppelin Contracts (last updated v5.1.0) (token/ERC721/IERC721.sol)
pragma solidity ^0.8.20;
import {IERC165} from "../../utils/introspection/IERC165.sol";
/**
* @dev Required interface of an ERC-721 compliant contract.
*/
interface IERC721 is IERC165 {
/**
* @dev Emitted when `tokenId` token is transferred from `from` to `to`.
*/
event Transfer(address indexed from, address indexed to, uint256 indexed tokenId);
/**
* @dev Emitted when `owner` enables `approved` to manage the `tokenId` token.
*/
event Approval(address indexed owner, address indexed approved, uint256 indexed tokenId);
/**
* @dev Emitted when `owner` enables or disables (`approved`) `operator` to manage all of its assets.
*/
event ApprovalForAll(address indexed owner, address indexed operator, bool approved);
/**
* @dev Returns the number of tokens in ``owner``'s account.
*/
function balanceOf(address owner) external view returns (uint256 balance);
/**
* @dev Returns the owner of the `tokenId` token.
*
* Requirements:
*
* - `tokenId` must exist.
*/
function ownerOf(uint256 tokenId) external view returns (address owner);
/**
* @dev Safely transfers `tokenId` token from `from` to `to`.
*
* Requirements:
*
* - `from` cannot be the zero address.
* - `to` cannot be the zero address.
* - `tokenId` token must exist and be owned by `from`.
* - If the caller is not `from`, it must be approved to move this token by either {approve} or {setApprovalForAll}.
* - If `to` refers to a smart contract, it must implement {IERC721Receiver-onERC721Received}, which is called upon
* a safe transfer.
*
* Emits a {Transfer} event.
*/
function safeTransferFrom(address from, address to, uint256 tokenId, bytes calldata data) external;
/**
* @dev Safely transfers `tokenId` token from `from` to `to`, checking first that contract recipients
* are aware of the ERC-721 protocol to prevent tokens from being forever locked.
*
* Requirements:
*
* - `from` cannot be the zero address.
* - `to` cannot be the zero address.
* - `tokenId` token must exist and be owned by `from`.
* - If the caller is not `from`, it must have been allowed to move this token by either {approve} or
* {setApprovalForAll}.
* - If `to` refers to a smart contract, it must implement {IERC721Receiver-onERC721Received}, which is called upon
* a safe transfer.
*
* Emits a {Transfer} event.
*/
function safeTransferFrom(address from, address to, uint256 tokenId) external;
/**
* @dev Transfers `tokenId` token from `from` to `to`.
*
* WARNING: Note that the caller is responsible to confirm that the recipient is capable of receiving ERC-721
* or else they may be permanently lost. Usage of {safeTransferFrom} prevents loss, though the caller must
* understand this adds an external call which potentially creates a reentrancy vulnerability.
*
* Requirements:
*
* - `from` cannot be the zero address.
* - `to` cannot be the zero address.
* - `tokenId` token must be owned by `from`.
* - If the caller is not `from`, it must be approved to move this token by either {approve} or {setApprovalForAll}.
*
* Emits a {Transfer} event.
*/
function transferFrom(address from, address to, uint256 tokenId) external;
/**
* @dev Gives permission to `to` to transfer `tokenId` token to another account.
* The approval is cleared when the token is transferred.
*
* Only a single account can be approved at a time, so approving the zero address clears previous approvals.
*
* Requirements:
*
* - The caller must own the token or be an approved operator.
* - `tokenId` must exist.
*
* Emits an {Approval} event.
*/
function approve(address to, uint256 tokenId) external;
/**
* @dev Approve or remove `operator` as an operator for the caller.
* Operators can call {transferFrom} or {safeTransferFrom} for any token owned by the caller.
*
* Requirements:
*
* - The `operator` cannot be the address zero.
*
* Emits an {ApprovalForAll} event.
*/
function setApprovalForAll(address operator, bool approved) external;
/**
* @dev Returns the account approved for `tokenId` token.
*
* Requirements:
*
* - `tokenId` must exist.
*/
function getApproved(uint256 tokenId) external view returns (address operator);
/**
* @dev Returns if the `operator` is allowed to manage all of the assets of `owner`.
*
* See {setApprovalForAll}
*/
function isApprovedForAll(address owner, address operator) external view returns (bool);
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @title INFTStats
/// @notice Interface for managing individual NFT stats and progression
interface INFTStats {
// ------------------------- Type definitions -------------------------
/// @notice Enum representing different rarity levels
/// affects the stat variation of the NFT
enum Rarity {
Common,
Uncommon,
Rare,
Epic,
Legendary
}
/// @notice Structure for NFT permanent stats
struct NFTStatsData {
// Core Stats (256 bits)
uint64 vitality; // Replaces HP
uint64 strength; // Replaces attack
uint64 agility; // Replaces speed
uint64 defense; // New stat
// Progression data (256 bits)
uint32 level;
uint96 currentXP;
uint96 xpToNextLevel;
uint32 dungeonRuns;
uint32 successfulRuns;
uint32 roomsCleared;
bool initialized;
Rarity rarity;
}
// ------------------------- Events - Stats -------------------------
/// @notice Event emitted when an NFT's stats are initialized
event StatsInitialized(
address indexed collection,
uint256 indexed tokenId,
uint64 vitality,
uint64 strength,
uint64 agility,
uint64 defense
);
/// @notice Event emitted when an NFT's stats are boosted
event StatsBoosted(
address indexed collection,
uint256 indexed tokenId,
uint64 newVitality,
uint64 newStrength,
uint64 newAgility,
uint64 newDefense
);
// ------------------------- Events - Progression -------------------------
/// @notice Event emitted when XP is gained
event XPGained(
address indexed collection,
uint256 indexed tokenId,
uint256 xpGained,
uint256 newTotalXP
);
/// @notice Event emitted when a level up occurs
event LevelUp(
address indexed collection,
uint256 indexed tokenId,
uint256 newLevel
);
/// @notice Event emitted when a run is recorded
event RunRecorded(
address indexed collection,
uint256 indexed tokenId,
bool success,
uint256 roomsCleared,
uint256 xpGained
);
// ------------------------- View/Pure Functions -------------------------
/// @notice Get current stats for an NFT
/// @param collection Address of the NFT collection
/// @param tokenId Token ID of the NFT
/// @return NFTStatsData struct containing current stats
function getStats(
address collection,
uint256 tokenId
) external view returns (NFTStatsData memory);
/// @notice Check if an NFT has been initialized
/// @param collection Address of the NFT collection
/// @param tokenId Token ID of the NFT
/// @return bool True if NFT has been initialized
function isInitialized(
address collection,
uint256 tokenId
) external view returns (bool);
/// @notice Calculate XP required for next level
/// @param currentLevel Current level of the NFT
/// @return uint256 XP required for next level
function getXPForNextLevel(
uint32 currentLevel
) external pure returns (uint96);
/// @notice Get secondary stats derived from core stats
/// @param vitality Character's vitality stat
/// @param strength Character's strength stat
/// @param agility Character's agility stat
/// @param defense Character's defense stat
/// @return criticalRate Chance to land critical hits (0-15)
/// @return dodgeChance Chance to dodge attacks (0-10)
/// @return blockRate Chance to block attacks (0-10)
/// @return initiative Determines turn order in combat (0-100)
function getSecondaryStats(
uint64 vitality,
uint64 strength,
uint64 agility,
uint64 defense
)
external
pure
returns (
uint8 criticalRate,
uint8 dodgeChance,
uint8 blockRate,
uint8 initiative
);
/// @notice Get the stat increases for a level up
/// @param collection Address of the NFT collection
/// @param tokenId Token ID of the NFT
/// @return vitalityIncrease Amount vitality increases
/// @return strengthIncrease Amount strength increases
/// @return agilityIncrease Amount agility increases
/// @return defenseIncrease Amount defense increases
function getLevelUpStats(
address collection,
uint256 tokenId
)
external
view
returns (
uint64 vitalityIncrease,
uint64 strengthIncrease,
uint64 agilityIncrease,
uint64 defenseIncrease
);
// ------------------------- State-Changing Functions -------------------------
/// @notice Initialize stats for an NFT based on its collection's base stats
/// @param collection Address of the NFT collection
/// @param tokenId Token ID of the NFT
function rollStats(address collection, uint256 tokenId) external;
/// @notice Award XP for dungeon progress
/// @param collection Address of the NFT collection
/// @param tokenId Token ID of the NFT
/// @param xpAmount Amount of XP to award
/// @param roomsCleared Number of rooms cleared in this run
function awardXP(
address collection,
uint256 tokenId,
uint256 xpAmount,
uint256 roomsCleared
) external;
/// @notice Record a dungeon run attempt
/// @param collection Address of the NFT collection
/// @param tokenId Token ID of the NFT
/// @param success Whether the run was successful
function recordRun(
address collection,
uint256 tokenId,
bool success
) external;
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @title ICollectionRegistry
/// @notice Interface for managing whitelisted NFT collections and their base stats
interface ICollectionRegistry {
// ------------------------- Type definitions -------------------------
/// @notice Enum for different class archetypes
enum ClassArchetype {
WARRIOR, // High strength/defense
ROGUE, // High agility/critical
PALADIN, // Balanced with healing
BERSERKER // High damage/risk
}
/// @notice Stats structure for NFT collections
struct CollectionStats {
uint64 baseVitality;
uint64 baseStrength;
uint64 baseAgility;
uint64 baseDefense;
uint8 classType; // ClassArchetype
uint8 complexity; // For gas limit determination
bool isWhitelisted;
}
// ------------------------- Events -------------------------
/// @notice Event emitted when a collection is whitelisted
event CollectionWhitelisted(
address indexed collection,
uint64 baseVitality,
uint64 baseStrength,
uint64 baseAgility,
uint64 baseDefense,
ClassArchetype classType,
uint8 complexity
);
/// @notice Event emitted when a collection's stats are updated
event CollectionStatsUpdated(
address indexed collection,
uint64 baseVitality,
uint64 baseStrength,
uint64 baseAgility,
uint64 baseDefense,
ClassArchetype classType,
uint8 complexity
);
/// @notice Event emitted when a collection is removed from whitelist
event CollectionRemoved(address indexed collection);
// ------------------------- Admin functions -------------------------
/// @notice Whitelist a new NFT collection with base stats
/// @param collection Address of the NFT collection
/// @param baseVitality Initial vitality for NFTs from this collection
/// @param baseStrength Initial strength for NFTs from this collection
/// @param baseAgility Initial agility for NFTs from this collection
/// @param baseDefense Initial defense for NFTs from this collection
/// @param classType Class archetype for this collection
/// @param complexity Gas complexity tier (1-3)
function whitelistCollection(
address collection,
uint64 baseVitality,
uint64 baseStrength,
uint64 baseAgility,
uint64 baseDefense,
ClassArchetype classType,
uint8 complexity
) external;
/// @notice Update base stats for a whitelisted collection
/// @param collection Address of the NFT collection
/// @param baseVitality New base vitality
/// @param baseStrength New base strength
/// @param baseAgility New base agility
/// @param baseDefense New base defense
/// @param classType New class archetype
/// @param complexity New complexity tier
function updateCollectionStats(
address collection,
uint64 baseVitality,
uint64 baseStrength,
uint64 baseAgility,
uint64 baseDefense,
ClassArchetype classType,
uint8 complexity
) external;
/// @notice Remove a collection from the whitelist
/// @param collection Address of the NFT collection to remove
function removeCollection(address collection) external;
// ------------------------- View functions -------------------------
/// @notice Check if a collection is whitelisted
/// @param collection Address of the NFT collection to check
/// @return bool True if collection is whitelisted
function isWhitelisted(address collection) external view returns (bool);
/// @notice Get base stats for a collection
/// @param collection Address of the NFT collection
/// @return CollectionStats struct containing base stats
function getCollectionStats(
address collection
) external view returns (CollectionStats memory);
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @title StatsCalculator
/// @notice Library for calculating derived stats and combat values
library StatsCalculator {
// ------------------------- Constants -------------------------
uint8 private constant BASE_CRITICAL_RATE = 5;
uint8 private constant MAX_CRITICAL_RATE = 15;
uint8 private constant MAX_DODGE_CHANCE = 10;
uint8 private constant MAX_BLOCK_RATE = 10;
uint8 private constant CRITICAL_DAMAGE_PERCENT = 150;
uint8 private constant BLOCK_REDUCTION_PERCENT = 50;
uint256 private constant HP_PER_VITALITY = 5;
// ------------------------- Core stat calculations -------------------------
/// @notice Calculate max HP from vitality
/// @param vitality Character's vitality stat
/// @return uint256 Maximum HP value
function calculateHp(uint64 vitality) public pure returns (uint256) {
return uint256(vitality) * HP_PER_VITALITY;
}
/// @notice Calculate damage considering strength and enemy defense
/// @param strength Attacker's strength stat
/// @param enemyDefense Defender's defense stat
/// @return uint256 Base damage value
function calculateDamage(
uint64 strength,
uint64 enemyDefense
) public pure returns (uint256) {
return (uint256(strength) * 100) / (100 + uint256(enemyDefense));
}
// ------------------------- Secondary stat calculations -------------------------
/// @notice Calculate all secondary stats
/// @param vitality Character's vitality stat
/// @param strength Character's strength stat
/// @param agility Character's agility stat
/// @param defense Character's defense stat
/// @return criticalRate Chance to land critical hits (0-15)
/// @return dodgeChance Chance to dodge attacks (0-10)
/// @return blockRate Chance to block attacks (0-10)
/// @return initiative Determines turn order in combat (0-100)
function calculateSecondaryStats(
uint64 vitality,
uint64 strength,
uint64 agility,
uint64 defense
)
public
pure
returns (
uint8 criticalRate,
uint8 dodgeChance,
uint8 blockRate,
uint8 initiative
)
{
criticalRate = calculateCriticalRate(agility);
dodgeChance = calculateDodgeChance(agility);
blockRate = calculateBlockRate(defense);
initiative = calculateInitiative(agility, strength);
}
/// @notice Calculate critical hit rate from agility
/// @param agility Character's agility stat
/// @return uint8 Critical hit chance (0-15)
function calculateCriticalRate(uint64 agility) public pure returns (uint8) {
uint8 critRate = BASE_CRITICAL_RATE + uint8(agility / 40);
return critRate > MAX_CRITICAL_RATE ? MAX_CRITICAL_RATE : critRate;
}
/// @notice Calculate dodge chance from agility
/// @param agility Character's agility stat
/// @return uint8 Dodge chance (0-10)
function calculateDodgeChance(uint64 agility) public pure returns (uint8) {
uint8 dodgeChance = uint8(agility / 50);
return dodgeChance > MAX_DODGE_CHANCE ? MAX_DODGE_CHANCE : dodgeChance;
}
/// @notice Calculate block rate from defense
/// @param defense Character's defense stat
/// @return uint8 Block chance (0-10)
function calculateBlockRate(uint64 defense) public pure returns (uint8) {
uint8 blockRate = uint8(defense / 50);
return blockRate > MAX_BLOCK_RATE ? MAX_BLOCK_RATE : blockRate;
}
/// @notice Calculate initiative for combat order
/// @param agility Character's agility stat
/// @param strength Character's strength stat
/// @return uint8 Initiative value (0-100)
function calculateInitiative(
uint64 agility,
uint64 strength
) public pure returns (uint8) {
return uint8(((uint256(agility) * 2) + uint256(strength)) / 3);
}
// ------------------------- Combat calculations -------------------------
/// @notice Calculate final damage including critical hits
/// @param baseDamage Base damage amount
/// @param criticalRate Critical hit chance
/// @param entropy Random value for critical determination
/// @return uint256 Final damage amount
function calculateDamageWithCrit(
uint256 baseDamage,
uint8 criticalRate,
bytes32 entropy
) public pure returns (uint256) {
bool isCritical = uint8(uint256(entropy) & 0xFF) < criticalRate;
return
isCritical
? (baseDamage * CRITICAL_DAMAGE_PERCENT) / 100
: baseDamage;
}
/// @notice Calculate damage reduction from blocking
/// @param incomingDamage Original damage amount
/// @param blockRate Block chance
/// @param entropy Random value for block determination
/// @return uint256 Final damage after potential block
function calculateDamageReduction(
uint256 incomingDamage,
uint8 blockRate,
bytes32 entropy
) public pure returns (uint256) {
bool isBlocked = uint8(uint256(entropy >> 8) & 0xFF) < blockRate;
return
isBlocked
? (incomingDamage * BLOCK_REDUCTION_PERCENT) / 100
: incomingDamage;
}
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "../interfaces/ICollectionRegistry.sol";
/// @title StatValidation
/// @notice Library for validating stat ranges and class-specific requirements
library StatValidation {
// ------------------------- Constants -------------------------
uint64 private constant MIN_STAT_VALUE = 50;
uint64 private constant MAX_STAT_VALUE = 200;
uint64 private constant MAX_STAT_INCREASE = 50;
uint8 private constant MAX_COMPLEXITY = 3;
// Gas limits by complexity tier
uint256 private constant TIER1_GAS_LIMIT = 30000;
uint256 private constant TIER2_GAS_LIMIT = 50000;
uint256 private constant TIER3_GAS_LIMIT = 80000;
// ------------------------- Errors -------------------------
error InvalidStatRange(uint64 value, uint64 min, uint64 max);
error InvalidStatIncrease(
uint64 oldValue,
uint64 newValue,
uint64 maxIncrease
);
error InvalidClassStats(uint8 classType, string reason);
error InvalidComplexity(uint8 complexity, uint256 gasUsed);
// ------------------------- Core validation -------------------------
/// @notice Validate a stat value is within acceptable range
/// @param value Stat value to check
/// @param min Minimum allowed value
/// @param max Maximum allowed value
function validateStatRange(
uint64 value,
uint64 min,
uint64 max
) public pure {
if (value < min || value > max) {
revert InvalidStatRange(value, min, max);
}
}
/// @notice Validate a stat increase is within acceptable range
/// @param oldValue Previous stat value
/// @param newValue New stat value
/// @param maxIncrease Maximum allowed increase
function validateStatIncrease(
uint64 oldValue,
uint64 newValue,
uint64 maxIncrease
) public pure {
if (newValue < oldValue || newValue > oldValue + maxIncrease) {
revert InvalidStatIncrease(oldValue, newValue, maxIncrease);
}
}
// ------------------------- Class validation -------------------------
/// @notice Validate stats are appropriate for class type
/// @param classType The class archetype
/// @param stats Array of stats [vitality, strength, agility, defense]
function validateClassStats(
uint8 classType,
uint64[4] memory stats
) public pure {
ICollectionRegistry.ClassArchetype archetype = ICollectionRegistry
.ClassArchetype(classType);
// Validate base requirements for each class
if (archetype == ICollectionRegistry.ClassArchetype.WARRIOR) {
if (stats[1] < 80 || stats[3] < 80) {
// strength and defense
revert InvalidClassStats(
classType,
"Warrior requires high strength and defense"
);
}
} else if (archetype == ICollectionRegistry.ClassArchetype.ROGUE) {
if (stats[2] < 80) {
// agility
revert InvalidClassStats(
classType,
"Rogue requires high agility"
);
}
} else if (archetype == ICollectionRegistry.ClassArchetype.PALADIN) {
if (stats[0] < 80 || stats[3] < 70) {
// vitality and defense
revert InvalidClassStats(
classType,
"Paladin requires high vitality and defense"
);
}
} else if (archetype == ICollectionRegistry.ClassArchetype.BERSERKER) {
if (stats[1] < 90) {
// strength
revert InvalidClassStats(
classType,
"Berserker requires very high strength"
);
}
}
// Validate all stats are within global range
for (uint256 i = 0; i < 4; i++) {
validateStatRange(stats[i], MIN_STAT_VALUE, MAX_STAT_VALUE);
}
}
// ------------------------- Complexity validation -------------------------
/// @notice Validate gas usage against complexity tier
/// @param complexity Complexity tier (1-3)
/// @param gasUsed Amount of gas used
function validateComplexityRequirements(
uint8 complexity,
uint256 gasUsed
) public pure {
if (complexity == 0 || complexity > MAX_COMPLEXITY) {
revert InvalidComplexity(complexity, gasUsed);
}
uint256 gasLimit = complexity == 1
? TIER1_GAS_LIMIT
: complexity == 2
? TIER2_GAS_LIMIT
: TIER3_GAS_LIMIT;
if (gasUsed > gasLimit) {
revert InvalidComplexity(complexity, gasUsed);
}
}
// ------------------------- Utility functions -------------------------
/// @notice Get the gas limit for a complexity tier
/// @param complexity Complexity tier (1-3)
/// @return uint256 Gas limit for the tier
function getGasLimitForComplexity(
uint8 complexity
) public pure returns (uint256) {
return
complexity == 1
? TIER1_GAS_LIMIT
: complexity == 2
? TIER2_GAS_LIMIT
: complexity == 3
? TIER3_GAS_LIMIT
: 0;
}
}
// SPDX-License-Identifier: Apache 2
pragma solidity ^0.8.0;
import "./EntropyEvents.sol";
interface IEntropy is EntropyEvents {
// Register msg.sender as a randomness provider. The arguments are the provider's configuration parameters
// and initial commitment. Re-registering the same provider rotates the provider's commitment (and updates
// the feeInWei).
//
// chainLength is the number of values in the hash chain *including* the commitment, that is, chainLength >= 1.
function register(
uint128 feeInWei,
bytes32 commitment,
bytes calldata commitmentMetadata,
uint64 chainLength,
bytes calldata uri
) external;
// Withdraw a portion of the accumulated fees for the provider msg.sender.
// Calling this function will transfer `amount` wei to the caller (provided that they have accrued a sufficient
// balance of fees in the contract).
function withdraw(uint128 amount) external;
// Withdraw a portion of the accumulated fees for provider. The msg.sender must be the fee manager for this provider.
// Calling this function will transfer `amount` wei to the caller (provided that they have accrued a sufficient
// balance of fees in the contract).
function withdrawAsFeeManager(address provider, uint128 amount) external;
// As a user, request a random number from `provider`. Prior to calling this method, the user should
// generate a random number x and keep it secret. The user should then compute hash(x) and pass that
// as the userCommitment argument. (You may call the constructUserCommitment method to compute the hash.)
//
// This method returns a sequence number. The user should pass this sequence number to
// their chosen provider (the exact method for doing so will depend on the provider) to retrieve the provider's
// number. The user should then call fulfillRequest to construct the final random number.
//
// This method will revert unless the caller provides a sufficient fee (at least getFee(provider)) as msg.value.
// Note that excess value is *not* refunded to the caller.
function request(
address provider,
bytes32 userCommitment,
bool useBlockHash
) external payable returns (uint64 assignedSequenceNumber);
// Request a random number. The method expects the provider address and a secret random number
// in the arguments. It returns a sequence number.
//
// The address calling this function should be a contract that inherits from the IEntropyConsumer interface.
// The `entropyCallback` method on that interface will receive a callback with the generated random number.
//
// This method will revert unless the caller provides a sufficient fee (at least getFee(provider)) as msg.value.
// Note that excess value is *not* refunded to the caller.
function requestWithCallback(
address provider,
bytes32 userRandomNumber
) external payable returns (uint64 assignedSequenceNumber);
// Fulfill a request for a random number. This method validates the provided userRandomness and provider's proof
// against the corresponding commitments in the in-flight request. If both values are validated, this function returns
// the corresponding random number.
//
// Note that this function can only be called once per in-flight request. Calling this function deletes the stored
// request information (so that the contract doesn't use a linear amount of storage in the number of requests).
// If you need to use the returned random number more than once, you are responsible for storing it.
function reveal(
address provider,
uint64 sequenceNumber,
bytes32 userRevelation,
bytes32 providerRevelation
) external returns (bytes32 randomNumber);
// Fulfill a request for a random number. This method validates the provided userRandomness
// and provider's revelation against the corresponding commitment in the in-flight request. If both values are validated
// and the requestor address is a contract address, this function calls the requester's entropyCallback method with the
// sequence number, provider address and the random number as arguments. Else if the requestor is an EOA, it won't call it.
//
// Note that this function can only be called once per in-flight request. Calling this function deletes the stored
// request information (so that the contract doesn't use a linear amount of storage in the number of requests).
// If you need to use the returned random number more than once, you are responsible for storing it.
//
// Anyone can call this method to fulfill a request, but the callback will only be made to the original requester.
function revealWithCallback(
address provider,
uint64 sequenceNumber,
bytes32 userRandomNumber,
bytes32 providerRevelation
) external;
function getProviderInfo(
address provider
) external view returns (EntropyStructs.ProviderInfo memory info);
function getDefaultProvider() external view returns (address provider);
function getRequest(
address provider,
uint64 sequenceNumber
) external view returns (EntropyStructs.Request memory req);
function getFee(address provider) external view returns (uint128 feeAmount);
function getAccruedPythFees()
external
view
returns (uint128 accruedPythFeesInWei);
function setProviderFee(uint128 newFeeInWei) external;
function setProviderFeeAsFeeManager(
address provider,
uint128 newFeeInWei
) external;
function setProviderUri(bytes calldata newUri) external;
// Set manager as the fee manager for the provider msg.sender.
// After calling this function, manager will be able to set the provider's fees and withdraw them.
// Only one address can be the fee manager for a provider at a time -- calling this function again with a new value
// will override the previous value. Call this function with the all-zero address to disable the fee manager role.
function setFeeManager(address manager) external;
function constructUserCommitment(
bytes32 userRandomness
) external pure returns (bytes32 userCommitment);
function combineRandomValues(
bytes32 userRandomness,
bytes32 providerRandomness,
bytes32 blockHash
) external pure returns (bytes32 combinedRandomness);
}
// SPDX-License-Identifier: Apache 2
pragma solidity ^0.8.0;
abstract contract IEntropyConsumer {
// This method is called by Entropy to provide the random number to the consumer.
// It asserts that the msg.sender is the Entropy contract. It is not meant to be
// override by the consumer.
function _entropyCallback(
uint64 sequence,
address provider,
bytes32 randomNumber
) external {
address entropy = getEntropy();
require(entropy != address(0), "Entropy address not set");
require(msg.sender == entropy, "Only Entropy can call this function");
entropyCallback(sequence, provider, randomNumber);
}
// getEntropy returns Entropy contract address. The method is being used to check that the
// callback is indeed from Entropy contract. The consumer is expected to implement this method.
// Entropy address can be found here - https://docs.pyth.network/entropy/contract-addresses
function getEntropy() internal view virtual returns (address);
// This method is expected to be implemented by the consumer to handle the random number.
// It will be called by _entropyCallback after _entropyCallback ensures that the call is
// indeed from Entropy contract.
function entropyCallback(
uint64 sequence,
address provider,
bytes32 randomNumber
) internal virtual;
}
// SPDX-License-Identifier: MIT
// OpenZeppelin Contracts (last updated v5.0.1) (utils/Context.sol)
pragma solidity ^0.8.20;
/**
* @dev Provides information about the current execution context, including the
* sender of the transaction and its data. While these are generally available
* via msg.sender and msg.data, they should not be accessed in such a direct
* manner, since when dealing with meta-transactions the account sending and
* paying for execution may not be the actual sender (as far as an application
* is concerned).
*
* This contract is only required for intermediate, library-like contracts.
*/
abstract contract Context {
function _msgSender() internal view virtual returns (address) {
return msg.sender;
}
function _msgData() internal view virtual returns (bytes calldata) {
return msg.data;
}
function _contextSuffixLength() internal view virtual returns (uint256) {
return 0;
}
}
// SPDX-License-Identifier: MIT
// OpenZeppelin Contracts (last updated v5.1.0) (utils/introspection/IERC165.sol)
pragma solidity ^0.8.20;
/**
* @dev Interface of the ERC-165 standard, as defined in the
* https://eips.ethereum.org/EIPS/eip-165[ERC].
*
* Implementers can declare support of contract interfaces, which can then be
* queried by others ({ERC165Checker}).
*
* For an implementation, see {ERC165}.
*/
interface IERC165 {
/**
* @dev Returns true if this contract implements the interface defined by
* `interfaceId`. See the corresponding
* https://eips.ethereum.org/EIPS/eip-165#how-interfaces-are-identified[ERC section]
* to learn more about how these ids are created.
*
* This function call must use less than 30 000 gas.
*/
function supportsInterface(bytes4 interfaceId) external view returns (bool);
}
// SPDX-License-Identifier: Apache-2.0
pragma solidity ^0.8.0;
import "./EntropyStructs.sol";
interface EntropyEvents {
event Registered(EntropyStructs.ProviderInfo provider);
event Requested(EntropyStructs.Request request);
event RequestedWithCallback(
address indexed provider,
address indexed requestor,
uint64 indexed sequenceNumber,
bytes32 userRandomNumber,
EntropyStructs.Request request
);
event Revealed(
EntropyStructs.Request request,
bytes32 userRevelation,
bytes32 providerRevelation,
bytes32 blockHash,
bytes32 randomNumber
);
event RevealedWithCallback(
EntropyStructs.Request request,
bytes32 userRandomNumber,
bytes32 providerRevelation,
bytes32 randomNumber
);
event ProviderFeeUpdated(address provider, uint128 oldFee, uint128 newFee);
event ProviderUriUpdated(address provider, bytes oldUri, bytes newUri);
event ProviderFeeManagerUpdated(
address provider,
address oldFeeManager,
address newFeeManager
);
event Withdrawal(
address provider,
address recipient,
uint128 withdrawnAmount
);
}
// SPDX-License-Identifier: Apache 2
pragma solidity ^0.8.0;
contract EntropyStructs {
struct ProviderInfo {
uint128 feeInWei;
uint128 accruedFeesInWei;
// The commitment that the provider posted to the blockchain, and the sequence number
// where they committed to this. This value is not advanced after the provider commits,
// and instead is stored to help providers track where they are in the hash chain.
bytes32 originalCommitment;
uint64 originalCommitmentSequenceNumber;
// Metadata for the current commitment. Providers may optionally use this field to help
// manage rotations (i.e., to pick the sequence number from the correct hash chain).
bytes commitmentMetadata;
// Optional URI where clients can retrieve revelations for the provider.
// Client SDKs can use this field to automatically determine how to retrieve random values for each provider.
// TODO: specify the API that must be implemented at this URI
bytes uri;
// The first sequence number that is *not* included in the current commitment (i.e., an exclusive end index).
// The contract maintains the invariant that sequenceNumber <= endSequenceNumber.
// If sequenceNumber == endSequenceNumber, the provider must rotate their commitment to add additional random values.
uint64 endSequenceNumber;
// The sequence number that will be assigned to the next inbound user request.
uint64 sequenceNumber;
// The current commitment represents an index/value in the provider's hash chain.
// These values are used to verify requests for future sequence numbers. Note that
// currentCommitmentSequenceNumber < sequenceNumber.
//
// The currentCommitment advances forward through the provider's hash chain as values
// are revealed on-chain.
bytes32 currentCommitment;
uint64 currentCommitmentSequenceNumber;
// An address that is authorized to set / withdraw fees on behalf of this provider.
address feeManager;
}
struct Request {
// Storage slot 1 //
address provider;
uint64 sequenceNumber;
// The number of hashes required to verify the provider revelation.
uint32 numHashes;
// Storage slot 2 //
// The commitment is keccak256(userCommitment, providerCommitment). Storing the hash instead of both saves 20k gas by
// eliminating 1 store.
bytes32 commitment;
// Storage slot 3 //
// The number of the block where this request was created.
// Note that we're using a uint64 such that we have an additional space for an address and other fields in
// this storage slot. Although block.number returns a uint256, 64 bits should be plenty to index all of the
// blocks ever generated.
uint64 blockNumber;
// The address that requested this random number.
address requester;
// If true, incorporate the blockhash of blockNumber into the generated random value.
bool useBlockhash;
// If true, the requester will be called back with the generated random value.
bool isRequestWithCallback;
// There are 2 remaining bytes of free space in this slot.
}
}