Case Study 2: Building on Testnet — The Developer's Reality

Background

Priya Anand is a software engineer with five years of experience building web applications. She has worked with React, Node.js, PostgreSQL, and AWS. She took a blockchain development course, completed the progressive project in a textbook much like this one, and decided to build a governance dApp for her local tech community — a token-weighted voting system for allocating a small community fund to proposed projects.

She had working contracts, passing tests, and a frontend that functioned perfectly on her local Hardhat network. The next step was deploying to a testnet — the last stop before mainnet. This case study documents what happened next, and the lessons that every developer learns the hard way when they leave the comfort of local development.

Week 1: The Faucet Problem

Priya chose Sepolia as her testnet, following current best practices. Her first task was to get testnet ETH from a faucet so she could pay for deployment gas.

Day 1: She visited the Sepolia faucet linked in the Hardhat documentation. The faucet required her to log in with an Alchemy account. She created an account, requested 0.5 Sepolia ETH, and received it within 30 seconds. So far, so good.

Day 2: She needed more ETH for her second deployment attempt (the first had a bug). The faucet had a 24-hour cooldown. She could not get more ETH until the next day.

Day 3: She tried a different faucet. It required her to tweet her address to prove she was a real person. She tweeted, submitted the link, and received an error: "Faucet is temporarily out of funds."

Day 4: She tried yet another faucet. This one required a minimum mainnet balance of 0.001 ETH to prevent bots. She did not have any mainnet ETH. She had to buy a small amount on Coinbase, transfer it to her MetaMask wallet, and then use the faucet. The whole process took two hours.

Lesson learned: Testnet faucets are unreliable. They run out of funds, impose rate limits, require social verification, or demand mainnet balances. For serious development, budget time for faucet issues and keep multiple faucet URLs bookmarked. Some teams set up internal faucets for their developers.

💡 Practical Tip: When you do get testnet ETH, get more than you think you need. Deployment, testing, and debugging all consume gas. Running out of testnet ETH in the middle of a debugging session is frustrating and costs hours of waiting for faucet cooldowns.

Week 2: The Deployment Debugging

With testnet ETH in hand, Priya ran her deployment script:

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

Problem 1: Nonce too low. Her first deployment attempt failed with nonce too low. She had used the same wallet for testing on a different project, and the nonce was out of sync. She fixed it by resetting her MetaMask nonce (Settings > Advanced > Clear Activity Tab Data).

Problem 2: Gas estimation failure. The deployment script worked locally but failed on Sepolia with cannot estimate gas; transaction may fail or may require manual gas limit. The issue was that her constructor called an external contract (the TimelockController) that had not been deployed yet at that point in the script. The deployment order mattered, and she had to restructure the script to deploy contracts in the correct sequence.

Problem 3: Insufficient gas. She set a manual gas limit, but the deployment still failed. The error message was unhelpful: transaction execution reverted. After 45 minutes of debugging, she discovered that her constructor was trying to call grantRole() on the TimelockController, but the deployer did not have the admin role because she had passed the wrong parameters to the TimelockController's constructor.

// WRONG: Empty admin means no one can configure the timelock
const timelock = await TimelockController.deploy(3600, [], [], ethers.ZeroAddress);

// RIGHT: Deployer is the initial admin (revoked later)
const timelock = await TimelockController.deploy(3600, [], [], deployer.address);

This bug did not manifest locally because Hardhat's error messages included the revert reason. On Sepolia, the revert reason was not always propagated through the RPC provider, making debugging much harder.

Lesson learned: Deployment order matters. Constructor arguments that reference other contracts must be valid at deployment time. Error messages on live networks are less informative than on local networks. Add explicit checks and logging to your deployment scripts.

Week 3: The Frontend Disconnect

With contracts deployed on Sepolia, Priya opened her frontend, clicked "Connect Wallet," and saw... nothing.

Problem 1: Wrong network. Her MetaMask was connected to Ethereum mainnet. The frontend did not check which network the user was on and did not prompt a network switch. She added network detection:

const network = await provider.getNetwork();
if (network.chainId !== 11155111n) { // Sepolia chain ID
    try {
        await window.ethereum.request({
            method: 'wallet_switchEthereumChain',
            params: [{ chainId: '0xaa36a7' }] // Sepolia in hex
        });
    } catch (error) {
        showError('Please switch to the Sepolia testnet in MetaMask.');
    }
}

Problem 2: Contract address mismatch. She had hardcoded the contract addresses from her local deployment in app.js. The Sepolia addresses were different. She updated the addresses but realized she needed a better system — an environment-specific configuration file that the deployment script generates automatically.

Problem 3: ABI mismatch. She had modified the contract after generating the ABI but before redeploying. The ABI in the frontend did not match the deployed contract. Calls failed with cryptic errors. She learned to always regenerate and copy the ABI after every deployment:

npx hardhat compile
# The ABI is in artifacts/contracts/VotingDApp.sol/VotingDApp.json

Problem 4: Transaction confirmation time. On her local Hardhat network, transactions confirmed instantly. On Sepolia, they took 12-15 seconds. Her frontend did not show a loading state, so the user experience was confusing — the user clicked "Vote," nothing appeared to happen for 15 seconds, and then the page suddenly updated. She added transaction status tracking:

showStatus('Transaction submitted. Waiting for confirmation...');
showTransactionLink(tx.hash); // Link to Sepolia Etherscan
const receipt = await tx.wait();
showSuccess(`Confirmed in block ${receipt.blockNumber}`);

Lesson learned: The gap between local and testnet is enormous. Network detection, address management, ABI synchronization, and transaction timing all behave differently on a live network. Test every user flow on the testnet before declaring the frontend complete.

Week 4: The Governance Lifecycle on Testnet

With contracts deployed and the frontend connected, Priya ran through the full governance lifecycle on Sepolia. Each step revealed new challenges.

Creating a proposal: This worked on the first try. Gas cost was 0.003 Sepolia ETH. The IPFS upload of proposal metadata to Pinata succeeded, and the CID was stored in the proposal description.

Waiting for the voting delay: In her local tests, she used mine(2) to fast-forward past the voting delay. On Sepolia, she had to wait for actual blocks to be mined. Her voting delay was set to 1 block (about 12 seconds), so this was quick. But she realized that a production voting delay of 1 day (~7,200 blocks) would make testnet iteration extremely slow.

Casting votes: She connected a second MetaMask account (her "voter" account) and voted. The transaction succeeded, but the frontend did not update to show the new vote count. The problem was that her frontend was reading vote counts from an RPC call (governor.proposalVotes(proposalId)) which returned the state at the latest block. The block that included her vote had not yet been indexed by her RPC provider. She added a 2-second delay after tx.wait() before refreshing the display — an inelegant but effective solution.

Waiting for the voting period to end: Her voting period was set to 50,400 blocks (about 1 week at Ethereum's 12-second block time). On Sepolia, with the same block time, this meant she had to wait a week to test the queue and execute steps. She redeployed with a voting period of 10 blocks (about 2 minutes) for testing purposes.

Lesson learned: Governance parameters that are appropriate for production (1-day voting delay, 1-week voting period, 48-hour timelock) are impossibly slow for testnet iteration. Use short parameters for testnet testing and document the production values separately. Consider creating a "test mode" deployment with abbreviated timelines.

Queueing the proposal: After the voting period ended, she called queue(). This failed with an error she had never seen before: TimelockController: operation already scheduled. After investigation, she discovered that she had accidentally called queue() twice (once from the frontend and once from a debugging script). The timelock had already scheduled the operation on the first call, and the second call failed because the operation ID was already in the queue.

Executing the proposal: After the timelock delay (which she had set to 60 seconds for testing), she called execute(). It worked. The proposal was executed, the target contract state was updated, and the event was emitted. She checked the transaction on Sepolia Etherscan and saw the full execution trace.

Week 5: The Etherscan Verification Struggle

Priya wanted to verify her contracts on Sepolia Etherscan so that her community members could read the source code and interact with the contracts directly.

Problem 1: Compiler version mismatch. Her local Hardhat used Solidity 0.8.20, but Etherscan expected the exact compiler version including the commit hash. She had to ensure her hardhat.config.js specified the exact version.

Problem 2: OpenZeppelin imports. Her contracts imported from @openzeppelin/contracts. Etherscan needed to see the full source of all imported files. The hardhat-verify plugin handles this by flattening the source or submitting the standard JSON input. She used the plugin:

npx hardhat verify --network sepolia 0xDeployedAddress arg1 arg2

Problem 3: Constructor arguments encoding. The TimelockController constructor took an array of addresses, which required special encoding. She created a constructor-args.js file:

module.exports = [
    3600,           // minDelay
    [],             // proposers
    [],             // executors
    "0xDeployer..." // admin
];

And ran:

npx hardhat verify --network sepolia --constructor-args constructor-args.js 0xTimelockAddress

After three attempts and extensive documentation reading, all three contracts were verified. She could now see the full source code on Etherscan, and her community members could interact with the contracts directly through the Etherscan interface.

Lesson learned: Contract verification is not optional and not trivial. Plan for it from the beginning. Test the verification process on testnet before mainnet, where failed verification wastes real money and time.

The Final State

After five weeks of testnet development, Priya had: - Three verified contracts on Sepolia Etherscan. - A frontend that handled network detection, wallet connection, and transaction status. - A complete governance lifecycle tested end-to-end on a live network. - A deployment script with proper error handling, logging, and role configuration. - A list of 23 bugs that did not exist on her local Hardhat network.

She estimated that 70% of her total development time was spent on testnet-specific issues that could not have been discovered locally. The smart contracts themselves — the Solidity code — accounted for perhaps 20% of the total effort. The remaining 80% was frontend integration, deployment scripting, error handling, and testnet debugging.

Discussion Questions

  1. Priya's deployment script had a critical bug (passing ethers.ZeroAddress as the TimelockController admin). How could this bug have been caught before testnet deployment? Design a deployment test that verifies the configuration is correct.

  2. The 50,400-block voting period made testnet iteration impractical. How would you design a deployment configuration system that uses different parameters for different environments? Consider Hardhat's network-specific configuration and deployment scripts that accept environment variables.

  3. Priya's frontend did not detect the wrong network until she manually discovered the issue. What other user-facing checks should a dApp perform at startup? List at least five checks beyond network detection.

  4. Is five weeks a reasonable timeline for testnet development of a governance dApp? What could Priya have done to accelerate the process? Consider tools, frameworks, and workflow improvements.

  5. Priya estimates that 80% of her effort was non-Solidity work. Does this match your experience with the progressive project? What implications does this have for blockchain developer education, which often focuses primarily on smart contract development?

Key Takeaways

  • Testnet deployment reveals a class of bugs invisible to local testing: network latency, gas estimation, nonce management, RPC provider behavior, and block confirmation timing.
  • Faucets are unreliable infrastructure. Budget time and maintain multiple faucet sources.
  • Governance parameters must be adapted for testing. Production-appropriate voting periods and timelock delays make testnet iteration impossibly slow.
  • Contract verification is a skill. It requires understanding compiler versions, import flattening, and constructor argument encoding.
  • The smart contract is the easy part. Most development time goes to the integration layer: frontend, deployment scripts, error handling, and user experience.
  • Every deployment teaches something. Keep a deployment log. Document every error and its resolution. Your future self will thank you.