Module 3: Smart Contract Development Environment

Introduction

Welcome to Module 3 of our Solidity programming course! Now that you understand blockchain fundamentals and Solidity basics, it's time to set up your development environment and learn the tools and workflows for smart contract development. This module will guide you through the process of setting up your development environment, testing and deploying smart contracts, and building simple decentralized applications (DApps).

Learning Objectives

By the end of this module, you will be able to:

1. Development Environment Options

There are several approaches to Solidity development, each with its own advantages:

1.1 Browser-based Development

Advantages:

Tools:

1.2 Local Development Environment

Advantages:

Tools:

1.3 Hybrid Approach

Many developers use a combination of tools:

2. Setting Up Remix IDE

Let's start with Remix IDE, which is the easiest way to begin Solidity development.

2.1 Accessing Remix

  1. Open your web browser and navigate to https://remix.ethereum.org/
  2. You'll see the Remix IDE interface with several panels

2.2 Remix Interface Overview

The Remix interface consists of several panels:

2.3 Creating Your First Contract in Remix

  1. In the File Explorer, click the "+" icon to create a new file
  2. Name it "SimpleStorage.sol"
  3. Enter the following code:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

contract SimpleStorage {
    uint256 private storedData;
    
    function set(uint256 x) public {
        storedData = x;
    }
    
    function get() public view returns (uint256) {
        return storedData;
    }
}

2.4 Compiling in Remix

  1. Click on the "Solidity Compiler" tab in the right panel
  2. Select the appropriate compiler version (0.8.17 or compatible)
  3. Click "Compile SimpleStorage.sol"
  4. If successful, you'll see a green checkmark

2.5 Deploying in Remix

  1. Click on the "Deploy & Run Transactions" tab in the right panel
  2. Select the environment:
    • "JavaScript VM" (now called "Remix VM") for local testing
    • "Injected Web3" for connecting to MetaMask
    • "Web3 Provider" for connecting to a local node
  3. Select "SimpleStorage" from the contract dropdown
  4. Click "Deploy"
  5. Your contract will appear under "Deployed Contracts"

2.6 Interacting with Your Contract

  1. Under "Deployed Contracts," you'll see your contract functions
  2. Click the "set" function, enter a value (e.g., 42), and click "transact"
  3. Click the "get" function to retrieve the stored value

3. Setting Up a Local Development Environment

For professional development, a local environment provides more flexibility and power. We'll focus on Hardhat, which is currently one of the most popular development environments.

3.1 Prerequisites

Before setting up Hardhat, ensure you have the following installed:

3.2 Installing Hardhat

  1. Create a new directory for your project:
    mkdir my-solidity-project
    cd my-solidity-project
  2. Initialize a new npm project:
    npm init -y
  3. Install Hardhat:
    npm install --save-dev hardhat
  4. Initialize a Hardhat project:
    npx hardhat
  5. Select "Create a JavaScript project" when prompted
  6. Follow the prompts to complete the setup

3.3 Project Structure

After initialization, your project will have the following structure:

my-solidity-project/
├── contracts/        # Solidity contracts
├── scripts/          # Deployment scripts
├── test/             # Test files
├── hardhat.config.js # Hardhat configuration
└── package.json      # Project dependencies

3.4 Installing Additional Dependencies

npm install --save-dev @nomiclabs/hardhat-ethers ethers @nomiclabs/hardhat-waffle ethereum-waffle chai

3.5 Creating a Simple Contract

Create a file named SimpleStorage.sol in the contracts directory:

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

contract SimpleStorage {
    uint256 private storedData;
    
    function set(uint256 x) public {
        storedData = x;
    }
    
    function get() public view returns (uint256) {
        return storedData;
    }
}

3.6 Compiling with Hardhat

Compile your contract with the following command:

npx hardhat compile

This will create a artifacts directory containing the compiled contract artifacts.

4. Testing Smart Contracts

Testing is a crucial part of smart contract development. Let's explore how to write and run tests for your contracts.

4.1 Writing Tests with Hardhat

Create a file named SimpleStorage.test.js in the test directory:

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

describe("SimpleStorage", function () {
  let SimpleStorage;
  let simpleStorage;
  let owner;
  let addr1;
  
  beforeEach(async function () {
    // Get contract factory and signers
    SimpleStorage = await ethers.getContractFactory("SimpleStorage");
    [owner, addr1] = await ethers.getSigners();
    
    // Deploy contract
    simpleStorage = await SimpleStorage.deploy();
    await simpleStorage.deployed();
  });
  
  it("Should return the initial value as 0", async function () {
    expect(await simpleStorage.get()).to.equal(0);
  });
  
  it("Should set the value correctly", async function () {
    // Set value to 42
    await simpleStorage.set(42);
    
    // Check if value was set correctly
    expect(await simpleStorage.get()).to.equal(42);
  });
  
  it("Should allow anyone to set a value", async function () {
    // Connect as addr1 and set value to 100
    await simpleStorage.connect(addr1).set(100);
    
    // Check if value was set correctly
    expect(await simpleStorage.get()).to.equal(100);
  });
});

