T7 · Solidity Basics
What does a contract become once it is deployed onchain?
- Capabilities practiced
- Builder
- Hands on
- Write a minimal deposit-and-withdrawal contract, deploy it to testnet, verify its source in a block explorer, and call its functions.
- AI Lab
- Have AI write comments and NatSpec for your contract. Check yourself whether it describes permissions incorrectly.
A Real-World Problem
The product team wants deposit escrow: users put money in, can withdraw it at any time, and the platform can step in when a dispute arises.
You have built dozens of services like this and could put together a first version in twenty minutes. But this time, “going live” is unlike any launch you know:
- The code is public. Anyone can decompile it, and most projects publish the source themselves.
- The database is public. The whole world can inspect how much each user deposited.
- There is no Nginx, gateway, or login page. Anyone can call any externally exposed function directly, using their own script without visiting your frontend.
- There is no staged rollout, rollback, or hotfix. Changing one line of code means changing the address.
The same business logic becomes something else in this environment. Before writing it, answer one question: What does a contract actually become after deployment?
Thought Experiment
Imagine a deployed contract as a vending machine in a public square.
It sits at a fixed location (an address). Its mechanism is welded in place (the code cannot change). The goods and money inside belong to the machine (storage). The panel has a row of buttons (external functions).
Now add four ways it differs from a real vending machine:
- There are no business hours and no attendant. Anyone can press any button at any time.
- The glass is completely transparent. Everyone can see how much money is inside and what each compartment holds. There is no “admin-only view.”
- The person pressing the buttons chooses their order and frequency. No one guarantees they will follow your intended flow. They can press only the third button ten thousand times.
- The machine knows who is pressing. Each press carries an identity marker that cannot be forged.
Point 4 is your only foothold. In a contract, “only I can do this” has just one expression: when this button is pressed, check whether the person pressing it is me.
Put the original deposit escrow into this machine. Anyone can press Deposit (reasonable). Anyone can press Withdraw (but only for their own money). What should the “platform intervention” button look like? Who can press it, and what happens when they do?
- One address = welded-in code + public storage
- Anyone can press any button any number of times
- There is no undo button after a press
Your Decision
How should the “platform intervention” button work? Choose one of four designs.
What Happens
Put the four choices together and the same structure emerges:
| Your design | What users must trust | Worst outcome if the key leaks | Can users spot it early? |
|---|---|---|---|
| No administrator | Only the code | No effect on user funds | Nothing to spot |
| Administrator can withdraw | You and your key management | All funds | No; the money disappears at once |
| Administrator can only pause | The code, plus some availability risk | The service is maliciously paused | Yes, with limited impact |
| Withdrawal requires notice and delay | You, with an observation period | All funds, two days later | Yes, with two days to respond |
Three points are worth remembering:
First, a permission is code, not a promise. “We will not touch user funds” on a website means nothing. Either the function does not exist in the code at all, or it exists but requires a two-day wait. Readers will inspect your contract, not your website.
Second, the granularity of permissions determines the scale of an incident. Putting both “pause” and “withdraw” under the same owner ties a small power and a large one to the same key.
Third, the chain will not automatically tell others what happened. The “announcement” above works because you deliberately emit a record. If you merely change a variable without emitting a record, monitoring systems and users will not see it. T1 explained that observability is part of contract design, not a built-in feature of the chain. This chapter makes that idea concrete.
Build a Model
A deployed contract is four things joined at one address.
0x1234… (one address)
├─ Code Fixed at deployment; cannot change unless you designed an upgrade path in advance
├─ Storage Persistent, public, and expensive. There are no truly “private fields” here
├─ Entrances List of externally visible functions = list of attack surfaces
└─ Records Events deliberately emitted during execution, readable only offchainTranslate your backend habits one by one and the differences become clear:
| How you do it in a backend | What it becomes in a contract |
|---|---|
| Private fields and internal methods | No real secrecy: no external entry point does not mean unreadable; anyone can read storage slots |
| Database tables | Storage slots. One write may cost hundreds of times as much as a computation (T8 goes deeper) |
| Centralized authorization middleware | No middleware. Each function checks its caller for itself |
| Logs | Events. You must choose their fields, and the contract cannot read them back |
| Staged rollout, rollback, hotfix | None. Only switches and upgrade paths you designed beforehand |
| Frontend validation | The frontend can be bypassed entirely. Validation must also happen in the contract |
In code, the smallest deposit contract looks like this:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4; // Custom errors are available from 0.8.4
contract Vault {
// ── Storage: these four variables are this contract's entire memory ──
address public owner;
bool public depositsPaused;
mapping(address => uint256) public balanceOf;
uint256 public totalDeposits;
// ── Records: the only voice offchain systems can hear ──
event Deposited(address indexed account, uint256 amount);
event Withdrawn(address indexed account, uint256 amount);
event DepositsPausedSet(bool paused);
event OwnerTransferred(address indexed from, address indexed to);
// ── Reasons for refusal: cheaper than require strings, and callers can identify them by type ──
error NotOwner();
error DepositsArePaused();
error ZeroAmount();
error ZeroAddress();
error InsufficientBalance(uint256 requested, uint256 available);
error TransferFailed();
modifier onlyOwner() {
if (msg.sender != owner) revert NotOwner();
_;
}
constructor() {
owner = msg.sender;
emit OwnerTransferred(address(0), msg.sender);
}
function deposit() external payable {
if (depositsPaused) revert DepositsArePaused();
if (msg.value == 0) revert ZeroAmount();
balanceOf[msg.sender] += msg.value;
totalDeposits += msg.value;
emit Deposited(msg.sender, msg.value);
}
function withdraw(uint256 amount) external {
if (amount == 0) revert ZeroAmount();
uint256 available = balanceOf[msg.sender];
if (amount > available) revert InsufficientBalance(amount, available);
// Update the ledger before paying. Reverse this order and things go badly; see T28
balanceOf[msg.sender] = available - amount;
totalDeposits -= amount;
emit Withdrawn(msg.sender, amount);
(bool ok, ) = msg.sender.call{value: amount}("");
if (!ok) revert TransferFailed();
}
// The administrator can close the valve, but cannot touch anyone's balance
function setDepositsPaused(bool paused) external onlyOwner {
depositsPaused = paused;
emit DepositsPausedSet(paused);
}
function transferOwner(address newOwner) external onlyOwner {
if (newOwner == address(0)) revert ZeroAddress();
emit OwnerTransferred(owner, newOwner);
owner = newOwner;
}
}When reading this code, read it four times through these four lenses. That is more useful than reading the syntax line by line:
- Entry points:
deposit,withdraw,setDepositsPaused,transferOwner. Four entry points mean four attack surfaces. For every new external function, ask again: “What happens if anyone calls it?” - Permissions: only two functions use
onlyOwner, and neither can touchbalanceOf. That is the “can only close the valve” row of the table above, expressed in code. - Order of state changes:
withdrawupdates the ledger and emits the event before sending the money. That order is not a matter of style. - Observability: every state change has an event, with key fields indexed. Offchain systems can reconstruct everyone’s balances from those four events. You designed that ability; it is not free.
One more detail is easy to miss: mapping looks like a hash table, but you cannot iterate over it. You cannot get a list of “all addresses that have deposited.” To get that list, you must maintain a separate array, which brings its own costs and attack surface. This is one of the most common moments when backend intuition fails in contracts.
What It Is Called
A variable declared at contract level and written to storage. It survives the transaction and forms the contract’s entire memory.
A public state variable automatically gets a read function with the same name. That merely makes reading convenient; without public, anyone can still read it. Storage is public even if there is no ready-made entry point.
A key-value storage structure. It has no length, cannot be iterated, and cannot tell you whether a key “exists.” A key never written returns the zero value of its type.
It is the most common accounting structure in contracts and the one most likely to be mistaken for a backend Map.
external and public functions join the external entry-point list and can be called by any address. internal and private functions do not create entry points.
Here, private means “no entry point,” not “secret.” Anyone can still read the data it marks from storage.
A reusable preliminary check written in a function signature and run before the function body. _; marks where the original function body is inserted.
Its value is putting the permission constraint on the function-signature line, so a reviewer can quickly see which functions are restricted.
A structured record that a contract deliberately writes to a transaction receipt during execution for offchain systems to read.
Two hard properties: the contract itself cannot read it, and if it is not emitted, it does not exist. Fields marked indexed can be filtered efficiently, with at most three per event. When designing events, ask what offchain systems must reconstruct from them.
Stops execution and reverses every state change made in that transaction, but still charges Gas.
It is the contract’s only way to “refuse.” Custom errors cost less than strings and let callers branch by error type.
Hands-on
The goal is not a complicated contract. It is to go through the entire process of turning code into an address and see for yourself what the outside world can access.
Type out the Vault above. Do not copy and paste it. The compiler will correct you several times as you type, and those corrections are central to this chapter.
Run four paths locally: a normal deposit, a normal withdrawal, withdrawal beyond the balance (must be rejected), and a call to setDepositsPaused from another address (must be rejected).
The last two matter more than the first two. The quality ceiling of contract code is set by its ability to reject bad actions. T12 will turn that sentence into a method.
Deploy to a testnet. Get test tokens from a faucet, then deploy. Record three things: the contract address, the deployment transaction hash, and the block height at construction.
Use a testnet throughout. Never use a private key holding real assets. Create a separate wallet for this exercise and discard it afterward.
Verify the source on a block explorer. Submit the source, compiler version, and optimization settings. The explorer will compare the bytecode.
After verification, refresh the page. You will see something important: the explorer has automatically generated an interface for reading and writing. Your contract now has a frontend you never wrote, and anyone can use it. That is what “an entry point is an attack surface” looks like in practice.
Call it through the explorer interface. Deposit and withdraw once. Then switch to another wallet and try setDepositsPaused. Observe the failure and the error message.
Read storage. Use the RPC method eth_getStorageAt to read slot 0 directly. You will find the owner’s address.
Now try an experiment: add address private secretAdmin; to the contract, redeploy, and read storage again. It is still there for anyone to read. Once you have seen this, you will never mistake private for secrecy again.
Collect the events. Use eth_getLogs filtered by your contract address. Fetch Deposited and Withdrawn, calculate each address’s current balance using only those events, and compare that with balanceOf.
If they match, your event design is complete. If not, you omitted an event for a state change. You can still fix it now; after mainnet deployment, it is too late.
AI Lab
Give the model the Vault you just wrote without modifications. Ask in two steps.
Step one:
Complete the NatSpec comments for this contract. For every function, explain what it does,
who can call it, when it reverts, which state it changes, and which events it emits.
Do not change any code logic.
Step two:
Now act as an auditor. For each function, answer: if a completely unknown address calls it
with arbitrary arguments and any number of times, what is the worst that can happen?
Describe only situations actually possible in the code. Do not suggest improvements.The first answer will look highly professional, which is precisely the danger. When the model writes comments, it is completing documentation that looks plausible; it is not necessarily reading your code. A classic failure: it says “only the contract owner can call this” about a function that does not have onlyOwner. Once comments and code diverge, everyone who reads the code later, including auditors and your future self, can be misled.
Try an experiment: remove setDepositsPaused’s onlyOwner, leave everything else untouched, and ask the model to write the comments again. See whether it notices.
The second step is where the value lies. Have it inspect every entry point from an attacker’s perspective, producing a table of “entry point and worst case.” You should be able to write that table yourself. If you cannot, you have not decided why that function exists.
Verify these points yourself after using AI
- You checked every permission claim against the function body: any function described as owner-only actually has a permission check
- Did it call a function with no permission check “protected”? This is a common model error
- Did it invent a precondition or restriction that the code does not contain?
- Did it cite a nonexistent library, interface, or standard, such as a plausible-sounding EIP number?
- Do its return descriptions match actual return values and parameter descriptions match parameter order?
- The contract still compiles after adding comments, but compilation does not prove comments are correct; verify those separately
Real-World Case
More than one project has put “unannounced allowlists,” “lottery random seeds,” or “parameters not yet in effect” in a private variable, assuming outsiders could not see them.
Storage is public. A single RPC call can read any slot. Some people have read seeds early to win precisely, while others have read allowlists ahead of time to trade in secondary markets.
There are no secrets in a contract. If something must be hidden, store only its hash and put the original onchain when it is revealed.
A library contract shared by many wallets had an initializer that set its owner, but no one restricted it to one call or to a particular caller.
Someone called it directly, made themselves owner of the library, and then destroyed it. Every wallet depending on that library stopped working at once, with funds permanently locked.
The lesson has two parts: assume anyone can call every external entry point; and “this code is meant as a library for others” offers no protection. T8 will use delegatecall to explain why destroying a library can disable other contracts.
A project reverses a condition in its ownership-transfer function, or allows the new owner to be the zero address without validation.
The contract permanently loses administrative control: no one can pause, upgrade, or change parameters. Funds are not stolen, but the product is dead.
These incidents share a feature: they will not appear in tests that never cover them. Few people write a test that transfers ownership to the zero address and confirms that the contract becomes unusable. Two-step transfer (the new owner must actively accept) prevents most cases.
A protocol adds a new path for moving funds in an upgrade but does not emit a matching event.
Its offchain accounting system reconstructs balances from events, making every change through that path invisible. Finance discovers the mismatch during reconciliation three weeks after launch. All reports in the meantime are wrong.
Events are the contract’s only active channel to the outside world. Whenever you add a state change, ask how offchain systems will know it happened.
Change One Variable
It disappears from the entry-point list. No external address can trigger it, and users cannot get their money back.
Visibility is not a decorative detail; it defines which capabilities this contract promises to outsiders. When reviewing a contract, first list every external and public function. That list is both its full set of promises and its full attack surface.
You gain iteration and two new problems.
First is cost: as users grow, any function that iterates through the array will eventually exceed a transaction’s Gas limit and become impossible to execute. Second is attack surface: an attacker can deposit a tiny amount from ten thousand addresses, bloat your array, and permanently jam a critical function.
“Need to iterate” is almost always a design signal in contracts: perform iteration offchain and keep only accounting onchain.
The contract still works and every state change happens. But the offchain world goes blind: no notifications, reports, reconciliation, or monitoring alerts.
Recall T1: a contract emits Events voluntarily. Here you are on the “voluntary” side and can understand why an indexer engineer might curse a contract with no events.
You separate code and storage. The address users interact with keeps the storage and forwards calls, while the actual logic lives at another replaceable address.
That lets you fix bugs, but also creates a new supreme power: whoever can replace the logic can rewrite every rule. The upgrade path embodies the trade-off from the start of this stage: immutability and mutability create different risks. One does not erase the other.
The technical basis is delegatecall in T8; T28 lists the risks.
Takeaway Questions
Before writing a contract, ask why this logic must be onchain. If it needs neither “anyone can verify it independently” nor “even its issuer cannot change it,” your server is cheaper, faster, and editable. Most things written as contracts do not need to be contracts.
The four permission designs in this chapter answer the same question in different ways: if the administrator’s private key leaks, who bears the loss? The answer is almost always users. Permission design is therefore a risk allocation you make on behalf of users, which must be public and verifiable.
Replace “What can an owner do?” with “What can an Agent do?” and the answer has the same shape: not one switch, but a list with scope, amounts, and delays. The habit built here, encoding permissions in code rather than promises, is the foundation of T32.
Chapter Check
They can. private only means no external read entry point is generated. It does not make data secret. Anyone can read the corresponding storage slot with an RPC call.
Handle secrets in only two ways: store a hash onchain and reveal the original later, or keep the secret offchain entirely.
Sending money hands control to the recipient. If the recipient is a contract, it can call your withdraw again as soon as it receives the funds.
If the balance has not yet been reduced, it can withdraw repeatedly. Update the ledger first so a second call naturally fails.
T28 covers the full attack and more variants. For now, make the order second nature.
There is no difference in execution, but a large difference in readability and auditability.
A modifier puts the permission constraint on the function-signature line. A reviewer can scan signatures and get the full list of protected functions and protection types. Checks buried in function bodies require opening each one.
A contract is audited, handed over, and reread by your future self far more often than it is written.
One criterion: what must offchain systems reconstruct from it?
List what you want offchain systems to calculate (each balance, changes in total value, the initiator of an action), then work backward to the fields needed. Mark fields used for filtering as indexed, usually addresses and IDs, up to three. Amounts generally need not be indexed because people rarely filter by an exact amount.
A practical acceptance test: can you reconstruct current state using only events? If yes, it is enough.
There is no standard answer. Check these points:
- For each permission, can you explain what breaks without it? If not, remove it.
- Have you mixed “closing the valve” and “moving money” in one role?
- Where is the key with the highest authority now? Is it a single signature or a multisig? What happens if it leaks?
- Does each use of a permission leave an onchain record anyone can see?
- Can the contract continue to work if you disappear?
Point 5 is the easiest to skip and the best at exposing hidden centralization assumptions.
Key idea
A contract is public code with persistent storage. Anyone can call it, so every entry point is an attack surface.