Technical

Solidity for Web2 Developers: My First Year Writing Smart Contracts

June 5, 202612 min readby Rahul Patel

A TypeScript developer's guide to Solidity. Mental model shifts, gas optimization, the reentrancy vulnerability every developer needs to understand, and Hardhat testing patterns.

SolidityWeb3BlockchainEthereumSmart Contracts
Share:

In 2021 I took on a project that required writing Solidity smart contracts. I had zero blockchain experience. My mental model was: "It's just code that runs on a distributed database." That mental model is wrong in ways that took me months to unlearn.

This is the guide I wish I had for developers who know JavaScript or TypeScript, understand databases and APIs, and are about to write their first production smart contract.

The Fundamental Mental Shift

In web development, mistakes are cheap. You deploy a broken API, you fix it, you deploy again. The broken version might have caused a few errors in your logs. In smart contract development, mistakes are permanent and potentially expensive. Once a contract is deployed to mainnet, that code runs forever exactly as written. If you have a bug, you deploy a *new* contract and migrate to it which costs gas and requires users to update any references they hold.

Warning

The DAO hack in 2016 drained $60 million in ETH from a reentrancy bug. The Poly Network hack in 2021 stole $611 million from a single function visibility mistake. These are not theoretical risks. Write every contract assuming an adversary will probe every code path.

The other shift is economic. Every operation in a smart contract costs gas a fee paid by the caller in ETH. Reading from storage is cheaper than writing. Writing a new storage slot costs 20,000 gas. Simple arithmetic is 3 gas. This means you care about computational complexity in a way you never did in traditional backend development.

Solidity for TypeScript Developers

The surface syntax will feel familiar. The semantics are completely different.

contracts/SimpleToken.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract SimpleToken {
    // State variables live on the blockchain (expensive to write, cheap to read)
    string public name;
    uint256 public totalSupply;

    // mapping is like a hash map, but every key already "exists" with default value 0
    mapping(address => uint256) public balances;

    // Events are the blockchain equivalent of console.log — they're queryable
    event Transfer(address indexed from, address indexed to, uint256 amount);

    // Constructor runs once at deploy time — like a class constructor
    constructor(string memory _name, uint256 _initialSupply) {
        name = _name;
        totalSupply = _initialSupply;
        balances[msg.sender] = _initialSupply; // msg.sender is the deployer
    }

    // external: can only be called from outside the contract
    // public: can be called externally and internally
    function transfer(address to, uint256 amount) external {
        require(balances[msg.sender] >= amount, "Insufficient balance");
        require(to != address(0), "Cannot transfer to zero address");

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

        emit Transfer(msg.sender, to, amount);
    }
}

