March 27, 202612 min read

Solidity and Smart Contracts: Blockchain Development from First Principles

Learn Solidity from scratch — smart contract basics, data types, functions, events, deploying to testnet, ERC-20 tokens, security pitfalls, and testing with Hardhat.

solidity blockchain smart-contracts ethereum web3
Ad 336x280

Smart contracts are one of those ideas that sound overly hyped until you understand what they actually do. Strip away the crypto speculation and the buzzwords, and you're left with a genuinely interesting concept: programs that run on a decentralized network, can hold and transfer money, and execute exactly as written with no one able to alter them after deployment.

That's it. No magic. No revolution in every industry. Just deterministic programs on a blockchain. But that property — code that executes as written, without any party being able to change the rules mid-game — turns out to be useful for specific categories of problems.

Solidity is the language you use to write these programs on Ethereum (and EVM-compatible chains like Polygon, Arbitrum, and BSC). Let's learn it from first principles.

What Smart Contracts Actually Are

A smart contract is a program stored on a blockchain. It has:

  • An address — like a user account, but controlled by code instead of a private key
  • State — variables stored permanently on the blockchain
  • Functions — code that can read or modify that state
  • A balance — it can hold and send cryptocurrency
When you deploy a contract, its bytecode is stored on the blockchain permanently. When someone calls a function on that contract, every node in the network executes the same code and agrees on the result. This is what makes it "trustless" — you don't need to trust any single party because thousands of nodes verify every execution.

The trade-off: every operation costs gas (a fee paid in ETH), storage is extremely expensive, and once deployed, you can't change the code. Bugs are forever.

Solidity Basics

Solidity looks like a cross between JavaScript and C++. If you know either language, you'll recognize the syntax.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract HelloWorld {
// State variable — stored on the blockchain
string public greeting;

// Constructor — runs once during deployment
constructor(string memory _greeting) {
greeting = _greeting;
}

// Function to update the greeting
function setGreeting(string memory _newGreeting) public {
greeting = _newGreeting;
}

// View function — reads state but doesn't modify it (no gas cost when called externally)
function getGreeting() public view returns (string memory) {
return greeting;
}
}

Key observations:

  • pragma solidity ^0.8.20 specifies the compiler version. The caret means "0.8.20 or higher within 0.8.x."
  • public on a state variable automatically generates a getter function.
  • memory tells Solidity where to store the data temporarily (more on this below).
  • view means the function doesn't modify state and costs no gas when called directly.

Data Types

Solidity is statically typed with some types you'll recognize and some that are blockchain-specific.

contract DataTypes {
    // Value types
    bool public isActive = true;
    uint256 public count = 42;          // unsigned 256-bit integer (most common)
    int256 public temperature = -10;     // signed integer
    address public owner;                // 20-byte Ethereum address
    bytes32 public hash;                 // fixed-size byte array

// Reference types
string public name = "MyContract";
uint256[] public numbers; // dynamic array
mapping(address => uint256) public balances; // key-value store

// Enums
enum Status { Pending, Active, Closed }
Status public status = Status.Pending;

// Structs
struct User {
string name;
uint256 balance;
bool isRegistered;
}
mapping(address => User) public users;
}

The address type is unique to Solidity. It represents an Ethereum address and has built-in methods like .balance and .transfer(). The mapping type is a hash table that exists only in storage — you can't iterate over it or get its length.

Data Locations: Storage, Memory, Calldata

Solidity has three data locations, and understanding them is essential:

  • storage — permanent blockchain storage. State variables are always in storage. Expensive to write.
  • memory — temporary, exists only during function execution. Cheaper than storage.
  • calldata — read-only, temporary, used for function parameters of external functions. Cheapest.
function processData(string calldata input) external pure returns (string memory) {
    // input is calldata — read-only, cheapest
    // return value is memory — temporary
    return string(abi.encodePacked("Processed: ", input));
}

Functions and Visibility

contract FunctionExamples {
    uint256 private count;

// public — callable from anywhere
function increment() public {
count += 1;
}

// external — only callable from outside the contract (slightly cheaper than public)
function getCount() external view returns (uint256) {
return count;
}

// internal — only callable from this contract and derived contracts
function _doubleCount() internal {
count *= 2;
}

// private — only callable from this contract
function _resetCount() private {
count = 0;
}

// pure — doesn't read or modify state
function add(uint256 a, uint256 b) public pure returns (uint256) {
return a + b;
}

// payable — can receive ETH
function deposit() public payable {
// msg.value contains the ETH sent
require(msg.value > 0, "Must send some ETH");
}
}

The distinction between view, pure, and state-modifying functions matters for gas costs. view and pure functions cost zero gas when called externally (they just read from the local node). They only cost gas when called internally by a state-modifying function.

