Full Stack Ethereum Development: Build Decentralized Applications

Full Stack Ethereum Development: Build Decentralized Applications

What Is Ethereum Development?

Ethereum is a decentralized blockchain that goes beyond simple value transfer (like Bitcoin) to execute arbitrary code — "smart contracts." Smart contracts are programs stored on the blockchain that run exactly as programmed without the possibility of downtime, censorship, fraud, or third-party interference.

Full-stack Ethereum development involves:

  • Smart contracts (Solidity) — the backend logic stored on-chain
  • Development environment (Hardhat or Foundry) — compile, test, and deploy contracts
  • Frontend (React + ethers.js or wagmi) — the user interface that interacts with contracts
  • Wallets (MetaMask) — browser extension that manages user keys and signs transactions

Core Concepts

Accounts: Two types exist — Externally Owned Accounts (EOAs) controlled by private keys (your wallet) and Contract Accounts (smart contracts) controlled by their code.

Transactions: Every state change on Ethereum requires a transaction signed by an EOA. Transactions cost gas — a fee paid in ETH that compensates validators for computation.

Gas: Measured in gwei (1 ETH = 1,000,000,000 gwei). Complex operations cost more gas. Users set a gasLimit and maxFeePerGas. After EIP-1559 (London upgrade), the base fee is burned and users can add a priority tip.

ABI (Application Binary Interface): The JSON description of a contract's functions and events. Your frontend uses this to encode/decode interactions with the contract.

Setting Up Your Development Environment

mkdir eth-dapp && cd eth-dapp
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npx hardhat init

Choose "Create a JavaScript project." This sets up Hardhat with a sample contract, test, and deployment script.

hardhat.config.js:

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

module.exports = {
  solidity: {
    version: "0.8.24",
    settings: {
      optimizer: { enabled: true, runs: 200 },
    },
  },
  networks: {
    hardhat: {},         // Local development network
    sepolia: {           // Ethereum testnet
      url: process.env.SEPOLIA_RPC_URL,
      accounts: [process.env.PRIVATE_KEY],
    },
    mainnet: {
      url: process.env.MAINNET_RPC_URL,
      accounts: [process.env.PRIVATE_KEY],
    },
  },
  etherscan: {
    apiKey: process.env.ETHERSCAN_API_KEY,
  },
};

Writing Smart Contracts in Solidity

Solidity is a statically typed, contract-oriented programming language compiled to EVM bytecode:

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

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

/// @title SimpleStorage — A basic key-value store
contract SimpleStorage is Ownable, ReentrancyGuard {
    // State variables (stored on-chain permanently)
    mapping(address => string) private userMessages;
    uint256 public messageCount;

    // Events (emitted for frontend listeners)
    event MessageSet(address indexed user, string message, uint256 timestamp);
    event MessageDeleted(address indexed user);

    // Custom errors (gas-efficient)
    error EmptyMessage();
    error MessageTooLong(uint256 length, uint256 maxLength);

    constructor() Ownable(msg.sender) {}

    /// @notice Store a message for the caller
    function setMessage(string calldata message) external {
        if (bytes(message).length == 0) revert EmptyMessage();
        if (bytes(message).length > 280) revert MessageTooLong(bytes(message).length, 280);

        userMessages[msg.sender] = message;
        messageCount++;

        emit MessageSet(msg.sender, message, block.timestamp);
    }

    /// @notice Retrieve a user's stored message
    function getMessage(address user) external view returns (string memory) {
        return userMessages[user];
    }

    /// @notice Delete the caller's message
    function deleteMessage() external {
        delete userMessages[msg.sender];
        if (messageCount > 0) messageCount--;
        emit MessageDeleted(msg.sender);
    }
}

Key Solidity concepts:

  • msg.sender: The address that called the function
  • msg.value: ETH sent with the transaction
  • view/pure functions: Don't modify state, don't cost gas when called externally
  • payable: Functions or addresses that can receive ETH
  • memory vs storage: Where data is stored (memory = temporary, storage = permanent/expensive)
  • event: Log entries stored efficiently, readable by frontends
  • mapping: Hash table for key-value storage

Testing Smart Contracts

Test thoroughly — deployed contracts are immutable:

// test/SimpleStorage.test.js
const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("SimpleStorage", function () {
  let storage, owner, user1;

  beforeEach(async function () {
    [owner, user1] = await ethers.getSigners();
    const SimpleStorage = await ethers.getContractFactory("SimpleStorage");
    storage = await SimpleStorage.deploy();
  });

  describe("setMessage", function () {
    it("should store a message for the caller", async function () {
      await storage.connect(user1).setMessage("Hello, Ethereum!");
      expect(await storage.getMessage(user1.address)).to.equal("Hello, Ethereum!");
    });

    it("should increment message count", async function () {
      await storage.setMessage("Test");
      expect(await storage.messageCount()).to.equal(1);
    });

    it("should emit MessageSet event", async function () {
      await expect(storage.setMessage("Test"))
        .to.emit(storage, "MessageSet")
        .withArgs(owner.address, "Test", anyValue);
    });

    it("should revert on empty message", async function () {
      await expect(storage.setMessage("")).to.be.revertedWithCustomError(
        storage, "EmptyMessage"
      );
    });
  });
});