Key Differences from TypeScript

  • No floats. Solidity has no floating-point numbers. Use integers scaled by a factor (e.g. represent $1.50 as 150 with 2 decimal places, or use 1.5 * 10^18 for 18-decimal token math).
  • Integer overflow protection is built in since 0.8.0. Before 0.8, uint8(255) + 1 would silently wrap to 0. Now it reverts. This eliminated an entire class of bugs.
  • `msg.sender` is the address that called this function. Not necessarily the contract owner. This is the foundation of access control.
  • `require(condition, message)` is your validation. If condition is false, the entire transaction reverts and gas used so far is lost (but the state change doesn't persist).
  • There is no `null`. Unset mappings return 0. Unset addresses return address(0). Unset booleans return false. Always check for zero-address explicitly.
  • Arrays have no built-in `indexOf`. If you need to check membership, use a mapping as a lookup set alongside the array.

Your Local Dev Setup: Hardhat

Hardhat is to Solidity what Jest + nodemon is to Node.js. It compiles contracts, runs a local Ethereum node for testing, and provides a testing framework.

bash
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npx hardhat init    # choose TypeScript project
test/SimpleToken.test.ts
import { expect } from "chai";
import { ethers } from "hardhat";
import { SimpleToken } from "../typechain-types";

describe("SimpleToken", () => {
  let token: SimpleToken;
  let owner: HardhatEthersSigner;
  let alice: HardhatEthersSigner;

  beforeEach(async () => {
    [owner, alice] = await ethers.getSigners();
    const factory = await ethers.getContractFactory("SimpleToken");
    token = await factory.deploy("TestToken", 1_000_000n * 10n ** 18n);
  });

  it("gives total supply to deployer", async () => {
    const balance = await token.balances(owner.address);
    expect(balance).to.equal(1_000_000n * 10n ** 18n);
  });

  it("transfers correctly", async () => {
    const amount = 100n * 10n ** 18n;
    await token.transfer(alice.address, amount);
    expect(await token.balances(alice.address)).to.equal(amount);
  });

  it("reverts on insufficient balance", async () => {
    await expect(
      token.connect(alice).transfer(owner.address, 1n)
    ).to.be.revertedWith("Insufficient balance");
  });
});
Tip

Hardhat generates TypeScript types for your contracts automatically (typechain-types/). Use them. Type-safe contract interaction is the difference between catching mistakes at compile time versus finding them by losing ETH.

The Reentrancy Vulnerability

The most important security concept to understand before you write a single line of production Solidity. A reentrancy attack happens when your contract sends ETH to an external address, and that address's fallback function calls back into your contract before the first call finishes executing.

contracts/VulnerableVault.sol
// ❌ VULNERABLE: reentrancy attack possible
contract VulnerableVault {
    mapping(address => uint256) public balances;

    function withdraw() external {
        uint256 amount = balances[msg.sender];
        require(amount > 0, "Nothing to withdraw");
        (bool success, ) = msg.sender.call{value: amount}("");  // <-- attacker's fallback runs here
        require(success, "Transfer failed");
        balances[msg.sender] = 0;  // <-- this line runs AFTER the attacker has already re-entered
    }
}

// ✅ SAFE: Checks-Effects-Interactions pattern
contract SafeVault {
    mapping(address => uint256) public balances;

    function withdraw() external {
        uint256 amount = balances[msg.sender];
        require(amount > 0, "Nothing to withdraw");
        balances[msg.sender] = 0;                                // Effect FIRST
        (bool success, ) = msg.sender.call{value: amount}("");   // Interaction LAST
        require(success, "Transfer failed");
    }
}

The rule is Checks-Effects-Interactions: validate inputs, then update state, then interact with external contracts or addresses. In that order. Always.

Gas Optimization: The Things That Actually Matter

  • Use `calldata` instead of `memory` for read-only function parameters. calldata avoids copying; memory copies. For string/array params, this can save thousands of gas.
  • Pack storage variables. Ethereum uses 32-byte storage slots. A uint128 + uint128 fits in one slot and costs one write. Two separate uint256 variables cost two writes. Order your struct fields from smallest to largest type.
  • Use events instead of storage for history. If you just need a record that something happened (an audit trail), emit an event instead of writing to storage. Events cost ~375 gas; storage writes cost 20,000+.
  • Use `unchecked {}` for known-safe math. In tight loops where you've already validated bounds, unchecked { i++; } skips overflow checking and saves ~40 gas per iteration.
  • Cache storage reads in memory variables. Every SLOAD (storage read) costs 100 gas. If you read the same storage variable twice, cache it: uint256 bal = balances[msg.sender]; then use bal.

What Surprised Me Most

After a year with Solidity, the thing that surprised me most wasn't the syntax or the tooling. It was how much the permanent, adversarial nature of the environment changes how you think about code. In web development I optimize for iteration speed. In smart contract development I optimize for correctness on the first try.

Solidity made me a better web developer by forcing me to think rigorously about state transitions, access control, and failure modes. Those skills transfer directly back to designing APIs and database schemas.

Note

Before deploying to mainnet: get an audit from a firm like Trail of Bits, OpenZeppelin, or Code4rena. Not optional for anything holding real value. Your test suite is not an audit.

more from the notebook

March 15, 2026Building

How I Built a Zero-Dependency npm Package from Scratch

The story behind auto-api-observe a zero-dependency Express/Fastify observability middleware. Architecture decisions, A…

March 1, 2026Technical

AsyncLocalStorage in Node.js: The Complete Guide with Real Examples

Everything you need to know about AsyncLocalStorage from basic context tracking to building a full request-scoped loggi…

February 20, 2026Technical

How to Build an MCP Server for WordPress from Scratch

A step-by-step guide to building a Model Context Protocol server that lets Claude manage WordPress content. Zod schemas,…