4.2 Running Tests

Run your tests with the following command:

npx hardhat test

You should see output indicating that all tests have passed.

4.3 Test Coverage

To check test coverage, you can use the solidity-coverage plugin:

  1. Install the plugin:
    npm install --save-dev solidity-coverage
  2. Add it to your hardhat.config.js:
    require("@nomiclabs/hardhat-waffle");
    require("solidity-coverage");
    
    module.exports = {
      solidity: "0.8.17",
      // ... other config
    };
  3. Run coverage:
    npx hardhat coverage

5. Deploying Smart Contracts

Now let's explore how to deploy your smart contracts to different networks.

5.1 Configuring Networks

Update your hardhat.config.js to include network configurations:

require("@nomiclabs/hardhat-waffle");

// Load environment variables
require("dotenv").config();

// Replace with your own API keys and private keys
const INFURA_API_KEY = process.env.INFURA_API_KEY || "";
const PRIVATE_KEY = process.env.PRIVATE_KEY || "0x0000000000000000000000000000000000000000000000000000000000000000";

module.exports = {
  solidity: "0.8.17",
  networks: {
    // Local development network
    hardhat: {
      chainId: 31337
    },
    // Goerli testnet
    goerli: {
      url: `https://goerli.infura.io/v3/${INFURA_API_KEY}`,
      accounts: [PRIVATE_KEY]
    },
    // Sepolia testnet
    sepolia: {
      url: `https://sepolia.infura.io/v3/${INFURA_API_KEY}`,
      accounts: [PRIVATE_KEY]
    },
    // Ethereum mainnet
    mainnet: {
      url: `https://mainnet.infura.io/v3/${INFURA_API_KEY}`,
      accounts: [PRIVATE_KEY]
    }
  }
};

You'll need to install the dotenv package to use environment variables:

npm install --save-dev dotenv

Create a .env file in your project root to store your private keys and API keys:

INFURA_API_KEY=your_infura_api_key
PRIVATE_KEY=your_private_key_without_0x_prefix

⚠️ Security Warning

Never commit your .env file to version control. Add it to .gitignore to prevent accidental exposure of your private keys.

5.2 Creating a Deployment Script

Create a file named deploy.js in the scripts directory:

async function main() {
  // Get the contract factory
  const SimpleStorage = await ethers.getContractFactory("SimpleStorage");
  
  // Deploy the contract
  console.log("Deploying SimpleStorage...");
  const simpleStorage = await SimpleStorage.deploy();
  
  // Wait for deployment to finish
  await simpleStorage.deployed();
  
  console.log("SimpleStorage deployed to:", simpleStorage.address);
}

// Execute the deployment
main()
  .then(() => process.exit(0))
  .catch((error) => {
    console.error(error);
    process.exit(1);
  });

5.3 Deploying to Different Networks

Local Development Network

Start a local node:

npx hardhat node

Deploy to the local network:

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

Test Networks

Deploy to Goerli testnet:

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

Mainnet

Deploy to Ethereum mainnet (be careful, this costs real ETH):

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

5.4 Verifying Contracts on Etherscan

To verify your contract on Etherscan, you can use the hardhat-etherscan plugin:

  1. Install the plugin:
    npm install --save-dev @nomiclabs/hardhat-etherscan
  2. Add it to your hardhat.config.js:
    require("@nomiclabs/hardhat-etherscan");
    
    module.exports = {
      // ... other config
      etherscan: {
        apiKey: process.env.ETHERSCAN_API_KEY
      }
    };
  3. Add your Etherscan API key to .env:
    ETHERSCAN_API_KEY=your_etherscan_api_key
  4. Verify your contract:
    npx hardhat verify --network goerli DEPLOYED_CONTRACT_ADDRESS

6. Interacting with Deployed Contracts

Once your contract is deployed, you can interact with it in various ways.

6.1 Using Hardhat Console

You can interact with your contract using the Hardhat console:

npx hardhat console --network goerli

Inside the console:

// Get the contract factory
const SimpleStorage = await ethers.getContractFactory("SimpleStorage");

// Connect to the deployed contract
const simpleStorage = await SimpleStorage.attach("DEPLOYED_CONTRACT_ADDRESS");