Modifiers: Reusable Access Control

Modifiers are a way to add preconditions to functions without repeating code.

contract Ownable {
    address public owner;

constructor() {
owner = msg.sender; // deployer becomes owner
}

modifier onlyOwner() {
require(msg.sender == owner, "Not the owner");
_; // placeholder for the function body
}

modifier costs(uint256 price) {
require(msg.value >= price, "Insufficient payment");
_;
}

function withdraw() public onlyOwner {
// Only the owner can call this
payable(owner).transfer(address(this).balance);
}

function premiumAction() public payable costs(0.01 ether) {
// Must send at least 0.01 ETH
// function logic here
}
}

The _ inside the modifier represents where the function's actual code gets inserted. Think of it as "run the precondition, then run the function body."

Events: The Blockchain's Logging System

Events are how contracts communicate with the outside world. They're stored in transaction logs (much cheaper than storage) and can be listened to by off-chain applications.

contract TokenEvents {
    event Transfer(address indexed from, address indexed to, uint256 amount);
    event Approval(address indexed owner, address indexed spender, uint256 amount);

mapping(address => uint256) public balances;

function transfer(address to, uint256 amount) public {
require(balances[msg.sender] >= amount, "Insufficient balance");

balances[msg.sender] -= amount;
balances[to] += amount;

emit Transfer(msg.sender, to, amount); // log the event
}
}

The indexed keyword makes the parameter searchable in logs. You can have up to three indexed parameters per event. Front-end applications use these events to update the UI in real-time — when a transfer happens, the app sees the event and refreshes the balance display.

Remix IDE: Your First Deployment

The fastest way to start writing Solidity is Remix, a browser-based IDE at remix.ethereum.org. No installation, no setup.

  1. Go to Remix and create a new file (e.g., SimpleStorage.sol)
  2. Write your contract
  3. Compile it (Solidity Compiler tab)
  4. Deploy it (Deploy & Run tab, using "Injected Provider" for MetaMask or "Remix VM" for a local simulation)
For deploying to a real testnet (Sepolia), you'll need:
  1. MetaMask browser extension with a Sepolia account
  2. Test ETH from a Sepolia faucet (several are free)
  3. Connect MetaMask to Remix via "Injected Provider"
  4. Deploy — MetaMask will ask you to confirm the transaction
The contract now exists on the Sepolia testnet. Anyone can interact with it using the contract address and ABI.

ERC-20 Token: A Real Example

The most common smart contract in the wild is the ERC-20 token. It's a standard interface that defines how a fungible token behaves — transfer, approve, allowance, balance tracking.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract SimpleToken {
string public name;
string public symbol;
uint8 public decimals = 18;
uint256 public totalSupply;

mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;

event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);

constructor(string memory _name, string memory _symbol, uint256 _initialSupply) {
name = _name;
symbol = _symbol;
totalSupply = _initialSupply 10 * decimals;
balanceOf[msg.sender] = totalSupply;
emit Transfer(address(0), msg.sender, totalSupply);
}

function transfer(address to, uint256 amount) public returns (bool) {
require(to != address(0), "Transfer to zero address");
require(balanceOf[msg.sender] >= amount, "Insufficient balance");

balanceOf[msg.sender] -= amount;
balanceOf[to] += amount;
emit Transfer(msg.sender, to, amount);
return true;
}

function approve(address spender, uint256 amount) public returns (bool) {
allowance[msg.sender][spender] = amount;
emit Approval(msg.sender, spender, amount);
return true;
}

function transferFrom(address from, address to, uint256 amount) public returns (bool) {
require(balanceOf[from] >= amount, "Insufficient balance");
require(allowance[from][msg.sender] >= amount, "Insufficient allowance");

balanceOf[from] -= amount;
balanceOf[to] += amount;
allowance[from][msg.sender] -= amount;
emit Transfer(from, to, amount);
return true;
}
}

This is a simplified but functional ERC-20 token. In production, you'd use OpenZeppelin's audited implementation, but writing it from scratch teaches you how every piece works.

The approve / transferFrom pattern allows third-party contracts (like DEXes) to transfer tokens on your behalf. You first approve a spending limit, then the third-party contract calls transferFrom.

Security: The Reentrancy Attack

Smart contract security is non-negotiable because bugs can't be patched and exploits drain real money. The most famous vulnerability is the reentrancy attack, which caused the 2016 DAO hack ($60 million lost).

The vulnerable pattern:

// VULNERABLE — DO NOT USE
contract VulnerableBank {
    mapping(address => uint256) public balances;

function withdraw() public {
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance");

// BUG: sends ETH before updating state
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");

balances[msg.sender] = 0; // too late — attacker re-entered before this line
}
}

The attacker deploys a contract with a receive() function that calls withdraw() again. Since the balance hasn't been zeroed yet, the second withdrawal succeeds, and the third, and the fourth — draining the contract.

The fix is the "checks-effects-interactions" pattern:

// SAFE — checks, effects, then interactions
contract SafeBank {
    mapping(address => uint256) public balances;

function withdraw() public {
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance");

// Update state BEFORE sending ETH
balances[msg.sender] = 0;

// Now send — even if attacker re-enters, balance is already 0
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
}

Or use OpenZeppelin's ReentrancyGuard:

import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

contract SafeBank is ReentrancyGuard {
function withdraw() public nonReentrant {
// ...
}
}

Other common vulnerabilities to study: integer overflow (mitigated by Solidity 0.8+ default checked math), front-running, oracle manipulation, and access control mistakes.

Testing with Hardhat

Hardhat is the standard development environment for Solidity. It gives you a local blockchain, a testing framework, and deployment scripts.

# Set up a Hardhat project
mkdir my-contract && cd my-contract
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npx hardhat init

A test file in Hardhat (using ethers.js and Mocha):

const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("SimpleToken", function () {
let token;
let owner;
let addr1;
let addr2;

beforeEach(async function () {
[owner, addr1, addr2] = await ethers.getSigners();
const Token = await ethers.getContractFactory("SimpleToken");
token = await Token.deploy("TestToken", "TT", 1000000);
await token.waitForDeployment();
});

it("should assign total supply to the deployer", async function () {
const ownerBalance = await token.balanceOf(owner.address);
expect(await token.totalSupply()).to.equal(ownerBalance);
});

it("should transfer tokens between accounts", async function () {
const amount = ethers.parseEther("100");
await token.transfer(addr1.address, amount);

expect(await token.balanceOf(addr1.address)).to.equal(amount);
});

it("should fail if sender doesn't have enough tokens", async function () {
const amount = ethers.parseEther("1");
await expect(
token.connect(addr1).transfer(owner.address, amount)
).to.be.revertedWith("Insufficient balance");
});

it("should handle approve and transferFrom", async function () {
const amount = ethers.parseEther("500");
await token.approve(addr1.address, amount);
await token.connect(addr1).transferFrom(owner.address, addr2.address, amount);

expect(await token.balanceOf(addr2.address)).to.equal(amount);
});
});

Run tests:

npx hardhat test

Hardhat's local network gives you instant feedback — no waiting for block confirmations, no spending real ETH. You can also fork mainnet to test against real contract state.

Deploying to a Testnet

Create a deployment script:

// scripts/deploy.js
const hre = require("hardhat");

async function main() {
const Token = await hre.ethers.getContractFactory("SimpleToken");
const token = await Token.deploy("MyToken", "MTK", 1000000);
await token.waitForDeployment();

console.log("Token deployed to:", await token.getAddress());
}

main().catch((error) => {
console.error(error);
process.exitCode = 1;
});

Configure Hardhat for Sepolia:

// hardhat.config.js
require("@nomicfoundation/hardhat-toolbox");

module.exports = {
solidity: "0.8.20",
networks: {
sepolia: {
url: https://eth-sepolia.g.alchemy.com/v2/${process.env.ALCHEMY_KEY},
accounts: [process.env.PRIVATE_KEY],
},
},
};

Deploy:

npx hardhat run scripts/deploy.js --network sepolia

Common Mistakes in Solidity Development

Not testing enough. Every function path, every edge case, every revert condition. Smart contracts handle money. Treat testing like you're writing code for a bank vault, because you literally are. Storing data on-chain that doesn't need to be there. Storage is the most expensive operation in Solidity. Store hashes, not full data. Use events for data that only needs to be read off-chain. Ignoring gas optimization. Pack your struct variables (smaller types adjacent), use calldata instead of memory for read-only parameters, cache storage reads in local variables, and use events instead of storage for historical data. Not using established libraries. OpenZeppelin provides audited, battle-tested implementations of ERC-20, ERC-721, access control, and more. Unless you're building something novel, use their contracts as a base. Forgetting upgradeability. Once deployed, contracts can't be changed. If you need upgradeability, design for proxy patterns from the start (UUPS or Transparent Proxy).

What's Next

Solidity and smart contract development is a field where the stakes are high and the tooling is improving rapidly. Start with Remix for quick experiments, move to Hardhat for serious projects, and always write comprehensive tests. Study the OpenZeppelin contracts — reading their code is one of the best ways to learn Solidity patterns.

The ecosystem moves fast, but the fundamentals — state management, access control, security patterns — remain constant. Master those, and you'll be able to adapt to whatever comes next in the blockchain space.

For more programming tutorials and language guides, check out CodeUp.

Ad 728x90