Solidity
Statically typed, compiled to EVM bytecode. Pin the compiler version in every
file — pragma solidity 0.8.24; rather than ^0.8.0 — so a contract is always
built with the compiler it was tested and audited against.
Visibility
public | external | internal | private | |
|---|---|---|---|---|
| Called from inside the contract | Yes | Only via this.f() | Yes | Yes |
| Called from a derived contract | Yes | Only via this.f() | Yes | No |
| Called from outside | Yes | Yes | No | No |
external allows arguments to be read straight from calldata, skipping the copy
into memory that public needs in order to also support internal calls. For a
function only ever called externally with large array arguments, that saving is
substantial.
private restricts code access, not visibility of data. Anything in storage
is readable off-chain with eth_getStorageAt — see
EVM and bytecode.
State mutability
function readTotal() external view returns (uint256) { return total; }
function add(uint a, uint b) external pure returns (uint) { return a + b; }
view— will not modify state, but may read it.pure— will not read or modify state.
Neither costs gas when called externally as an eth_call, because nothing is
mined. Called internally from a transaction they cost normally — the
computation still happens on-chain.
payable
payable is a state mutability specifier, not a modifier, though it appears in
the same position. A function without it rejects any call carrying value, and an
address must be payable to receive ether via transfer or send.
contract OnlineStore {
function buySomething() external payable {
require(msg.value == 0.001 ether, "Incorrect payment");
transferThing(msg.sender);
}
}
Calling it with ethers v6:
await store.buySomething({ value: ethers.parseEther("0.001") });
Since Solidity 0.8, msg.sender is address rather than address payable. This
is the cause of:
Member "push" not found or not visible after argument-dependent lookup in
address payable[] storage ref
The array wants address payable; convert explicitly:
players.push(payable(msg.sender));
The same code compiles unchanged on 0.7, which is why tutorials from that era fail here.
require, revert, assert
require(withdrawAmount < 1 ether, "Cannot withdraw more than 1 ether");
requirevalidates inputs and conditions before doing work. Refunds remaining gas.revertaborts and undoes state changes. Refunds remaining gas. Use it where the condition needs surrounding logic.assertchecks invariants that should never be false. A failing assert means a bug in the contract.
Before Solidity 0.8, assert consumed all remaining gas via the invalid opcode
while require refunded it. Since 0.8 both revert, but they still emit
different error selectors: assert produces Panic(uint256) and require
produces Error(string). Tooling distinguishes them, so use each for its
intended meaning.
Custom errors are cheaper than revert strings and are the current idiom:
error InsufficientBalance(uint256 available, uint256 required);
if (balance < amount) revert InsufficientBalance(balance, amount);
A revert string is stored in the contract's bytecode, paid for at deploy time and encoded again on every revert. A custom error is a 4-byte selector plus ABI-encoded arguments.
Modifiers
modifier limitWithdraw(uint256 withdrawAmount) {
require(withdrawAmount <= 0.1 ether, "Cannot withdraw more than 0.1 ether");
_;
}
_; marks where the function body runs. Code before it executes as a
precondition, code after it as a postcondition — which is how reentrancy guards
work.
Modifier code is inlined at every use site, so a large modifier applied widely inflates the bytecode. Have it call an internal function when the body is more than a check or two.
Events
event Transfer(address indexed from, address indexed to, uint256 value);
indexed parameters become searchable log topics, so clients can filter on
them. At most three parameters may be indexed — the fourth topic slot holds
the event signature hash. Anonymous events free that slot but lose the
signature.
Indexed dynamic types (string, bytes, arrays) are stored as the hash of the
value, not the value. You can filter for a known string but cannot read it back
out of the log.
Events are the only practical way for a contract to communicate with off-chain software. Contracts cannot read their own logs.
Interfaces
interface ICounter {
function increment() external;
function count() external view returns (uint256);
}
Rules:
- May inherit from other interfaces, but not from contracts.
- No constructor.
- No state variables.
- All functions must be
external, and have no body.
Calling other contracts
(bool success, bytes memory data) = addr.call{value: msg.value, gas: 5000}("");
require(success, "Call failed");
The braces carry call options — value and gas — and the parentheses carry
the ABI-encoded calldata. Passing an empty string invokes the recipient's
receive or fallback function.
call is the recommended way to send ether. transfer and send forward a
fixed 2300 gas stipend, which sufficed until EIP-1884 raised the cost of
SLOAD; contracts with a non-trivial receive now run out of gas and the
transfer fails.
Two rules when using call:
- Always check
success. A failedcallreturnsfalserather than reverting. Ignoring it is how funds go missing silently. - Guard against reentrancy.
callhands control to the recipient, which may call back in. Apply checks-effects-interactions — update state before the external call — and addReentrancyGuardwhere the flow is not obviously safe.
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount, "Insufficient balance");
balances[msg.sender] -= amount; // effect first
(bool ok, ) = msg.sender.call{value: amount}(""); // interaction last
require(ok, "Transfer failed");
}
Snippets
// Address literal — checksummed, no quotes
address owner = 0xf7a35Eef60dC35fa2D3188Dfb22e635E4308fc8b;
// Simple allowlist. Cheap to read, expensive to populate:
// one SSTORE per address, paid by whoever deploys or updates it.
mapping(address => bool) public allowList;
if (allowList[msg.sender]) { /* ... */ }
For a large allowlist, a Merkle tree is the standard approach — store one 32-byte root and have each caller supply a proof, moving the cost off-chain and onto the claimant:
bytes32 public merkleRoot;
function claim(bytes32[] calldata proof) external {
bytes32 leaf = keccak256(abi.encodePacked(msg.sender));
require(MerkleProof.verify(proof, merkleRoot, leaf), "Not allowed");
// ...
}
Hash the leaf twice, or include a domain separator, to prevent second-preimage
attacks in which an internal node is passed off as a leaf. OpenZeppelin's
MerkleProof documents the pattern.
Upgrade patterns
The Truffle Migrations contract shipped this shape:
function upgrade(address new_address) public restricted {
Migrations upgraded = Migrations(new_address);
upgraded.setCompleted(last_completed_migration);
}
This is migration bookkeeping, not contract upgradeability — it points the migration record at a new instance. Real upgradeability keeps one address and swaps the implementation behind a proxy:
| Pattern | Mechanism |
|---|---|
| Transparent proxy | Proxy delegates to an implementation; admin calls handled separately |
| UUPS | Upgrade logic lives in the implementation, not the proxy — cheaper to deploy |
| Diamond (EIP-2535) | Many implementation facets behind one address |
Use @openzeppelin/contracts-upgradeable with the Hardhat or Foundry upgrade
plugins. Upgradeable contracts cannot use constructors or immutable
variables, and storage layout must be append-only across versions. The plugins
check both, which is the main reason to use them rather than hand-rolling a
proxy.