// Call contract functions
await simpleStorage.set(42);
const value = await simpleStorage.get();
console.log("Stored value:", value.toString());

6.2 Using a Script

Create a file named interact.js in the scripts directory:

async function main() {
  // Get the contract factory
  const SimpleStorage = await ethers.getContractFactory("SimpleStorage");
  
  // Connect to the deployed contract
  const simpleStorage = await SimpleStorage.attach("DEPLOYED_CONTRACT_ADDRESS");
  
  // Get the current value
  const currentValue = await simpleStorage.get();
  console.log("Current value:", currentValue.toString());
  
  // Set a new value
  console.log("Setting new value...");
  const tx = await simpleStorage.set(100);
  await tx.wait();
  
  // Get the updated value
  const newValue = await simpleStorage.get();
  console.log("New value:", newValue.toString());
}

main()
  .then(() => process.exit(0))
  .catch((error) => {
    console.error(error);
    process.exit(1);
  });

Run the script:

npx hardhat run scripts/interact.js --network goerli

6.3 Using Web3.js or Ethers.js in a Frontend

You can also interact with your contract from a web frontend using libraries like Web3.js or Ethers.js.

Example with Ethers.js:

// Import ethers.js
import { ethers } from "ethers";

// Import contract ABI and address
import SimpleStorageABI from "./SimpleStorageABI.json";
const contractAddress = "DEPLOYED_CONTRACT_ADDRESS";

// Connect to the Ethereum network
async function connectToContract() {
  // Check if MetaMask is installed
  if (typeof window.ethereum !== "undefined") {
    try {
      // Request account access
      await window.ethereum.request({ method: "eth_requestAccounts" });
      
      // Create a provider
      const provider = new ethers.providers.Web3Provider(window.ethereum);
      
      // Get the signer
      const signer = provider.getSigner();
      
      // Create contract instance
      const simpleStorage = new ethers.Contract(
        contractAddress,
        SimpleStorageABI,
        signer
      );
      
      return simpleStorage;
    } catch (error) {
      console.error("Error connecting to contract:", error);
    }
  } else {
    console.error("Please install MetaMask!");
  }
}

// Get the stored value
async function getValue() {
  const simpleStorage = await connectToContract();
  try {
    const value = await simpleStorage.get();
    console.log("Stored value:", value.toString());
    return value;
  } catch (error) {
    console.error("Error getting value:", error);
  }
}

// Set a new value
async function setValue(newValue) {
  const simpleStorage = await connectToContract();
  try {
    const tx = await simpleStorage.set(newValue);
    await tx.wait();
    console.log("Value set successfully!");
  } catch (error) {
    console.error("Error setting value:", error);
  }
}

7. Debugging Smart Contracts

Debugging is an essential skill for smart contract development. Let's explore some debugging techniques.

7.1 Using Hardhat's Built-in Debugger

Hardhat comes with a built-in debugger that you can use to debug transactions:

npx hardhat node
# In another terminal
npx hardhat test --network localhost

When a test fails, you'll see a stack trace with the exact line of code that caused the error.

7.2 Using Remix Debugger

Remix has a powerful debugger that allows you to step through transaction execution:

  1. Deploy your contract in Remix
  2. Execute a function that you want to debug
  3. In the terminal panel, find the transaction and click the "Debug" button
  4. Use the debugger controls to step through the execution

7.3 Using Events for Debugging

Events can be used as a logging mechanism for debugging:

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

contract DebuggableStorage {
    uint256 private storedData;
    
    // Define an event for debugging
    event DebugLog(string message, uint256 value);
    
    function set(uint256 x) public {
        // Emit debug event
        emit DebugLog("Setting value", x);
        
        storedData = x;
        
        // Emit another debug event
        emit DebugLog("Value set", storedData);
    }
    
    function get() public view returns (uint256) {
        return storedData;
    }
}

7.4 Common Errors and Solutions

Error Possible Causes Solutions
Out of Gas
  • Infinite loops
  • Inefficient code
  • Gas limit too low
  • Optimize your code
  • Increase gas limit
  • Break operations into smaller transactions
Revert/Require Failed
  • Condition in require statement not met
  • Function called with invalid parameters
  • Check input parameters
  • Verify contract state
  • Add more detailed error messages
Invalid Opcode
  • Assertion failed
  • Array index out of bounds
  • Division by zero
  • Add bounds checking
  • Validate inputs
  • Check for division by zero

8. Building a Simple DApp

Now let's put everything together and build a simple decentralized application (DApp) that interacts with our SimpleStorage contract.

8.1 Setting Up the Frontend