Run tests:

npx hardhat test
npx hardhat coverage    # Code coverage report

Deploying Contracts

// scripts/deploy.js
const { ethers } = require("hardhat");

async function main() {
  const [deployer] = await ethers.getSigners();
  console.log("Deploying with account:", deployer.address);

  const balance = await ethers.provider.getBalance(deployer.address);
  console.log("Account balance:", ethers.formatEther(balance), "ETH");

  const SimpleStorage = await ethers.getContractFactory("SimpleStorage");
  const contract = await SimpleStorage.deploy();
  await contract.waitForDeployment();

  const address = await contract.getAddress();
  console.log("SimpleStorage deployed to:", address);

  // Verify on Etherscan
  if (network.name !== "hardhat") {
    await run("verify:verify", { address });
  }
}

main().catch((error) => {
  console.error(error);
  process.exit(1);
});
npx hardhat run scripts/deploy.js --network sepolia

Building the React Frontend

npm create vite@latest frontend -- --template react
cd frontend
npm install ethers

Connect to MetaMask and interact with the contract:

// src/App.jsx
import { useState, useEffect } from 'react';
import { ethers } from 'ethers';
import SimpleStorageABI from './abi/SimpleStorage.json';

const CONTRACT_ADDRESS = '0xYourDeployedContractAddress';

export default function App() {
  const [provider, setProvider] = useState(null);
  const [signer, setSigner] = useState(null);
  const [contract, setContract] = useState(null);
  const [account, setAccount] = useState('');
  const [message, setMessage] = useState('');
  const [storedMessage, setStoredMessage] = useState('');
  const [loading, setLoading] = useState(false);

  async function connectWallet() {
    if (!window.ethereum) return alert('Please install MetaMask!');

    const provider = new ethers.BrowserProvider(window.ethereum);
    await provider.send("eth_requestAccounts", []);
    const signer = await provider.getSigner();
    const address = await signer.getAddress();

    const contract = new ethers.Contract(CONTRACT_ADDRESS, SimpleStorageABI, signer);

    setProvider(provider);
    setSigner(signer);
    setContract(contract);
    setAccount(address);

    // Read existing message
    const msg = await contract.getMessage(address);
    setStoredMessage(msg);
  }

  async function handleSetMessage(e) {
    e.preventDefault();
    if (!contract) return;

    setLoading(true);
    try {
      const tx = await contract.setMessage(message);
      await tx.wait();  // Wait for transaction to be mined
      setStoredMessage(message);
      setMessage('');
      alert('Message saved on-chain!');
    } catch (err) {
      console.error(err);
      alert('Transaction failed: ' + err.message);
    } finally {
      setLoading(false);
    }
  }

  return (
    <div>
      <h1>Ethereum Message Board</h1>
      {!account ? (
        <button onClick={connectWallet}>Connect MetaMask</button>
      ) : (
        <div>
          <p>Connected: {account.slice(0,6)}...{account.slice(-4)}</p>
          <p>Your message: {storedMessage || '(none stored)'}</p>
          <form onSubmit={handleSetMessage}>
            <input
              value={message}
              onChange={e => setMessage(e.target.value)}
              placeholder="Enter your message"
              maxLength={280}
            />
            <button type="submit" disabled={loading || !message}>
              {loading ? 'Processing...' : 'Save to Blockchain'}
            </button>
          </form>
        </div>
      )}
    </div>
  );
}

Common Patterns and Security

Reentrancy Guard: Prevents contracts from being called recursively mid-execution (the DAO hack). Use OpenZeppelin's ReentrancyGuard modifier.

Checks-Effects-Interactions: Always check conditions, update state, then interact with external contracts — in that order.

OpenZeppelin Contracts: Battle-tested, audited implementations of common patterns (ERC20 tokens, ERC721 NFTs, access control, pausable). Always use these rather than rolling your own:

npm install @openzeppelin/contracts

ERC-20 Token (fungible):

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MyToken is ERC20 {
    constructor() ERC20("MyToken", "MTK") {
        _mint(msg.sender, 1_000_000 * 10**decimals());
    }
}

ERC-721 NFT (non-fungible):

import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";

The Ethereum Ecosystem

  • Layer 2s: Polygon, Arbitrum, Optimism — faster and cheaper than mainnet, fully EVM-compatible
  • IPFS: Decentralized file storage for NFT metadata and frontend hosting
  • The Graph: Indexing blockchain data for efficient querying
  • Chainlink: Decentralized oracles for off-chain data (prices, randomness)
  • Hardhat/Foundry: Competing development frameworks (Foundry is faster, Rust-based)
Share: