Writing my first Solidity contract: lessons from the trenches
Before I joined my current company, I had exactly zero production Solidity experience. I'd read the docs, deployed a toy contract on a testnet years ago, and that was it. Three months in, I've shipped a staking contract to mainnet and learned a lot of lessons the hard way.
Solidity is not like other languages
The execution model is completely different. You pay gas for everything. State mutations cost more than reads. Loops are dangerous at scale. Reentrancy is a real attack vector, not a theoretical one.
Coming from TypeScript, the biggest mental shift was thinking about storage carefully. Every state variable you declare costs gas to write. I initially had a struct with way more fields than necessary and had to refactor after realizing the deployment and interaction costs were unnecessarily high.
Test everything on a local fork before testnet
Hardhat's mainnet forking is fantastic. Instead of deploying to a testnet with its own limitations, you can fork mainnet locally and interact with real contracts in a sandboxed environment.
npx hardhat node --fork https://eth-mainnet.alchemyapi.io/v2/YOUR_KEY
We found two bugs this way before they ever touched a testnet. One of them was a subtle integer overflow that the Solidity version we were using didn't catch at compile time.
Slither before you ship anything
Slither is a static analysis tool for Solidity. Run it on every contract before deployment:
slither . --filter-paths "test,node_modules"
It caught a reentrancy vulnerability in my first draft that I'd overlooked. Reading the vulnerability report, it was obvious in hindsight — but I'd been staring at the code for too long to see it.
The frontend integration is where things get real
Writing the Solidity is actually the smaller part of the work. Integrating with viem on the React frontend is where the majority of the bugs lived.
Transaction lifecycle management is complex. You have pending → mining → confirmed states. Each needs different UI treatment. Failures can happen at the wallet signing stage, at broadcast, or during mining. The error messages from wallet providers are inconsistent across MetaMask, WalletConnect, and others.
We ended up building a thin abstraction over the transaction lifecycle that normalized errors and state transitions:
type TxState =
| { status: 'idle' }
| { status: 'awaiting-signature' }
| { status: 'pending'; hash: string }
| { status: 'confirmed'; receipt: TransactionReceipt }
| { status: 'failed'; reason: string };
Having that discriminated union meant the UI components could be exhaustive and never show the wrong state accidentally.
What I'd do differently
I'd spend more time on test coverage upfront. Solidity unit tests feel tedious but they're more important here than in web development because you can't patch a deployed contract. Once it's on mainnet, it's there.
The Foundry testing framework is also worth learning if you haven't — the test DX is significantly better than Hardhat's for complex scenarios. I switched partway through and regretted not doing it from the start.