My first Solidity Smart Contract
After setting up our environment we are now ready to write our first smart contract using Solidity.
In this article we will be writing a basic smart contract, some tests as well as a deployment script to push it into the blockchain. As a bonus, we will configure Hardhat to give us cost estimations from our code. Exciting!
Index
- Project Setup
- Smart Contract: Person
- Testing the contract
- Gas reporter
- Deploying the Contract
- Storage Layout
- Smart Contract: Better Person
- Better?
- Conclusion
Project Setup
If you followed the previous article you know how to create a new project using hardhat. Go ahead and create it; you can delete the contents of contracts
, scripts
as well as test
, we will be creating new files for each category.
Smart Contract: Person
Imagine for a moment we want to represent a "person" as a smart contract. For simplicity sake a person will be limited to their birth date. Create a contract and call it PersonV1.sol
(there will be V2 later).
We start off by the smart contract license. A full list of licenses can be found here. We will go with MIT because it is the second easiest to understand after WTFPL
// SPDX-License-Identifier: MIT
Right after, we need to select the solidity version we want our contract to be compiled with. We will go with latest.
pragma solidity ^0.8.17;
A contract is defined by the keyword contract
followed by a meaningful name. If you are familiar with object-oriented programming languages, it is similar to defining a class.
contract PersonV1 {
uint256 public year;
uint256 public month;
uint256 public day;
constructor(
uint256 _year,
uint256 _month,
uint256 _day
) {
year = _year;
month = _month;
day = _day;
}
}
Our person is defined by the year, the month and the day she was born. Obviously, this is a rather over simplified example as nothing other than the address of this contract makes this person unique in the eyes of the law. Moving on...
The birth date of this person is set at the constructor level, meaning that once set it cannot be changed; we will be adding a method exactly for this purpose to show the cost of altering data on the blockchain. We willingly ignore bad input for the sake of keeping it simple.
contract PersonV1 {
// ...
function setAge(
uint256 _year,
uint256 _month,
uint256 _day
) public {
year = _year;
month = _month;
day = _day;
}
}
With this we are able to change our smart contract and measure the cost of such operations. Just like a regular program each operation has a cost, whereas on a CPU the cost is payed with time and energy, on the blockchain the cost is payed with money. We will later try to optimize our contract and understand how we can better write smart contracts to minimize the monetary cost.
You can now compile your contract to make sure the syntax is correct.
npx hardhat compile
Testing the contract
Testing a smart contract is the single most important task a smart contract developer will be doing. A flawed contract is a contract that is open for attacks, potentially putting at risk the Ether you have placed into it.
Thankfully our smart contract does not receive any ETH, we don't have to worry about breaches. Regardless, it is a good practice to test every single functionality of your contract; with basic cases as well a edge cases to make sure it can handle all situations.
import { expect } from "chai";
import { BigNumberish } from "ethers";
import { ethers } from "hardhat";
describe("PersonV1", function () {
const year: BigNumberish = 1987
const month: BigNumberish = 4
const day: BigNumberish = 17
async function deployPerson() {
const Person = await ethers.getContractFactory("PersonV1")
const contract = await Person.deploy(year, month, day)
return contract
}
describe("Deployment", function () {
it("should set the right age", async function () {
const contract = await deployPerson()
//
const y = await contract.year()
expect(y).equal(year)
//
const m = await contract.month()
expect(m).equal(month)
//
const d = await contract.day()
expect(d).equal(day)
})
it("should update the age", async function () {
const contract = await deployPerson()
//
await contract.setAge(year + 1, month + 1, day + 1)
//
const y = await contract.year()
expect(y).equal(year + 1)
//
const m = await contract.month()
expect(m).equal(month + 1)
//
const d = await contract.day()
expect(d).equal(day + 1)
})
})
})
In our first test (defined by the first it
function), we make sure that the values passed when constructing our smart contract are correctly saved. On our second test we call the age setter method and verify the new values have been saved into the blockchain.
As you can see, any interaction with the contract requires an await
keyword. This is because interacting with the blockchain is not an immediate operation and therefore needs to wait for the value before proceeding.
Run the tests to make sure everything gets the green light.
npx hardhat test
Gas reporter
Hardhat has a feature that, after setting it up and connecting all the pieces, allows our tests to report gas usage based on data taken from CoinMarketCap. It remains an estimate and the actual price will vary depending on a number of factors, but it is useful enough to start optimizing our smart contracts.
The gas reporter package does not come with the default project setup. Run the following command to add it:
pnpm add -D hardhat-gas-reporter
Now in our hardhat.config.ts
we can import the package to expose new configuration options:
import "hardhat-gas-reporter";
Adding this import will add a new gasReporter
property to HardhatUserConfig
. This new property has multiple entries, the ones we need right now are used to set the currency and to set the API key to connect to CoinMarketCap.
To get an API key, head to CoinMarketCap, after creating an account and you will find your key in the Overview
page. They offer multiple plans, from free to enterprise, that come with more or less limitations. For our needs the free tier is more than enough.
const config: HardhatUserConfig = {
//...
gasReporter: {
currency: "USD",
enabled: true,
coinmarketcap: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
}
};
We are all set up! Running our tests will from now on include a summary of our gas usage:
pnpm hardhat test
As it is right now, it would cost us roughly $12 to add a new person to the blockchain... ouch! Another $1.50 to change their birth data.
Deploying the Contract
Create a deploy.ts
file under scripts
. This will be our deploy script that we will use to actually deploy our smart contract into the local blockchain. It is in this stage that we can pass custom values to the constructor of our smart contract or apply any other logic you may require.
import { ethers } from "hardhat";
async function printMemoryLayout(address: string, count: number) {
console.log("### Memory Layout")
for (let idx = 0; idx < count; ++idx) {
let slot = await ethers.provider.getStorageAt(address, idx)
console.log(`Slot\t#${idx}\t${slot}`);
}
}
async function deployPersonV1() {
const PersonV1 = await ethers.getContractFactory("PersonV1")
const contract = await PersonV1.deploy(1987, 4, 17)
await contract.deployed()
console.log("PersonV1 deployed!")
printMemoryLayout(contract.address, 3)
}
async function main() {
await deployPersonV1()
}
main().catch((error) => {
console.error(error)
process.exitCode = 1
});
In the deployPersonV1
function we start off by getting the PersonV1
contract factory and using it to deploy a new instance of the smart contract by passing it three inputs as expect by PersonV1
's constructor. Notice that, again, all methods that interact with the blockchain require an await
.
We also have a printMemoryLayout
function that we call after deploying the PersonV1
contract. This method will be useful to check our contract storage layout.
Time to publish our first smart contract!
npx hardhat run scripts/deploy.ts --network localhost
Storage Layout
In the EVM, the maximum size a type can have is 256 bits. It stores values in slots
which also happen to have a size of 256 bits. This means that in a slot
you can fit data with a combined size of at most 256 bits. For example, in a slot
you can fit:
- 1
int256
/uint256
- 2
int128
/uint128
- 4
int64
/uint64
- ...
- 32
int8
/uint
8
Or it can be combined: 1 int128
/uint128
and 2 int64
/uint64
because 1x128 (bits) + 2x64 (bits) = 256 (bits)
When compiling your smart contract, the compiler will attempt to pack your data into the most optimized way in order to use the least slots possible.
After deploying the contract, the following layout was printed.
### Memory Layout
Slot #0 0x00000000000000000000000000000000000000000000000000000000000007c3
Slot #1 0x0000000000000000000000000000000000000000000000000000000000000004
Slot #2 0x0000000000000000000000000000000000000000000000000000000000000011
These values might not seem relevant until you look at its hexadecimal values. Remember that our contract had only three values:
-
uint256 public year
initialized to1987
or0x07C3
which can be seen at the end of Slot #0 -
uint256 public month
initialized to4
or0x04
which can be seen at the end of Slot #1 -
uint256 public day
initialized to17
or0x11
which can be seen at the end of Slot #2
A 256 bits long number seems a bit of a waste for values that will never be able to make full use of its capacity. Remember that the solidity compiler will try to pack the memory as best as possible but sometimes the developer needs to give it a hand.
Smart Contract: Better Person
In our first contract, all our data was of type uint256
which can have a maximum value of
115792089237316195423570985008687907853269984665640564039457584007913129639935
Personally, I cannot pronounce that number...
More importantly no month has that many days, no year has that many months and the universe will not have that many years (or will it?).
Therefore we can definitely save a lot of space by using appropriate types that fit our usage, namely uint8
(whose maximum value is 255) for the month and day and an uint16 (whose maximum value is 65535) for the year.
Create a new file under contracts
and name it PersonV2.sol
. This will be a duplicate of PersonV1
but instead of using 3 uint256
we will use smaller sized types.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
contract PersonV2 {
uint16 public year;
uint8 public month;
uint8 public day;
constructor(
uint16 _year,
uint8 _month,
uint8 _day
) {
year = _year;
month = _month;
day = _day;
}
function setAge(
uint16 _year,
uint8 _month,
uint8 _day
) public {
year = _year;
month = _month;
day = _day;
}
}
We can use the same tests as for PersonV1
as nothing was changed. Duplicate test/PersonV1.ts
an name it PersonV2.ts
. Make sure you replace any reference of PersonV1
with PersonV2
!
Finally let's also duplicate the deploy code for our PersonV2
contract.
async function deployPersonV2() {
const PersonV2 = await ethers.getContractFactory("PersonV2")
const contract = await PersonV2.deploy(1987, 4, 17)
await contract.deployed()
console.log("PersonV2 deployed!")
printMemoryLayout(contract.address, 3)
}
async function main() {
//...
await deployPersonV2()
}
Better?
After you deploy both smart contracts, you will see a different storage layout for each:
PersonV1 deployed!
### Memory Layout
Slot #0 0x00000000000000000000000000000000000000000000000000000000000007c3
Slot #1 0x0000000000000000000000000000000000000000000000000000000000000004
Slot #2 0x0000000000000000000000000000000000000000000000000000000000000011
PersonV2 deployed!
### Memory Layout
Slot #0 0x00000000000000000000000000000000000000000000000000000000110407c3
Slot #1 0x0000000000000000000000000000000000000000000000000000000000000000
Slot #2 0x0000000000000000000000000000000000000000000000000000000000000000
For PersonV2
, slot #1 and slot #2 are zero-ed out although both contract contain three member. Why would that be?
Unlike previously, this time the solidity compiler was able to do its magic after a bit of our help. You see, the compiler will attempt to pack storage memory into as few slots as possible. Whereas before each of our data was using 256 bits, now we are only using a fraction of that!
You can still see that our data hasn't changed. Slot #0 of PersonV2
has the value of 0x110407c3
, the slots aggregation of PersonV1
.
If we decompose this value:
- Day now fits in 8 bits, i.e. 2 bytes =
0x11
(17) - Month also fits in 8 bits, i.e. 2 bytes =
0x04
(4) - Year fits in 16 bits, i.e. 4 bytes =
0x07c3
(1987)
We have effectively reduced our storage footprint by a lot! Does that help cost wise?
Each time we alter the blockchain, a cost has to be paid. This time we are deploying a contract that will use less storage space on the blockchain. Comparing the cost of deployment for each version of our contract speaks for itself:
- PersonV1 = $12.60
- PersonV2 = $9.34 (25% less!)
Same goes for the method that alters the state of the blockchain:
- PersonV1 = $1.52
- PersonV2 = $1.14 (25% less!)
Conclusion
Creating a smart contract, creating tests to verify all its functionality and creating a deploy script is the foundation of what it means to be a smart contract developer.
On top of that, thanks to some basic profiling tools and common sense, the developer is able make a smart contract more cost effective.
In this article we attempted to cover all of these points to hopefully give you a good starting point on becoming a smart contract developer. The most challending part will be to audit your contracts to avoid any exploits and potentially loosing money.
Remember: only practice makes perfect and stay curious!
Top comments (0)