Skip to main content

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

publicexternalinternalprivate
Called from inside the contractYesOnly via this.f()YesYes
Called from a derived contractYesOnly via this.f()YesNo
Called from outsideYesYesNoNo

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");
  • require validates inputs and conditions before doing work. Refunds remaining gas.
  • revert aborts and undoes state changes. Refunds remaining gas. Use it where the condition needs surrounding logic.
  • assert checks 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 failed call returns false rather than reverting. Ignoring it is how funds go missing silently.
  • Guard against reentrancy. call hands control to the recipient, which may call back in. Apply checks-effects-interactions — update state before the external call — and add ReentrancyGuard where 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:

PatternMechanism
Transparent proxyProxy delegates to an implementation; admin calls handled separately
UUPSUpgrade 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.