We'll use a simple HTML/CSS/JavaScript setup for our frontend. Create a directory named frontend in your project root:

mkdir frontend
cd frontend

Create an index.html file:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>SimpleStorage DApp</title>
    <link rel="stylesheet" href="styles.css">
</head>
<body>
    <div class="container">
        <h1>SimpleStorage DApp</h1>
        
        <div class="card">
            <h2>Current Value</h2>
            <p id="value">Loading...</p>
            <button id="refreshButton">Refresh</button>
        </div>
        
        <div class="card">
            <h2>Set New Value</h2>
            <input type="number" id="newValue" placeholder="Enter new value">
            <button id="setButton">Set Value</button>
            <p id="status"></p>
        </div>
        
        <div class="card">
            <h2>Connection Status</h2>
            <p id="connectionStatus">Not connected</p>
            <button id="connectButton">Connect Wallet</button>
        </div>
    </div>
    
    <script src="https://cdn.ethers.io/lib/ethers-5.6.umd.min.js" type="application/javascript"></script>
    <script src="app.js"></script>
</body>
</html>

Create a styles.css file:

body {
    font-family: Arial, sans-serif;
    line-height: 1.6;
    margin: 0;
    padding: 0;
    background-color: #f4f4f4;
}

.container {
    max-width: 800px;
    margin: 0 auto;
    padding: 20px;
}

h1 {
    text-align: center;
    margin-bottom: 30px;
}

.card {
    background-color: #fff;
    border-radius: 5px;
    padding: 20px;
    margin-bottom: 20px;
    box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}

input[type="number"] {
    width: 100%;
    padding: 10px;
    margin-bottom: 10px;
    border: 1px solid #ddd;
    border-radius: 4px;
}

button {
    background-color: #4CAF50;
    color: white;
    border: none;
    padding: 10px 15px;
    border-radius: 4px;
    cursor: pointer;
}

button:hover {
    background-color: #45a049;
}

#status {
    margin-top: 10px;
    font-style: italic;
}

#value {
    font-size: 24px;
    font-weight: bold;
    text-align: center;
}

Create an app.js file:

// Contract ABI (copy from artifacts/contracts/SimpleStorage.sol/SimpleStorage.json)
const contractABI = [
    {
        "inputs": [],
        "name": "get",
        "outputs": [
            {
                "internalType": "uint256",
                "name": "",
                "type": "uint256"
            }
        ],
        "stateMutability": "view",
        "type": "function"
    },
    {
        "inputs": [
            {
                "internalType": "uint256",
                "name": "x",
                "type": "uint256"
            }
        ],
        "name": "set",
        "outputs": [],
        "stateMutability": "nonpayable",
        "type": "function"
    }
];

// Contract address (replace with your deployed contract address)
const contractAddress = "YOUR_DEPLOYED_CONTRACT_ADDRESS";

// Global variables
let provider;
let signer;
let contract;

// DOM elements
const valueElement = document.getElementById("value");
const newValueInput = document.getElementById("newValue");
const setButton = document.getElementById("setButton");
const refreshButton = document.getElementById("refreshButton");
const connectButton = document.getElementById("connectButton");
const connectionStatusElement = document.getElementById("connectionStatus");
const statusElement = document.getElementById("status");

// Initialize the app
async function init() {
    // Check if MetaMask is installed
    if (typeof window.ethereum !== "undefined") {
        // Listen for account changes
        window.ethereum.on("accountsChanged", handleAccountsChanged);
        
        // Setup button event listeners
        connectButton.addEventListener("click", connectWallet);
        setButton.addEventListener("click", setValue);
        refreshButton.addEventListener("click", getValue);
    } else {
        connectionStatusElement.textContent = "MetaMask not detected. Please install MetaMask.";
        connectButton.disabled = true;
        setButton.disabled = true;
        refreshButton.disabled = true;
    }
}

// Connect wallet
async function connectWallet() {
    try {
        // Request account access
        await window.ethereum.request({ method: "eth_requestAccounts" });
        
        // Setup provider and signer
        provider = new ethers.providers.Web3Provider(window.ethereum);
        signer = provider.getSigner();
        
        // Get connected account
        const account = await signer.getAddress();
        connectionStatusElement.textContent = `Connected: ${account}`;
        
        // Create contract instance
        contract = new ethers.Contract(contractAddress, contractABI, signer);
        
        // Enable buttons
        setButton.disabled = false;
        refreshButton.disabled = false;
        
        // Get initial value
        getValue();
    } catch (error) {
        console.error("Error connecting wallet:", error);
        connectionStatusElement.textContent = "Error connecting wallet. See console for details.";
    }
}

