Nathaniel's blog
Back to posts

Writing Your First Solidity Smart Contract

Nathaniel LinFebruary 3, 202611 min read0 views
Writing Your First Solidity Smart Contract

Smart contracts are self-executing programs that live on a blockchain. Once deployed, they can't be modified — every function call is a transaction, and every state change is permanent. That's both the power and the danger. Let's build one from scratch.

Setting Up

You'll need Hardhat or Foundry for development. We'll use Hardhat since it's more familiar to JavaScript developers:

mkdir my-contract && cd my-contract
npx hardhat init

Choose "Create a TypeScript project" — yes, you can write tests and deploy scripts in TypeScript while the contracts themselves are in Solidity.

A Simple Token

Let's build a basic ERC-20-like token:

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

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) external returns (bool) {
        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) external returns (bool) {
        allowance[msg.sender][spender] = amount;
        emit Approval(msg.sender, spender, amount);
        return true;
    }

    function transferFrom(address from, address to, uint256 amount) external returns (bool) {
        require(balanceOf[from] >= amount, "Insufficient balance");
        require(allowance[from][msg.sender] >= amount, "Insufficient allowance");
        allowance[from][msg.sender] -= amount;
        balanceOf[from] -= amount;
        balanceOf[to] += amount;
        emit Transfer(from, to, amount);
        return true;
    }
}

Key Concepts

State variables (balanceOf, totalSupply) are stored permanently on-chain. Reading them is free; writing costs gas.

Mappings are like hash maps but with some quirks: you can't iterate over them, and every key has a default value (0 for integers, address(0) for addresses).

Events are cheap log entries that don't affect state but let off-chain apps (your frontend) react to contract activity. They're indexed for efficient filtering.

msg.sender is always the address that called the function. It's the primary identity mechanism — there are no usernames or passwords, just cryptographic keys.

Testing

Write tests in TypeScript with Hardhat:

import { expect } from "chai";
import { ethers } from "hardhat";

describe("SimpleToken", () => {
  it("should assign total supply to deployer", async () => {
    const [owner] = await ethers.getSigners();
    const Token = await ethers.getContractFactory("SimpleToken");
    const token = await Token.deploy("Test", "TST", 1000);

    const balance = await token.balanceOf(owner.address);
    expect(balance).to.equal(ethers.parseEther("1000"));
  });

  it("should transfer tokens", async () => {
    const [owner, recipient] = await ethers.getSigners();
    const Token = await ethers.getContractFactory("SimpleToken");
    const token = await Token.deploy("Test", "TST", 1000);

    await token.transfer(recipient.address, ethers.parseEther("100"));
    expect(await token.balanceOf(recipient.address)).to.equal(
      ethers.parseEther("100")
    );
  });
});

Security Considerations

Smart contracts handle real money. Common vulnerabilities:

  • Reentrancy: An external call re-enters your contract before state updates complete. Always update state before making external calls.

  • Integer overflow: Solidity 0.8+ has built-in overflow checks, but be aware when using unchecked blocks.

  • Access control: Without explicit checks, anyone can call any public function. Use OpenZeppelin's Ownable or AccessControl.

Before deploying to mainnet, get an audit. The immutability of smart contracts means bugs are forever.

Share this post

Reactions