Overview
There were a lot of new concepts for me in this lesson, as well as familiar ones that I felt the need to learn more. It's a bit winded, but I believe understanding what these terms are will help us to better understand the code when we see it. In addition, there was a lot of updating to the two files from the previous lesson and the addition of two more. I'll break them down bit by bit. This is a long one, but we are getting closer to finishing our course.
Challenging Concepts:
Ownable Contracts:
An "Ownable" contract is a common design pattern in Ethereum and Solidity that allows you to manage ownership and access control within a smart contract. The primary purpose of an Ownable contract is to designate one or more addresses as the owner(s) of the contract, granting them special privileges or control over the contract's functionality. This pattern is often used for contracts where certain operations should only be allowed by the owner.
Here's how an Ownable contract typically works:
Contract Ownership: The Ownable contract defines a state variable to store the address of the owner(s). This address is typically set during contract deployment, designating the initial owner(s).
Modifiers: The Ownable contract includes modifiers (such as
onlyOwner
) that can be used to restrict access to specific functions. These modifiers are placed before the function declarations.Access Control: Functions that require special access control (e.g., to pause the contract, change settings, or perform administrative actions) are annotated with the
onlyOwner
modifier. This modifier checks if the sender of the transaction is the owner and allows or denies access accordingly.
Constructor:
A constructor is like a special function that runs only once when you create a new instance of a smart contract. It's like the setup process for your smart contract.
Consider the following:
Imagine you're building a house (the smart contract). The constructor is like the blueprint that tells the builders (the Ethereum network) how to set up the house when they start building it. It specifies things like the initial color of the walls, the number of rooms, and who the owner of the house is.
Once the constructor is called, it sets up these initial conditions, and you can't change them later. It's a one-time setup that happens when the contract is "born" on the blockchain.
OpenZepplin:
Function Modifiers:
In simple terms, function modifiers in Solidity are like gatekeepers for functions in a smart contract. They check certain conditions before allowing a function to execute. Think of them as security checks or rules that functions must pass before they can run.
Imagine a nightclub (the smart contract) with a bouncer (the modifier) at the entrance. Before people (function calls) can enter the club (execute the function), they must pass the bouncer's checks.
If you meet the age requirement (condition), the bouncer lets you in.
If you're on the VIP list (condition), the bouncer lets you in.
If you have the right dress code (condition), the bouncer lets you in.
But if you don't meet these conditions, the bouncer stops you from entering.
Modifiers work similarly in Solidity. They are used to ensure that only certain conditions are met before a function can be executed. If the conditions are satisfied, the function runs; otherwise, it's rejected, just like at the nightclub entrance.
Let's look at this in the case of onlyOwner
from our lesson.
The onlyOwner
modifier is commonly used for function modifiers in Solidity, and it's typically used to restrict access to specific functions so that only the owner of the contract can execute them. Here's how it applies to onlyOwners
:
Ownership Control: When you create a contract that manages important operations or settings, you often want to ensure that only the contract's owner (or a designated set of owners) can perform certain actions like changing settings, pausing the contract, or performing administrative tasks.
Ownership Modifier: To implement this access control, you define a modifier called
onlyOwner
. This modifier checks whether the sender of a transaction (i.e., the caller of a function) is the owner of the contract. If the sender is the owner, the modifier allows the function to execute; otherwise, it rejects the function call.
So, the onlyOwner
modifier is like the "bouncer" that ensures only the owner (or those with the owner's private key) can perform specific actions within the contract. It's a powerful tool for access control and enhancing the security of smart contracts.
Smaller uint
in Structs:
The gas cost of storing and manipulating data depends on the size of the data types used in a struct. Using smaller data types can indeed reduce gas costs, especially when you have many instances of that struct stored in storage.
Here's a brief explanation:
Gas Costs in Storage: When you declare a struct and store instances of it in storage, each field in the struct consumes a certain amount of gas. Gas costs can be significantly affected by the size of the data types used in the struct. Smaller data types consume less gas than larger ones.
Example: Let's say you have a struct with two fields: an
uint8
and anuint256
. Theuint8
field consumes less gas because it can store values in the range of 0 to 255 and requires fewer storage slots compared to theuint256
, which can store much larger values.Array of Structs: If you have an array of these structs and you're storing many instances of them in storage, the gas cost will multiply. Using smaller data types within the struct can lead to significant gas savings when you have many instances.
However, it's important to note that gas optimization should be balanced with the requirements of your contract. Using smaller data types can reduce gas costs, but it may limit the range of values your contract can handle. You should choose data types that suit your contract's functionality and avoid excessive optimization that might compromise the correctness or usability of your contract.
To be sure, let's look at some different uint sizes:
uint: This is the most basic form of an unsigned integer, and it doesn't have a specific bit size mentioned. It usually defaults to
uint256
in modern Solidity.uint8: It's an unsigned integer represented using 8 bits. This means it can store values from 0 to 255.
uint16: It's an unsigned integer represented using 16 bits. This means it can store values from 0 to 65,535.
uint256: It's an unsigned integer represented using 256 bits. This is a very large integer that can store extremely large values.
readyTime:
"readyTime" is a variable or identifier used to represent a point in time or a timestamp. It often indicates a specific moment when something is expected to be ready or available.
In Solidity or Ethereum smart contracts, "readyTime" could be used to signify when a particular action or condition will be considered "ready" or available for execution. For example, in a DApp, "readyTime" might represent the time when a user can claim rewards or perform a specific action after a waiting period has elapsed.
In simple terms, "readyTime" is a way to keep track of when something is scheduled to be prepared or become accessible in a program or smart contract. It's often used for time-related functionality and scheduling actions in a blockchain application.
Time Units:
Time units are used to work with time-related values, like durations or timestamps, within smart contracts. These units help developers specify time periods in a human-readable and consistent way. Here's a simple explanation:
Seconds (
seconds
): This is the default time unit. If you write a number without any unit, it's assumed to be in seconds. For example,60
means 60 seconds.Minutes (
minutes
): You can specify time in minutes by addingminutes
to a number. For example,5 minutes
represents 5 minutes.Hours (
hours
): Similar to minutes, you can usehours
to specify time in hours. For example,2 hours
represents 2 hours.Days (
days
): To work with days, you usedays
as the unit. For example,7 days
represents a week.Weeks (
weeks
): Weeks are used for longer periods. For example,4 weeks
represents a month (approximately).
These time units make it easier to handle time-related calculations within smart contracts. They provide a clear and standardized way to express durations and timestamps, making code more readable and less error-prone when dealing with time-sensitive operations.
If it looks a bit vague, don't worry we will see this in action a bit later in our code.
calldata:
Similar to memory, calldata
refers to a special area of memory that is used to store the input data when a function is called. It's a read-only area, meaning that data stored in "calldata" cannot be modified by the function being executed.
Imagine you have a function, and someone wants to call that function with some information, like their name. When they make the call, their name is temporarily written on a piece of paper (the "calldata") that the function can read. However, the function can't change what's written on that paper; it can only read and use the information.
In the context of Solidity, calldata
is typically used for function arguments with external visibility. It's an efficient way to pass data to functions without allowing them to modify the original data. This is important because it helps ensure that the function's behavior doesn't inadvertently change the caller's data.
View Functions:
Think of a view
function like a pair of "read-only glasses" for your smart contract. When you call a view
function, it allows you to see or "read" information from the blockchain, such as the current state of a variable or the result of a calculation. However, it doesn't allow you to make any changes to the blockchain. It's like looking at a book in a library; you can read the book's content, but you can't write or modify it.
So, in summary, a view
function in Solidity is used to retrieve information from the blockchain without altering it, making it a safe and efficient way to access data stored on the Ethereum network.
uint[] memory:
Let's break down what uint[] memory
means in simple terms:
uint[]
: This part indicates that you're working with an array of unsigned integers. An array is a collection of values, and in this case, those values are integers that can't be negative (unsigned).memory
: This indicates where the array is stored. When you usememory
, it means the array is created in temporary memory for the duration of a function call. This memory is efficient for temporary data storage but gets wiped out when the function execution ends. It's like using a scratch pad to perform calculations; you can write on it during your calculations, but the content disappears when you're done.
So, uint[] memory
signifies that you're declaring an array of unsigned integers that will be stored in temporary memory for some operations within a function. It's commonly used for temporary storage of data that doesn't need to be stored permanently on the blockchain.
For Loops:
A for loop is like a repetitive task that a computer program performs. It's like telling the computer to do something again and again until a certain condition is met. Imagine you have a stack of cards, and you want to go through each card one by one. The for loop helps you do this by giving you a way to repeat a set of instructions for each card in the stack.
For example, you can use a for loop in Solidity to go through a list of numbers or items and perform the same action on each one, like adding them up or checking if they meet a condition. It's a way to make your program do repetitive tasks efficiently.
Unlike the other concepts, I'd like to dig a bit deeper here as I understand that for loops are a huge part of coding (or so I've been told).
In general, for loops work similarly across many programming languages. They allow you to repeat a block of code a specified number of times or iterate over elements in an array or collection. Here's an explanation of for loops in Solidity with examples:
Basic Structure: The basic structure of a for loop in Solidity looks like this:
for (initialization; condition; increment/decrement) { // Code to be executed in each iteration }
initialization
: This part is used to initialize a loop control variable. It typically defines a variable and assigns an initial value.condition
: This is a Boolean expression that is checked before each iteration. If the condition evaluates totrue
, the loop continues; otherwise, it exits.increment/decrement
: This part is used to modify the loop control variable after each iteration.
Iteration: The loop runs repeatedly as long as the
condition
remainstrue
. Inside the loop, you can perform various actions, calculations, or operations.Example: Here's an example of a for loop that counts from 1 to 10 and prints the numbers:
for (uint i = 1; i <= 10; i++) { // Print the current value of 'i' emit NumberPrinted(i); }
In this example,
uint i
is initialized to 1, and the loop continues as long asi
is less than or equal to 10. After each iteration,i
is incremented by 1.Use Cases: For loops are commonly used in Solidity for various tasks, such as iterating over elements in an array, performing calculations, or executing a series of actions a specific number of times.
It's advised to be cautious with loops in smart contracts, especially when dealing with large data sets, as excessive gas consumption can lead to expensive transactions. Always consider gas costs and efficiency when using loops in your contracts.
Changes to Previous Files/Code Breakdown
zombiefactory.sol:
imported ownable.sol
inherited from ownable.sol
added two new properties to the struct
declared
uint cooldownTime
under eventsupdate zombies.push in
_createZombie
function
Let's break down the provided code step by step. You'll be able to identify the changes mentioned above:
pragma solidity >=0.5.0 <0.6.0;
import "./ownable.sol";
contract ZombieFactory is Ownable {
- This code starts by specifying the Solidity version (
>=0.5.0 <0.6.0
) and importing an external contract calledOwnable.sol
. TheZombieFactory
contract inherits from theOwnable
contract, which means it inherits all the functions and modifiers defined inOwnable
for managing ownership and access control.
event NewZombie(uint zombieId, string name, uint dna);
- This line declares an event called
NewZombie
. Events are used to log important contract actions that can be observed by external applications. In this case, it logs the creation of a new zombie with its ID, name, and DNA.
uint dnaDigits = 16;
uint dnaModulus = 10 ** dnaDigits;
uint cooldownTime = 1 days;
Here, several variables are defined:
dnaDigits
is set to 16, indicating the number of digits in the DNA.dnaModulus
calculates the maximum DNA value using the number of digits.cooldownTime
is set to 1 day, which represents a cooldown period for zombie feeding times.
struct Zombie {
string name;
uint dna;
uint32 level;
uint32 readyTime;
}
- This defines a
Zombie
struct with four attributes:name
,dna
,level
, andreadyTime
. Each zombie has a name, DNA, level, and a timestamp indicating when it's ready for action.
Zombie[] public zombies;
- An array named
zombies
is declared to store instances of theZombie
struct. It's declared aspublic
, so it can be accessed from outside the contract.
mapping (uint => address) public zombieToOwner;
mapping (address => uint) ownerZombieCount;
Two mappings are declared:
zombieToOwner
: Maps a zombie's ID to its owner's address.ownerZombieCount
: Maps an owner's address to the count of zombies they own.
function _createZombie(string memory _name, uint _dna) internal {
uint id = zombies.push(Zombie(_name, _dna, 1, uint32(now + cooldownTime))) - 1;
zombieToOwner[id] = msg.sender;
ownerZombieCount[msg.sender]++;
emit NewZombie(id, _name, _dna);
}
_createZombie
is an internal function used to create a new zombie. It takes a name and DNA as parameters, creates a newZombie
instance, adds it to thezombies
array, updates mappings and emits aNewZombie
event.
function _generateRandomDna(string memory _str) private view returns (uint) {
uint rand = uint(keccak256(abi.encodePacked(_str)));
return rand % dnaModulus;
}
_generateRandomDna
is a private function that generates a random DNA based on an input string_str
. It uses cryptographic hashing to create randomness.
function createRandomZombie(string memory _name) public {
require(ownerZombieCount[msg.sender] == 0);
uint randDna = _generateRandomDna(_name);
randDna = randDna - randDna % 100;
_createZombie(_name, randDna);
}
createRandomZombie
is a public function that allows users to create a new zombie with a random DNA. It checks if the caller doesn't already own a zombie (usingrequire
), generates random DNA, and calls_createZombie
to create the new zombie.
In summary, this code defines a contract called ZombieFactory
that inherits ownership functionality from Ownable
. It allows users to create zombies with random DNA while enforcing ownership and access control. Zombies are represented as structs and stored in an array, and their ownership is tracked using mappings.
zombiefeeding.sol:
updated KittyInterface kittyContract to not equal to anything (deleted = ckAddress)
set new
function setKittyContractAddress
this function is later set to
onlyOwner
added
_triggerCooldown
functionadded
_isReady
functionmade
feedAndMultiply
function internal
added check for
_isready
call
_triggerCooldown
after_createZombie
Let's break down the provided code step by step:
import "./zombiefactory.sol";
- This line imports another Solidity file named
zombiefactory.sol
. This means the code in the current contract can use or extend the functionality defined inzombiefactory.sol
.
contract KittyInterface {
function getKitty(uint256 _id) external view returns (
bool isGestating,
bool isReady,
uint256 cooldownIndex,
uint256 nextActionAt,
uint256 siringWithId,
uint256 birthTime,
uint256 matronId,
uint256 sireId,
uint256 generation,
uint256 genes
);
}
- This part declares an interface called
KittyInterface
. An interface defines the structure of functions that another contract must implement to be considered compatible with this interface. In this case, the interface describes a function calledgetKitty
, which takes an_id
as input and returns a set of attributes related to a "kitty."
contract ZombieFeeding is ZombieFactory {
- This line declares a new contract called
ZombieFeeding
, which inherits from theZombieFactory
contract. This meansZombieFeeding
inherits all the functions and state variables defined inZombieFactory
.
KittyInterface kittyContract;
- This line declares a state variable
kittyContract
of typeKittyInterface
. This variable will be used to interact with another contract that conforms to theKittyInterface
interface.
function setKittyContractAddress(address _address) external onlyOwner {
kittyContract = KittyInterface(_address);
}
- This function,
setKittyContractAddress
, allows the owner of the contract (as defined by theonlyOwner
modifier) to set the address of thekittyContract
. It takes an_address
parameter and assigns it tokittyContract
. This function enables the contract to interact with thekittyContract
.
function _triggerCooldown(Zombie storage _zombie) internal {
_zombie.readyTime = uint32(now + cooldownTime);
}
- This is an internal function,
_triggerCooldown
, which takes aZombie
storage reference as a parameter. It updates thereadyTime
attribute of the given zombie to a timestamp in the future, indicating a cooldown period. This function is used to manage the cooldown of zombies.
function feedAndMultiply(uint _zombieId, uint _targetDna, string memory _species) public {
require(msg.sender == zombieToOwner[_zombieId]);
Zombie storage myZombie = zombies[_zombieId];
_targetDna = _targetDna % dnaModulus;
uint newDna = (myZombie.dna + _targetDna) / 2;
if (keccak256(abi.encodePacked(_species)) == keccak256(abi.encodePacked("kitty"))) {
newDna = newDna - newDna % 100 + 99;
}
_createZombie("NoName", newDna);
_triggerCooldown(myZombie);
}
This function,
feedAndMultiply
, is used to feed a zombie with a specific_zombieId
. It requires that the sender of the transaction is the owner of the zombie (as checked byrequire
).It calculates a new DNA for the zombie based on the provided
_targetDna
.If the
_species
parameter is equal to "kitty," it modifies the new DNA.It then calls
_createZombie
to create a new zombie with the modified DNA and triggers a cooldown for the zombie.
function feedOnKitty(uint _zombieId, uint _kittyId) public {
uint kittyDna;
(,,,,,,,,,kittyDna) = kittyContract.getKitty(_kittyId);
feedAndMultiply(_zombieId, kittyDna, "kitty");
}
This function,
feedOnKitty
, allows a zombie (specified by_zombieId
) to feed on a "kitty" (specified by_kittyId
). It retrieves the kitty's DNA by calling thegetKitty
function of thekittyContract
.Then, it calls the
feedAndMultiply
function to combine the zombie's DNA with the kitty's DNA and create a new zombie.
In summary, this code defines a contract called ZombieFeeding
that inherits functionality from ZombieFactory
. It introduces interactions with another contract (kittyContract
) through an interface (KittyInterface
) and allows zombies to be fed and reproduce based on their DNA. The onlyOwner
modifier is used to restrict access to certain functions to the contract owner.
New Files: Code Breakdown
ownable.sol:
This Solidity code defines a contract called Ownable
, which is a common pattern for managing ownership and access control in Ethereum smart contracts. Let's break down each part of the code:
contract Ownable {
address private _owner;
event OwnershipTransferred(
address indexed previousOwner,
address indexed newOwner
);
The
Ownable
contract declares a state variable_owner
, which is a private address. This variable will store the address of the owner of the contract.An
OwnershipTransferred
event is declared, which is emitted whenever ownership of the contract is transferred from one address to another. This event can be used to log ownership changes.
constructor() internal {
_owner = msg.sender;
emit OwnershipTransferred(address(0), _owner);
}
- The constructor function is executed when the contract is created. It sets the initial owner of the contract to the address that deploys (creates) the contract. This is done by assigning
msg.sender
(the sender of the transaction that created the contract) to the_owner
variable. AnOwnershipTransferred
event is emitted to log this initial ownership assignment.
function owner() public view returns (address) {
return _owner;
}
- The
owner
function is a public view function that allows anyone to query and get the address of the current owner of the contract. It does not modify the contract's state.
modifier onlyOwner() {
require(isOwner());
_;
}
- The
onlyOwner
modifier is a special kind of function modifier. It is used to restrict access to certain functions to only the owner of the contract. To use this modifier, the condition inside it (require(isOwner())
) must be met before the function can be executed. If the condition fails, the function call is reverted, and the transaction fails.
function isOwner() public view returns (bool) {
return msg.sender == _owner;
}
- The
isOwner
function is a public view function that checks whether the sender of the current transaction (msg.sender
) is the owner of the contract. It returnstrue
if the sender is the owner, andfalse
otherwise.
function renounceOwnership() public onlyOwner {
emit OwnershipTransferred(_owner, address(0));
_owner = address(0);
}
- The
renounceOwnership
function allows the current owner of the contract to voluntarily relinquish ownership. When called, it emits anOwnershipTransferred
event to indicate that ownership is being transferred from the current owner (_owner
) to address(0), effectively making the contract ownerless.
function transferOwnership(address newOwner) public onlyOwner {
_transferOwnership(newOwner);
}
- The
transferOwnership
function allows the current owner to transfer ownership of the contract to a new address (newOwner
). It calls the internal_transferOwnership
function to perform the ownership transfer.
function _transferOwnership(address newOwner) internal {
require(newOwner != address(0));
emit OwnershipTransferred(_owner, newOwner);
_owner = newOwner;
}
- The
_transferOwnership
function is an internal function that handles the actual transfer of ownership. It requires that the new owner's address is not the zero address (address(0)
), and if the condition is met, it emits anOwnershipTransferred
event to log the ownership change and updates the_owner
variable with the new owner's address.
In summary, this Ownable
contract provides a basic framework for ownership management and access control in Ethereum smart contracts. It allows an owner to transfer ownership, renounce ownership, and restrict access to certain functions using the onlyOwner
modifier.
zombiehelper.sol:
Let's break down our last code step by step:
import "./zombiefeeding.sol";
- This line imports another Solidity file named
zombiefeeding.sol
. It means that the code in the current contract can use or extend the functionality defined inzombiefeeding.sol
.
contract ZombieHelper is ZombieFeeding {
- This line declares a new contract called
ZombieHelper
, which is inheriting from theZombieFeeding
contract. It means thatZombieHelper
inherits all the functions and state variables defined inZombieFeeding
.
modifier aboveLevel(uint _level, uint _zombieId) {
require(zombies[_zombieId].level >= _level);
_;
}
- This is a custom function modifier named
aboveLevel
. Modifiers are used to add conditions that must be met before a function is executed. In this case,aboveLevel
checks if the level of a zombie specified by_zombieId
is greater than or equal to_level
. If this condition is met, the underscore (_
) indicates where the modified function's code will be executed.
function changeName(uint _zombieId, string _newName) external aboveLevel(2, _zombieId) {
require(msg.sender == zombieToOwner[_zombieId]);
zombies[_zombieId].name = _newName;
}
This function,
changeName
, allows the owner of a zombie (specified by_zombieId
) to change the zombie's name to_newName
. To invoke this function, the caller must meet two conditions:The caller (
msg.sender
) must be the owner of the specified zombie (require(msg.sender == zombieToOwner[_zombieId])
).The zombie's level must be greater than or equal to 2 (
aboveLevel(2, _zombieId)
).
function changeDna(uint _zombieId, uint _newDna) external aboveLevel(20, _zombieId) {
require(msg.sender == zombieToOwner[_zombieId]);
zombies[_zombieId].dna = _newDna;
}
This function,
changeDna
, allows the owner of a zombie (specified by_zombieId
) to change the zombie's DNA to_newDna
. Similar tochangeName
, two conditions must be met:The caller must be the owner of the specified zombie.
The zombie's level must be greater than or equal to 20.
function getZombiesByOwner(address _owner) external view returns(uint[]) {
uint[] memory result = new uint[](ownerZombieCount[_owner]);
uint counter = 0;
for (uint i = 0; i < zombies.length; i++) {
if (zombieToOwner[i] == _owner) {
result[counter] = i;
counter++;
}
}
return result;
}
- This function,
getZombiesByOwner
, is a view function that allows anyone to retrieve an array of zombie IDs owned by a specific address (_owner
). It creates a dynamic arrayresult
with a length equal to the number of zombies owned by the specified_owner
. It then iterates through thezombies
array and checks if each zombie is owned by_owner
. If so, it adds the zombie's ID to theresult
array. Finally, it returns theresult
array containing the IDs of zombies owned by_owner
.
In summary, this code defines a contract called ZombieHelper
that inherits from ZombieFeeding
. It introduces custom function modifiers (aboveLevel
) to restrict access to certain functions based on the zombie's level. It also provides functions to change a zombie's name and DNA, as well as a function to retrieve zombie IDs owned by a specific address.
Alright, it's getting kinda heavy. We are now 54% complete in our Beginner to Intermediate Smart Contracts course. I've learned a lot about the structures and functions so far. I think I'll review this page again before moving on to lesson 4.