// Handle account changes
function handleAccountsChanged(accounts) {
    if (accounts.length === 0) {
        // MetaMask is locked or user has no accounts
        connectionStatusElement.textContent = "Not connected";
        setButton.disabled = true;
        refreshButton.disabled = true;
    } else {
        // Reconnect with new account
        connectWallet();
    }
}

// Get current value
async function getValue() {
    if (!contract) return;
    
    try {
        const value = await contract.get();
        valueElement.textContent = value.toString();
    } catch (error) {
        console.error("Error getting value:", error);
        valueElement.textContent = "Error";
    }
}

// Set new value
async function setValue() {
    if (!contract) return;
    
    const newValue = newValueInput.value;
    if (!newValue) {
        statusElement.textContent = "Please enter a value";
        return;
    }
    
    try {
        statusElement.textContent = "Transaction pending...";
        
        // Send transaction
        const tx = await contract.set(newValue);
        
        statusElement.textContent = "Transaction sent! Waiting for confirmation...";
        
        // Wait for transaction to be mined
        await tx.wait();
        
        statusElement.textContent = "Value set successfully!";
        
        // Update displayed value
        getValue();
    } catch (error) {
        console.error("Error setting value:", error);
        statusElement.textContent = "Error setting value. See console for details.";
    }
}

// Initialize the app when the page loads
window.addEventListener("load", init);

8.2 Serving the DApp

You can serve your DApp using a simple HTTP server:

npx http-server frontend -p 8080

Then open your browser and navigate to http://localhost:8080.

8.3 Connecting to Your Contract

Before using your DApp, make sure to:

  1. Deploy your contract to a network (local, testnet, or mainnet)
  2. Update the contractAddress in app.js with your deployed contract address
  3. Verify that the ABI in app.js matches your contract

8.4 Testing Your DApp

  1. Open your DApp in a browser with MetaMask installed
  2. Click "Connect Wallet" to connect MetaMask
  3. Click "Refresh" to see the current stored value
  4. Enter a new value and click "Set Value"
  5. Confirm the transaction in MetaMask
  6. Wait for the transaction to be mined
  7. Verify that the value has been updated

9. Development Workflow Best Practices

Let's summarize the best practices for a smart contract development workflow:

9.1 Development Lifecycle

  1. Planning: Define requirements and design the contract architecture
  2. Development: Write the contract code
  3. Testing: Write and run tests to verify functionality
  4. Deployment: Deploy to testnet for real-world testing
  5. Auditing: Have your code reviewed by security experts
  6. Mainnet Deployment: Deploy to mainnet when ready
  7. Monitoring: Monitor your contract for issues
  8. Maintenance: Address issues and implement upgrades if needed

9.2 Version Control

Use Git for version control:

git init
git add .
git commit -m "Initial commit"

Create a .gitignore file to exclude sensitive files:

node_modules
.env
coverage
coverage.json
typechain
typechain-types

# Hardhat files
cache
artifacts

9.3 Documentation

Document your code using NatSpec comments:

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

/// @title A simple storage contract
/// @author Your Name
/// @notice This contract stores a single uint256 value
/// @dev This is a simple example for educational purposes
contract SimpleStorage {
    uint256 private storedData;
    
    /// @notice Set the stored value
    /// @param x The new value to store
    /// @dev Updates the storedData state variable
    function set(uint256 x) public {
        storedData = x;
    }
    
    /// @notice Get the stored value
    /// @return The current stored value
    /// @dev Returns the storedData state variable
    function get() public view returns (uint256) {
        return storedData;
    }
}

9.4 Continuous Integration

Set up continuous integration to automatically run tests on every commit. You can use services like GitHub Actions, Travis CI, or CircleCI.

Example GitHub Actions workflow (.github/workflows/test.yml):

name: Test

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - name: Use Node.js
        uses: actions/setup-node@v2
        with:
          node-version: '14.x'
      - name: Install dependencies
        run: npm ci
      - name: Run tests
        run: npm test

Summary

In this module, we've covered the essential aspects of setting up a Solidity development environment and the workflow for smart contract development. We've explored:

With these tools and techniques, you're now equipped to develop, test, and deploy your own smart contracts and build decentralized applications on Ethereum.

Exercises

  1. Set up a local development environment with Hardhat or Truffle
  2. Write and deploy a simple contract that stores and retrieves multiple values
  3. Write comprehensive tests for your contract
  4. Deploy your contract to a testnet (Goerli or Sepolia)
  5. Build a simple frontend that interacts with your deployed contract
  6. Add error handling and loading states to your frontend
  7. Implement a continuous integration workflow for your project

Further Reading