Case Study: From Remix to Production — A Junior Developer's First Smart Contract Deployment

Meet Priya

Priya Sharma is a junior developer at a blockchain startup in Bangalore. She has two years of experience writing TypeScript backends and React frontends, but this is her first month working with smart contracts. Her team lead has assigned her a seemingly simple task: deploy a rewards token for the company's testnet demo. The token needs to be ERC-20 compliant, have a fixed initial supply of 10 million tokens, and include a function for the contract owner to distribute rewards to users.

Priya estimates this will take a day. She is wrong. It takes four days, and every mistake she makes along the way teaches a lesson that no tutorial covers.

Day One: The Remix Prototype

Priya starts in Remix, Ethereum's browser-based IDE, because it requires no local setup. She writes her token contract in under an hour, drawing heavily on the ERC-20 template she found in the Remix documentation:

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

contract RewardsToken {
    string public name = "RewardsToken";
    string public symbol = "RWD";
    uint8 public decimals = 18;
    uint256 public totalSupply;

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

    address public owner;

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

    constructor() {
        owner = msg.sender;
        totalSupply = 10000000 * 10 ** 18;
        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");
        balanceOf[from] -= amount;
        balanceOf[to] += amount;
        allowance[from][msg.sender] -= amount;
        emit Transfer(from, to, amount);
        return true;
    }

    function distributeReward(address to, uint256 amount) external {
        require(msg.sender == owner, "Only owner");
        require(balanceOf[owner] >= amount, "Insufficient reward pool");
        balanceOf[owner] -= amount;
        balanceOf[to] += amount;
        emit Transfer(owner, to, amount);
    }
}

She deploys it to Remix's JavaScript VM (a local, in-browser Ethereum simulator), tests the basic functions manually, and everything works. She transfers tokens between test accounts, checks balances, and the distributeReward function works as expected. She pushes the code to the team's GitHub repository and messages her lead: "Token contract is ready for review."

Her lead, Marcus, responds within the hour: "Looks good functionally, but we need it deployed to Sepolia with Hardhat tests before the demo. Can you set that up?"

Day Two: The Hardhat Migration

Priya has never used Hardhat. She follows the official documentation, installs Node.js 20, creates a new project directory, and initializes Hardhat:

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

She selects "Create a JavaScript project" and copies her contract into the contracts/ directory. The first compilation fails:

Error: HH606: The project cannot be compiled, see reasons below.
The Solidity version pragma statement in these files doesn't match any of the
configured compilers in your config.

Priya's hardhat.config.js specifies Solidity 0.8.19 (Hardhat's default at the time), but her pragma says ^0.8.20. She updates the config to 0.8.20. Compilation succeeds.

Lesson 1: Always verify that your pragma version matches your compiler configuration. This seems trivial, but version mismatches are the single most common compilation error for new Hardhat users. In Remix, the compiler version is selected from a dropdown and updates automatically. In Hardhat, it is a configuration value that you must maintain.

Next, she writes her first test. She has written Jest tests for JavaScript applications but has never tested a smart contract:

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

describe("RewardsToken", function () {
    let token, owner, user1;

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

    it("should have correct initial supply", async function () {
        const supply = await token.totalSupply();
        expect(supply).to.equal(10000000n * 10n ** 18n);
    });
});

The test fails with a confusing error about BigInt comparison. Priya spends an hour debugging before realizing that ethers.js v6 returns BigInt values, not the BigNumber objects used in ethers.js v5. Her tutorial was written for v5. She updates her comparisons to use BigInt literals (the n suffix) and the test passes.

Lesson 2: Verify which version of your dependencies you are using. The ethers.js v5-to-v6 migration changed numerous APIs. Tutorials from 2022-2023 often use v5 syntax. Hardhat Toolbox bundles ethers.js v6 as of Hardhat 2.19+.

She writes twelve more tests covering transfers, allowances, distributeReward, and error cases. All pass locally. She feels confident.

Day Three: The Testnet Deployment

Priya configures Sepolia in her hardhat.config.js, creates an Alchemy account for an RPC endpoint, and exports a private key from MetaMask for her testnet account. She puts the private key directly in the config file:

module.exports = {
    solidity: "0.8.20",
    networks: {
        sepolia: {
            url: "https://eth-sepolia.g.alchemy.com/v2/abc123...",
            accounts: ["0x1234...deadbeef"]  // Her actual private key
        }
    }
};

She runs npx hardhat run scripts/deploy.js --network sepolia and gets:

Error: insufficient funds for intrinsic transaction cost

She forgot to get test ETH from a faucet. She visits three faucets before finding one that is not rate-limited or broken (faucets are notoriously unreliable), waits 10 minutes for the test ETH to arrive, and re-runs the deployment. This time it works:

RewardsToken deployed to: 0x7B4f352Cd40114f12e82fC9b4b9Bc1c6e45f8684

She commits her code to GitHub, including the hardhat.config.js with the private key. She pushes. Two minutes later, Marcus messages her: "Priya, you pushed a private key to GitHub. Rotate it immediately."

She panics. Marcus explains: automated bots continuously scan GitHub for private keys and Ethereum addresses. Even though this is a testnet key with no real value, the habit of pushing keys is catastrophic if it ever happens with a mainnet key. Marcus has seen it happen at a previous company — a developer pushed a deployment key, and within four minutes, bots had drained $43,000 from the associated mainnet account.

