Solidity
Solidity is a statically typed, contract-oriented language that compiles to EVM bytecode. Syntactically it borrows from JavaScript and C++, which makes the first hour deceptively comfortable and the first week surprising — because the execution environment, not the syntax, is what you're actually learning.
Four properties change how you write code. Deployed bytecode is immutable, so a bug is permanent unless you designed for upgrades. Every operation costs money, so a loop over an unbounded array is a denial-of-service vector rather than a performance note. All state is publicly readable, regardless of what you mark private. And any function you expose can be called by anyone, in any order, from another contract, including in the middle of your own execution.
Writing Solidity well is mostly about internalizing those four facts.
TL;DR
- Contracts are like classes: state variables, functions, inheritance, and a constructor that runs once at deployment.
- Storage is persistent and expensive; memory is temporary and cheap; calldata is read-only and cheapest.
privaterestricts contract access, not visibility — anyone can read storage directly off-chain.- Custom errors (
error Unauthorized()) are far cheaper thanrequirestrings and are the modern default. - Events are the only practical way for off-chain systems to observe what happened.
- Checks-Effects-Interactions: validate, update state, then call external contracts. This prevents reentrancy.
- Pack storage variables into 32-byte slots; storage writes dominate gas costs.
- Deployed code is immutable — plan for proxy upgrade patterns before deployment or accept permanence.
- Use Foundry: tests in Solidity, fuzzing built in, mainnet forking, fast.
Quick Example
A complete, modern contract showing the idioms you'll use constantly:
Note the ordering in withdraw: every check first, then the state update, and only then the external transfer. That sequence is not stylistic — it's what makes the function safe against a malicious token contract calling back into it.
Core Concepts
Data locations
Using memory where calldata would do — the most common gas waste in beginner contracts — copies the entire array for no reason.
Storage packing
Storage is addressed in 32-byte slots. Variables that fit are packed together, and one slot write is one cost.
The rule is to declare small types adjacently. Note that packing costs a little extra gas to read and mask individual fields, so it's a win when you write the slot and a marginal loss when you only ever read one field of it.
Visibility
⚠️ Warning:
privatemeans "other contracts cannot call or reference this." It does not mean the data is hidden. Anyone can read any contract's storage slots directly witheth_getStorageAt. Never store secrets on-chain.
State mutability
view and pure functions cost nothing when called off-chain, because the caller's node computes them locally without a transaction. They still cost gas when called from within a transaction.
Custom errors vs. require strings
Custom errors save deployment gas and revert gas, and they give clients structured data to decode rather than a string to parse.
Events
Events write to the transaction log — cheap, not readable by contracts, and the only practical way for anything off-chain to know what happened.
Index the fields you'll filter on (addresses, IDs), leave the rest unindexed to save gas. Every state change an interface needs to display should emit an event — reconstructing history by reading storage is not viable. See Web3 Frontends.
Modifiers
Modifiers are inlined at every use site, so a heavy modifier applied to twenty functions inflates the bytecode twenty times. For anything substantial, call an internal function from the modifier instead.
Upgradeability
Deployed bytecode cannot be changed. If a contract must be fixable, the standard approach is a proxy: a permanent address holding the storage that delegatecalls into a swappable implementation.
Storage layout compatibility is the hard constraint: never reorder, remove, or change the type of an existing variable in a new implementation. Append only, and use a storage gap or ERC-7201 namespaced storage to reserve room. OpenZeppelin's upgrade plugins validate this automatically, and skipping that validation is how upgrades corrupt state.
Upgradeability is also a centralization tradeoff worth stating plainly: whoever controls the upgrade key can replace the contract's behavior entirely. Serious deployments put that key behind a multisig and a timelock so users can exit before a change takes effect.
Testing with Foundry
Foundry runs tests written in Solidity, which removes the language switch and makes fuzzing trivial.
Fuzz and invariant testing matter far more here than in ordinary software, because your contract's callers are adversarial. An invariant test asserts a property that must always hold — "the sum of all balances equals totalDeposits" — across randomly generated sequences of calls, which is exactly how the interesting bugs are found.
Mainnet forking (vm.createSelectFork(RPC_URL)) lets you test against live protocols with real state, which is where most integration bugs actually live.
Best Practices
Follow Checks-Effects-Interactions everywhere
Validate inputs, update all state, and only then make external calls. This ordering alone prevents the majority of reentrancy bugs, and it's cheaper than a reentrancy guard. Use nonReentrant as defense in depth, not as a substitute.
Use OpenZeppelin rather than writing primitives
Token standards, access control, pausability, reentrancy guards, and proxies are all subtly harder than they look and are all audited in OpenZeppelin. Reimplementing ERC-20 is a well-known way to ship a bug.
Use SafeERC20 for token transfers
Some widely used tokens (USDT among them) don't return a boolean from transfer, breaking naive calls. Others charge fees on transfer, so the amount received differs from the amount sent. SafeERC20 handles the return-value cases; fee-on-transfer needs an explicit balance-difference measurement.
Avoid unbounded loops
An unbounded loop over user-controlled data is a permanent denial of service waiting to happen.
Prefer call to transfer for sending ETH
Combine this with Checks-Effects-Interactions, since call forwards all gas and therefore permits reentrancy.
Use immutable and constant where possible
Both are baked into bytecode rather than read from storage, turning a 2,100-gas cold SLOAD into effectively nothing. Any value fixed at deployment should be immutable; any compile-time constant should be constant.
Emit events for every meaningful state change
Indexers, front ends, monitoring, and incident forensics all depend on logs. A state change with no event is invisible to everything off-chain.
Pin the compiler version
A floating ^0.8.0 means your verified source may compile to different bytecode later. Pin exactly in production contracts and record the version in your deployment artifacts.
Common Mistakes
External call before the state update
Using tx.origin for authorization
On-chain randomness
Missing access control on a critical function
Missing or wrong access control causes more losses than any other single bug class.
Assuming a token transfer succeeded
Reordering storage variables in an upgrade
FAQ
Solidity or Vyper?
Solidity, unless you have a specific reason. It has the overwhelming majority of tooling, libraries, audit expertise, and documentation. Vyper is a deliberately restricted, Python-like alternative that omits inheritance, modifiers, and function overloading to make contracts easier to audit — a defensible choice for small, high-value contracts, and a much smaller ecosystem.
How much does deployment cost?
gas_used × gas_price. A simple contract might use 500,000–2,000,000 gas; on Ethereum mainnet at 30 gwei that's meaningful money, and on an L2 it's cents. Bytecode size is also capped (24 KB per EIP-170), which large contracts hit — splitting into libraries or the diamond pattern is the usual escape.
Do I need to optimize gas?
Optimize the obvious things always — storage packing, calldata over memory, immutable/constant, custom errors — because they're free to do correctly from the start. Beyond that, optimize where transactions are frequent, and never at the cost of clarity in security-relevant code. On L2s, calldata size often matters more than execution gas, which changes what's worth optimizing.
Should my contract be upgradeable?
It's a real tradeoff, not a default. Upgradeability lets you fix bugs and adapt, at the cost of a trusted upgrade key that can change everything — which users must trust and which is itself an attack target. The common middle ground: upgradeable during early life behind a multisig and timelock, with a documented path to renouncing the upgrade key once the contract is proven.
How do I get my contract audited?
Prepare first: complete tests including fuzz and invariant suites, documented invariants and threat model, a frozen codebase, and a self-review against a checklist. Then engage a reputable firm well ahead of launch, and budget for a fix-and-review cycle. Audits are expensive and imperfect — they reduce risk rather than eliminate it. Public bug bounties (Immunefi) and formal verification of core invariants are useful complements. See Smart Contract Security.
What is unchecked and when should I use it?
Since 0.8.0, arithmetic reverts on overflow by default, which costs a small amount of gas per operation. unchecked { } skips those checks. Use it only where overflow is provably impossible — most commonly a loop counter, or a subtraction you've already bounds-checked — and add a comment explaining why. Using it to save gas without that proof reintroduces the bug class the default was added to prevent.
Related Topics
- Ethereum & the EVM — Gas, accounts, and the execution environment
- Smart Contract Security — The attack classes this page's practices defend against
- Blockchain Fundamentals — What immutability and public state actually mean
- Layer 2 Scaling — Where the same contracts run for far less
- Web3 Frontends — Calling contracts from an application
- Solana — A different contract model, in Rust
- Testing — Fuzzing and invariant testing in general
- Design Patterns — Proxies, factories, and the pull pattern