Smart Contracts and Solidity: My First Web3 Project

The Web3 conversation in 2021 has been impossible to ignore. Every week there is a new story about NFTs selling for millions, DeFi protocols processing billions in transactions, and developers leaving traditional companies to build on Ethereum. I am naturally skeptical of hype, but I also believe in forming opinions from experience rather than speculation.
So I decided to build something. A simple voting contract. Nothing fancy, just enough to understand how smart contracts actually work.
Setting Up Hardhat
Hardhat is the development environment for Ethereum smart contracts. Think of it like the Node.js of the blockchain world. It provides a local blockchain for testing, compilation tools, and deployment scripts.
mkdir voting-contract && cd voting-contract
npm init -y
npm install --save-dev hardhat @nomiclabs/hardhat-ethers ethers
npx hardhat init
The npx hardhat init command creates a project structure with a sample contract, test file, and configuration. The local Hardhat Network gives you a blockchain running on your machine with accounts pre-funded with test ETH. You can deploy and interact with contracts instantly without waiting for real transactions to confirm.
Writing the Contract
Solidity looks like a mix of JavaScript and C++. It is statically typed, has its own set of data types, and runs on the Ethereum Virtual Machine (EVM). The learning curve is real, especially around concepts like gas, storage vs memory, and the immutability of deployed contracts.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Voting {
struct Candidate {
string name;
uint256 voteCount;
}
address public owner;
mapping(address => bool) public hasVoted;
Candidate[] public candidates;
constructor(string[] memory candidateNames) {
owner = msg.sender;
for (uint i = 0; i < candidateNames.length; i++) {
candidates.push(Candidate({
name: candidateNames[i],
voteCount: 0
}));
}
}
function vote(uint256 candidateIndex) public {
require(!hasVoted[msg.sender], "Already voted");
require(candidateIndex < candidates.length, "Invalid candidate");
hasVoted[msg.sender] = true;
candidates[candidateIndex].voteCount++;
}
function getResults() public view returns (Candidate[] memory) {
return candidates;
}
}
A few things stand out if you are coming from JavaScript. The msg.sender is the address of whoever called the function. The require statements act as guards that revert the transaction if the condition is not met. The mapping type is like a hash map that tracks which addresses have voted.
The view keyword on getResults means this function only reads data and does not modify state. View functions are free to call because they do not require a transaction.
Testing with Ethers.js
Testing smart contracts is critical because once a contract is deployed, you cannot change it. Bugs in deployed contracts have cost people real money. Hardhat makes testing straightforward.
const { expect } = require('chai');
const { ethers } = require('hardhat');
describe('Voting', function () {
let voting;
let owner;
let voter1;
let voter2;
beforeEach(async function () {
[owner, voter1, voter2] = await ethers.getSigners();
const Voting = await ethers.getContractFactory('Voting');
voting = await Voting.deploy(['Alice', 'Bob', 'Charlie']);
await voting.deployed();
});
it('should allow a user to vote', async function () {
await voting.connect(voter1).vote(0);
const results = await voting.getResults();
expect(results[0].voteCount).to.equal(1);
});
it('should prevent double voting', async function () {
await voting.connect(voter1).vote(0);
await expect(
voting.connect(voter1).vote(1)
).to.be.revertedWith('Already voted');
});
});
The ethers.getSigners() gives you test accounts. The connect method simulates calling a function from a specific address. You can test the happy path and error cases just like any other test suite.
Deploying to a Testnet
Deploying to a testnet (I used Rinkeby) means your contract runs on a real Ethereum network but with fake ETH. You need a wallet, some test ETH from a faucet, and an RPC endpoint from a service like Alchemy or Infura.
The deployment script is simple:
async function main() {
const Voting = await ethers.getContractFactory('Voting');
const voting = await Voting.deploy(['Alice', 'Bob', 'Charlie']);
await voting.deployed();
console.log('Voting deployed to:', voting.address);
}
main().catch(console.error);
Running npx hardhat run scripts/deploy.js --network rinkeby sends the transaction. It took about 15 seconds for the contract to be confirmed. After that, the contract is live on the testnet and anyone with the address can interact with it.
Gas Costs
This is the part that surprised me the most. Every operation on Ethereum costs gas, and gas costs real money. Deploying my tiny voting contract cost about $30 in gas fees on mainnet at the time I checked. A single vote transaction would cost $3 to $10 depending on network congestion.
For a voting application, paying $5 to cast a vote is absurd. Gas costs are the single biggest barrier to mainstream smart contract adoption in my opinion. Layer 2 solutions and alternative chains are working on this, but on Ethereum mainnet in 2021, simple operations are expensive.
My Honest Take
Building the contract was fun. Solidity is interesting, the tooling around Hardhat is solid, and the immutability model forces you to think carefully about your code. As a learning experience, I would recommend it to any developer.
As a practical development platform, I have mixed feelings. The gas costs make many applications impractical on mainnet. The immutability means you cannot fix bugs in deployed contracts (there are upgrade patterns, but they add complexity). And the promise of "decentralization" often gets undermined by the fact that most dApps rely on centralized infrastructure like Infura for RPC access and centralized exchanges for onboarding.
I am glad I built something and formed my own opinion. Will I build another Web3 project? Maybe. But I will wait for the cost and usability problems to improve before committing to it for a real product.