Priya rotates the key (creates a new MetaMask account), installs dotenv, moves the private key and RPC URL to a .env file, adds .env to .gitignore, and force-pushes to remove the key from Git history. The entire process takes two hours, including learning about git filter-branch to scrub the key from historical commits.

Lesson 3: Never commit secrets to version control. Use environment variables loaded from .env files that are excluded via .gitignore. Use dotenv or Hardhat's built-in environment variable support. Treat this as a non-negotiable rule from day one, not something you will "fix later."

Day Three, Continued: The Zero-Address Bug

Marcus reviews her deployed contract on Sepolia Etherscan and immediately spots a problem: the transfer and distributeReward functions do not check for the zero address. He asks Priya to send 1 token to 0x0000000000000000000000000000000000000000 on the testnet deployment.

She does, and the transaction succeeds. One token is now permanently locked at the zero address, irrecoverable. On mainnet, this would be a real loss.

"But who would send tokens to the zero address?" Priya asks.

"Nobody intentionally," Marcus replies. "But frontend bugs, misconfigured scripts, and copy-paste errors happen constantly. A user copies an address from a block explorer, accidentally selects the transaction hash instead, and pastes a string that ethers.js truncates or pads to the zero address. Your contract should be the last line of defense."

She adds zero-address checks to transfer, transferFrom, and distributeReward. But she cannot update the deployed contract. It is immutable. She must deploy a new version. The old contract still exists on Sepolia, at the same address, with the same bug, forever.

Lesson 4: Deployed code is immutable. Every bug you deploy lives forever. This is fundamentally different from web development, where you can push a fix in minutes. In smart contract development, the fix is a new deployment, and any users who interacted with the old contract are affected by the old bugs.

Day Four: The Reentrancy Education

During the code review for the new deployment, Marcus asks Priya about the Checks-Effects-Interactions pattern. She has not heard of it. He explains:

"Your distributeReward function modifies state (balances) before the function ends. That is correct. But what if you had written it differently — what if you called an external contract before updating the balance?"

He shows her a hypothetical vulnerable version:

function distributeReward(address to, uint256 amount) external {
    require(msg.sender == owner, "Only owner");
    require(balanceOf[owner] >= amount, "Insufficient reward pool");
    // DANGEROUS: External call before state update
    (bool success,) = to.call{value: 0}("");
    require(success);
    balanceOf[owner] -= amount;
    balanceOf[to] += amount;
}

If to is a malicious contract, it could re-enter distributeReward during the .call, before the balance is updated. The require(balanceOf[owner] >= amount) check would pass again because the balance has not been decreased yet. The attacker could drain the entire reward pool.

"Your contract does not have this bug because you are not making external calls," Marcus says. "But you need to internalize the pattern now, because you will write contracts that do make external calls. Always: checks first, then state changes, then external interactions."

Lesson 5: Learn the Checks-Effects-Interactions pattern before you need it. The pattern is simple — validate inputs, update state, then interact with external contracts — but violating it has caused hundreds of millions of dollars in losses across the Ethereum ecosystem.

The Final Deployment

Priya deploys the corrected contract to Sepolia. She verifies it on Etherscan using Hardhat's verification plugin. She writes a deployment report documenting the contract address, constructor arguments, compiler version, and optimizer settings. Marcus approves the deployment for the team demo.

The total time: four days for a "simple" ERC-20 token with one custom function. But Priya has learned lessons that no tutorial teaches:

  1. Development environment configuration is not trivial — version mismatches between compiler, framework, and libraries cause real delays
  2. Testing in an isolated environment (Remix, local Hardhat) does not catch deployment and configuration issues
  3. Secret management is a day-one concern, not an afterthought
  4. Deployed code is permanent — every bug is forever
  5. Security patterns (zero-address checks, Checks-Effects-Interactions) must be habitual, not aspirational

Postscript: Three Months Later

Three months into the job, Priya reviews code from a new hire who has written a staking contract. She spots a missing zero-address check in the stake function and a potential reentrancy vector in the withdraw function. She flags both in the code review. The new hire asks, "How did you catch those so fast?"

"I made those exact mistakes," Priya says. "On my first contract."

Discussion Questions

  1. Priya's four-day timeline for a simple ERC-20 token might seem slow, but experienced smart contract developers often cite similar timelines for their first deployment. Why does smart contract development have a steeper learning curve than traditional web development, even for experienced programmers?

  2. Marcus caught the private key exposure within minutes. What organizational practices could prevent this from happening in the first place? Consider: CI/CD pipeline checks, pre-commit hooks, onboarding checklists, and repository scanning tools.

  3. The immutability of deployed contracts means that Priya's buggy first deployment lives on Sepolia forever. In what ways is immutability a feature rather than a bug? In what ways is it genuinely problematic? How do proxy patterns (which we will study in Chapter 14) attempt to address this tension?

  4. Priya learned the Checks-Effects-Interactions pattern from a code review conversation. How would you structure a training program for junior smart contract developers to ensure they learn security patterns before deploying to production? What role should formal verification, static analysis tools, and security audits play?

  5. Compare Priya's development workflow (Remix prototype, then Hardhat, then testnet, then mainnet) to a typical web development workflow (local development, staging environment, production). What are the analogies and where do they break down?