This tutorial explores an Ethereum blockchain entirely from the shell — accounts, transactions, blocks, gas, and units — using a local test chain and then the real network. There is no Solidity here, and no frontend.
Writing and deploying a contract is the subject of the follow-up, Writing a Smart Contract with Foundry.
Ethereum
A blockchain is a shared, append-only ledger that nobody owns. What makes one programmable is the ability to put code on it — a smart contract, which is a program with its own address, its own storage, and its own balance. Once deployed, it runs exactly as written for anyone who calls it, and no single party can quietly change it or stop it.
Ethereum is where that idea was first made to work, and it is the network this
tutorial uses. Its virtual machine, the EVM, has been running since 2015,
and contracts for it are usually written in Solidity — which is what
TipJar is written in. Ethereum is neither the fastest nor the cheapest chain
available, but it has the largest ecosystem, the most mature tooling, and by far
the most thoroughly tested security practice, which makes it the sensible place
to learn.
What You Will Use
anvil— run a local Ethereum blockchaincast— inspect accounts, send transactions, read blocks and contracts
Both ship with Foundry, and both run inside a Podman container so nothing is installed on the host.
Where Each Command Runs
You will have up to three shells open at once, and running a command in the wrong one is the most common way to get confused. So every command block is preceded by a line saying where it belongs:
`container : /work : main`
│ │ │
│ │ └── which window
│ └── the working directory
└── host machine, or inside the container
The fields are:
host your normal shell
container inside the Podman container, after `podman exec`
host → container the block itself crosses over, via `podman exec`
main the shell you work in
anvil the shell running the local blockchain
mainnet a third shell, used only at the end
The anvil shell is opened in Section 2, and the
mainnet shell in
Section 14.
1. Install Foundry in a Container
Foundry’s installer drops binaries into your home directory and edits your shell profile. Running it inside a container keeps all of that off the host, and makes the whole environment disposable — if anything goes wrong, delete the container and start again.
Everything below runs in Podman. Docker works identically; substitute docker
for podman throughout.
First, a directory on the host to hold the project, so your work survives the container:
host : ~ : main
mkdir -p ~/tip-jar-work
Now start a long-lived Ubuntu container with that directory mounted:
host : ~ : main
podman run -dit \
--name foundry \
--volume ~/tip-jar-work:/work:Z \
--workdir /work \
ubuntu sleep infinity
Three flags worth understanding:
-dit detached, but with a TTY so shells can attach later
--name foundry a fixed name, so every later command can find it
--volume ...:Z host directory ──▶ /work inside the container
sleep infinity is the container’s main process. A container lives exactly as
long as its main process, so without it the container would start, run nothing,
and immediately exit. Sleeping forever keeps it up so we can open shells into it.
The :Z suffix tells Podman to relabel the directory for SELinux. It is required
on Fedora and RHEL, and harmless everywhere else.
Now open a shell inside it:
host : ~ : main
podman exec -it foundry bash
The prompt changes to something like root@a1b2c3d4:/work#. You are now inside
the container, and everything in this tutorial happens there.
The base Ubuntu image is deliberately bare, so install what Foundry needs:
container : /work : main
apt-get update
apt-get install -y curl git ca-certificates jq
git and jq are not needed by anvil or cast themselves, but forge
uses git to fetch dependencies and jq is handy for reading JSON output, so
it is worth installing them now.
Then the normal Foundry install:
container : /work : main
curl -L https://foundry.paradigm.xyz | bash
source ~/.bashrc
foundryup
Verify:
container : /work : main
forge --version
anvil --version
cast --version
If forge is not found after foundryup, the source did not take. Foundry
installs to ~/.foundry/bin, so this fixes it:
container : /work : main
export PATH="$HOME/.foundry/bin:$PATH"
Getting Back In
The container keeps running in the background, so you can leave and return:
exit leave the shell, container keeps running
podman exec -it foundry bash open another shell in it
podman ps confirm it is still up
podman start foundry restart it after a reboot
Anything written to /work inside the container appears in ~/tip-jar-work on
the host, so you can edit TipJar.sol with your usual editor while running the
commands in the container.
The one thing that does not survive is Foundry itself. It lives in the container’s own filesystem, not in the mounted volume, so if you delete the container you will reinstall it. Section 16 covers cleanup and how to avoid that.
2. Start Anvil
anvil is the third of Foundry’s tools, alongside the forge we have been using
to build. It is a complete Ethereum node that runs on your machine and keeps its
entire blockchain in memory — no peers, no syncing, no waiting.
Three things make it a development tool rather than a real node:
ten pre-funded accounts 10,000 fake ETH each, keys printed on startup
instant blocks a transaction is mined the moment it arrives
nothing persists the chain exists only while the process runs
What it does not change is the interface. Anvil speaks the same JSON-RPC as
every real Ethereum node, which is why forge and cast cannot tell the
difference, and why
Section 14 can point the identical
commands at mainnet by changing one variable.
Open a second terminal on the host and get a second shell into the same container:
host : ~ : anvil
podman exec -it foundry bash
Then start the local blockchain:
container : /work : anvil
anvil
Both shells are inside the same container, so Anvil’s 127.0.0.1:8545 is
reachable from the first one exactly as if everything were running natively.
Nothing is published to the host, which means the chain is unreachable from
outside the container. That is usually what you want. If you would rather point
a host tool at it, recreate the container in
Section 1 with --publish 8545:8545
and start Anvil with anvil --host 0.0.0.0.
Leave it running for the rest of the tutorial. On startup it prints a screen like this, abridged here:
Available Accounts
==================
(0) 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 (10000.000000000000000000 ETH)
(1) 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 (10000.000000000000000000 ETH)
(2) ...
... 10 accounts in total ...
Private Keys
==================
(0) 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
(1) 0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d
(2) ...
... 10 keys in total, one per account above ...
Wallet
==================
Mnemonic: test test test test test test test test test test test junk
Derivation path: m/44'/60'/0'/0/
Chain ID
==================
31337
Base Fee
==================
1000000000
Gas Limit
==================
30000000
Genesis Timestamp
==================
(the Unix time at which you started Anvil, so this one varies)
Genesis Number
==================
0
Listening on 127.0.0.1:8545
Every value this tutorial needs comes off that screen. Five lines matter:
Available Accounts
(0) 0xf39Fd6...92266 ──▶ $OWNER the tip jar owner
(1) 0x709979...dc79C8 ──▶ $TIPPER sends the tips
Private Keys
(0) 0xac0974...f2ff80 ──▶ $OWNER_KEY signs transactions as the owner
(1) 0x59c699...78690d ──▶ $TIPPER_KEY signs transactions as the tipper
Listening on 127.0.0.1:8545
──▶ $RPC_URL http://127.0.0.1:8545
Account (0) is just a convention here — any two of the ten accounts will do,
as long as you keep each address paired with the private key printed at the same
index.
Those values are identical on every machine, because Anvil derives them from the
fixed mnemonic test test ... junk shown above. If you started Anvil with your
own --mnemonic, copy the keys your own screen printed instead.
Here is what each section of the screen means:
Available Accounts
==================
10 pre-funded Ethereum accounts.
Format:
[(account address) (ETH balance)]
By default, Anvil gives each account 10,000 ETH.
Private Keys
==================
10 private keys, one for each account listed above.
A private key is used to sign transactions on behalf of its corresponding
Ethereum address.
IMPORTANT: These Anvil private keys are for local development only.
**Never use them with real funds**.
Wallet
==================
Mnemonic:
[seed phrase from which the accounts/private keys are deterministically generated]
Derivation Path:
[rule/path used to derive individual accounts from the mnemonic]
Example:
m/44'/60'/0'/0/0
m/44'/60'/0'/0/1
...
Chain ID
==================
Identifies the Ethereum-compatible network.
Every Ethereum network has a chain ID. It helps prevent a transaction
signed for one network from being replayed on another network.
Example:
Anvil default Chain ID = 31337
Base Fee
==================
1000000000 wei
The minimum gas price per unit of gas for inclusion in a block under
EIP-1559.
1000000000 wei = 1 gwei
NOTE:
The base fee is a price per unit of gas, not the total transaction fee.
Gas Limit
==================
The maximum amount of gas that can be consumed by a block.
This is NOT a gas fee.
For an individual transaction:
gas used × gas price = transaction fee
Genesis Timestamp
==================
The timestamp assigned to the genesis block.
The genesis block is block 0: the first block in the blockchain.
Genesis Number
==================
The block number at which the local blockchain starts.
Normally:
Genesis Number = 0
The Listening on line is the address of the JSON-RPC server, which every
cast and forge command in this tutorial talks to.
3. Set Environment Variables
Back in your first container shell. Every command from here on refers to accounts through shell variables, never through a pasted address, so this is the only place you copy anything.
Start with the RPC endpoint, taken from Anvil’s Listening on line:
container : /work : main
export RPC_URL=http://127.0.0.1:8545
Now the two private keys, copied from the Private Keys section of the Anvil
screen — index (0) for the owner, index (1) for the tipper:
container : /work : main
export OWNER_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
export TIPPER_KEY=0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d
These are Anvil’s public development keys, and they are only safe because the chain is local and the ETH is fake. Never use them for real funds.
You do not need to copy the addresses. An Ethereum address is derived from its
private key, so cast can work them out:
container : /work : main
export OWNER=$(cast wallet address --private-key "$OWNER_KEY")
export TIPPER=$(cast wallet address --private-key "$TIPPER_KEY")
Print them:
container : /work : main
echo "RPC: $RPC_URL"
echo "Owner: $OWNER"
echo "Tipper: $TIPPER"
Expected:
RPC: http://127.0.0.1:8545
Owner: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
Tipper: 0x70997970C51812dc3A010C7d01b50e0d17dc79C8
Those two addresses should be character-for-character the accounts printed at
(0) and (1) under Available Accounts in the Anvil terminal. If they are
not, the keys were copied from the wrong index.
Those five variables are everything this tutorial needs. Deploying a contract
adds one more, TIPJAR, which is covered in the follow-up tutorial linked at
the end.
4. Check the Current Block
container : /work : main
cast block-number \
--rpc-url "$RPC_URL"
You should see:
0
Anvil starts with the genesis block, block 0.
5. Check the Account Balances
container : /work : main
cast balance \
"$OWNER" \
--ether \
--rpc-url "$RPC_URL"
You should see:
10000.000000000000000000
Anvil gave this account 10,000 test ETH when it created the local
blockchain. It is the same figure printed in parentheses after account (0) on
the Anvil screen, and confirms that $OWNER really is one of the funded
accounts.
Check the tipper:
container : /work : main
cast balance \
"$TIPPER" \
--ether \
--rpc-url "$RPC_URL"
It should also have:
10000.000000000000000000
6. Send 1 ETH From the Owner to the Tipper
container : /work : main
cast send \
"$TIPPER" \
--value 1ether \
--private-key "$OWNER_KEY" \
--rpc-url "$RPC_URL"
cast uses the private key to sign a transaction saying:
Owner ─── 1 ETH ───▶ Tipper
Anvil receives the transaction through its RPC server and mines it into a block.
The command prints a transaction receipt containing information such as:
blockNumber
from
to
gasUsed
status
transactionHash
7. Check the Block Number Again
container : /work : main
cast block-number \
--rpc-url "$RPC_URL"
You should now see:
1
The transaction caused Anvil to mine a new block:
Block 0 Genesis
│
▼
Block 1 1 ETH transfer
8. Check the Recipient’s Balance
container : /work : main
cast balance \
"$TIPPER" \
--ether \
--rpc-url "$RPC_URL"
You should now see:
10001.000000000000000000
The tipper received 1 ETH.
9. Check the Sender’s Balance
container : /work : main
cast balance \
"$OWNER" \
--ether \
--rpc-url "$RPC_URL"
You will see slightly less than:
9999 ETH
rather than exactly 9999 ETH, because the owner paid:
1 ETH
+
transaction gas
The gas is also paid using Anvil’s fake ETH.
10. Look at the Latest Block
container : /work : main
cast block latest \
--rpc-url "$RPC_URL"
This shows information about the block Anvil just created, including:
number
timestamp
gasLimit
gasUsed
baseFeePerGas
transactions
You can also ask for a specific block:
container : /work : main
cast block 1 \
--rpc-url "$RPC_URL"
11. View a Balance in Wei
Try:
container : /work : main
cast balance \
"$TIPPER" \
--rpc-url "$RPC_URL"
Without --ether, the balance is displayed in wei:
10001000000000000000000
because:
1 ETH = 1,000,000,000,000,000,000 wei
= 10¹⁸ wei
The --ether option simply tells cast to display the value in ETH instead of
wei.
Wei and Gwei
Wei is the real unit. “ETH” and “gwei” are just names for round multiples of it:
1 wei = 1 wei
1 gwei = 1 000 000 000 wei = 10⁹ wei
1 ether = 1 000 000 000 000 000 000 wei = 10¹⁸ wei
The reason the base unit is so small is that the EVM has no floating-point arithmetic at all — every value is a 256-bit unsigned integer. That is a consensus requirement rather than an omission: thousands of nodes must compute bit-identical results, and floating-point rounding differs between machines. So money is stored as whole numbers of a unit small enough that fractions of it never come up.
Gwei exists purely for readability, and only ever for gas prices. The same mainnet gas price in each unit:
wei 45526731
gwei 0.045526731 ← the one a human can read
ether 0.000000000045526731
That is why the Anvil screen in Section 2 shows a base fee
of 1000000000, annotated as 1 gwei.
Solidity has suffixes for all three, which are nothing more than compile-time
multipliers, and cast converts in both directions:
container : /work : main
cast to-wei 0.1 ether # 100000000000000000
cast from-wei 100000000000000000 # 0.100000000000000000
The catch is that units are a display convention, not a type. msg.value,
totalTips, and tipsByAddress are all plain uint256 holding wei, and
nothing stops you adding a gwei figure to a wei one — you are simply wrong by a
factor of a billion, and the compiler will not notice.
12. What Just Happened?
You interacted with Anvil in essentially the same way software interacts with a real Ethereum node:
cast
│
│ JSON-RPC
▼
127.0.0.1:8545
│
▼
┌─────────────────────┐
│ Anvil │
│ │
│ Block 0: Genesis │
│ Block 1: Transfer │
│ │
│ Owner │
│ ~9999 ETH │
│ │
│ Tipper │
│ 10001 ETH │
└─────────────────────┘
The important commands so far:
container : /work : main
# What block are we on?
cast block-number --rpc-url "$RPC_URL"
# How much ETH does an address have?
cast balance "$OWNER" --ether --rpc-url "$RPC_URL"
# Send a transaction
cast send "$TIPPER" --value 1ether \
--private-key "$OWNER_KEY" --rpc-url "$RPC_URL"
# Inspect the latest block
cast block latest --rpc-url "$RPC_URL"
These commands demonstrate the basic Ethereum model:
accounts
↓
signed transactions
↓
Ethereum node
↓
blocks
↓
updated blockchain state
The next step is to deploy the TipJar contract with forge and then use
cast to read from and write to it.
13. Important Anvil Behavior
Anvil is an ephemeral local blockchain. If you stop it and start it again:
container : /work : anvil
anvil
your old local blockchain is gone. That means previous:
- transactions
- account balances
- blocks
- contract deployments
no longer exist.
The new screen prints the same accounts and private keys as the old one, because
Anvil derives them from the same default mnemonic every time. So RPC_URL,
OWNER_KEY, TIPPER_KEY, OWNER, and TIPPER all stay valid, and everything
in this tutorial can simply be run again. Anything derived from a particular
run does not survive — a transaction hash saved in TX, for instance, or the
address of a contract you deployed.
Stopping the container has the same effect, and more besides. Anvil keeps its
chain in memory, so podman stop foundry ends the process and takes the chain
with it. Restarting gives you a fresh chain at block 0, exactly as if you had
pressed Ctrl-C on Anvil itself.
This is the whole point of a development chain. Nothing you do here is permanent, nothing costs anything, and starting over is one command. That stops being true the moment you point the same tools at a real network, which is what the next section does.
14. Pointing cast at the Real Ethereum
Everything so far has run against Anvil. Nothing about cast is local, though —
it is a JSON-RPC client, and Ethereum mainnet speaks the same JSON-RPC as the
node on your laptop. Every command in this tutorial that only reads works
against the real chain with one variable changed.
Reading is free. There is no key, no signature, no transaction, and nothing to lose. The node computes an answer and hands it back. So this whole section is safe to run, and none of it costs anything.
Open a third shell into the container, so you do not clobber the Anvil setup, and point it at mainnet:
host → container : /work : mainnet
podman exec -it foundry bash
export ETH_RPC_URL=https://ethereum-rpc.publicnode.com
The container reaches the internet through the host’s network by default, so no extra Podman configuration is needed to talk to a real node.
Note the variable name. It is ETH_RPC_URL, not the RPC_URL the rest of this
tutorial uses. cast reads ETH_RPC_URL from the environment on its own, so
every command below can drop the --rpc-url flag that earlier sections pass
explicitly.
That difference matters more than it looks. With no --rpc-url and no
ETH_RPC_URL, cast quietly falls back to http://localhost:8545, which is
Anvil. Everything in this section would then run against your local chain and
report that mainnet’s contracts do not exist.
So check where you actually are before going further:
container : /work : mainnet
cast chain
cast chain-id
cast block-number
cast client
cast chain-id must print 1. If it prints 31337, the export did not take and
you are still talking to Anvil. Nothing below will work until that reads 1.
Public endpoints are rate limited and occasionally down. If one stops answering, try another, or get a free key from a provider such as Alchemy, Infura, or QuickNode. The URL is the only thing that changes.
What Those Four Commands Told You
The chain ID is the number folded into every signature under EIP-155. It is the
same field Anvil printed as 31337 on its startup screen, and the reason a
transaction signed for one network cannot be replayed on another.
cast client reports which node software answered — geth, nethermind,
reth, and so on. There is no single Ethereum program; there are several
independent implementations that must agree, and you are talking to one of them.
The Chain Has a Beginning
Same command as Section 4, pointed somewhere older:
container : /work : mainnet
cast block 1
Block 1 of Ethereum mainnet was mined on 30 July 2015. cast age puts a date on
any block number:
container : /work : mainnet
cast age 1
cast age latest
Compare the header fields against the ones Anvil produced in Section 10. The structure is identical. There are simply rather more blocks.
What Gas Actually Costs
container : /work : mainnet
cast gas-price
cast base-fee
Both come back in wei, which is not a useful unit for reading:
container : /work : mainnet
cast from-wei $(cast gas-price) gwei
Gwei is the customary unit for gas prices — 10⁹ wei. Anvil hard-codes a base fee of 1 gwei, as its startup screen showed. Mainnet’s floats with demand, and watching it move over a few minutes is the cheapest possible introduction to Ethereum’s fee market.
Look Up an Account
cast resolves ENS names, so you rarely need to paste hex:
container : /work : mainnet
cast balance vitalik.eth --ether
cast resolve-name vitalik.eth
cast nonce vitalik.eth
The nonce is the number of transactions that address has ever sent. It is also what makes contract addresses predictable: a contract’s address is derived from its deployer and that deployer’s nonce, so it can be computed before the contract exists.
There is a reverse command as well, going from an address back to a name:
The Best of Both
Anvil can start from a copy of the real chain:
container : /work : anvil
anvil --fork-url https://ethereum-rpc.publicnode.com
Now http://127.0.0.1:8545 has every mainnet contract, balance, and storage slot
in it, but with instant blocks, the same ten pre-funded accounts, and money that
is not real. You can call USDC, deploy TipJar alongside it, and break whatever
you like. Nothing leaves your machine, and Ctrl-C puts it all back.
15. Anatomy of a Real Contract
Section 14 poked at several mainnet contracts in passing. This section takes one apart properly.
WETH — Wrapped Ether — is a good subject. It is one of the most heavily used contracts on Ethereum, it is small enough to hold in your head, and it does something conceptually neat: it takes ETH in and issues an equal number of WETH tokens against it, so that ETH can be handled by code that expects an ERC-20 token. Everything below is read-only and costs nothing.
Continuing in the mainnet shell from the previous section, ETH_RPC_URL is
already set, so only the address is new:
container : /work : mainnet
export WETH=0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2
cast chain-id
That must still print 1. If it does not, re-export ETH_RPC_URL as described
in Section 14.
Is There Anything There?
container : /work : mainnet
cast code "$WETH" | head -c 120
A long hex string, beginning 0x6060604052.... That is deployed EVM bytecode —
the compiled contract, stored in the account itself.
So an Ethereum account is one of two things:
Ethereum account
│
├── externally owned (a person) balance, nonce
│
└── contract balance, nonce,
bytecode,
storage
A contract account holds ETH just like a person does:
container : /work : mainnet
cast balance "$WETH" --ether
That figure is the entire point of WETH. Every WETH token in existence is backed by one ETH sitting in that balance.
Asking It What It Is
WETH implements the ERC-20 interface, so it can describe itself:
container : /work : mainnet
cast call "$WETH" "name()(string)"
cast call "$WETH" "symbol()(string)"
cast call "$WETH" "decimals()(uint8)"
"Wrapped Ether"
"WETH"
18
The interesting part is the argument:
"name()(string)"
│ │
│ └── what it returns, so cast can decode the reply
└── what it takes: nothing
No ABI file, no artifact, no source code. You tell cast the shape of the
function and it does the encoding. This works for any contract on the network
provided you know a function’s signature — and
Section 14 showed how to recover
signatures you do not know.
decimals() returning 18 means WETH uses the same scale as ETH, which is
deliberate: one wei of ETH becomes one unit of WETH.
1 WETH = 1 000 000 000 000 000 000 units = 10¹⁸
Do not assume that of other tokens. USDC returns 6.
How Much Exists
container : /work : mainnet
cast call "$WETH" "totalSupply()(uint256)"
A very large integer, in the token’s smallest unit. Pipe it through from-wei,
which reads from standard input when given no argument:
container : /work : mainnet
cast call "$WETH" "totalSupply()(uint256)" | cast from-wei
That is roughly how many WETH exist — and therefore, near enough, how much ETH
is locked in the contract. Compare it with the cast balance figure above; they
should track each other closely.
Two Kinds of Balance
Ask what WETH balance an address holds:
container : /work : mainnet
export ADDRESS=0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045
cast call "$WETH" "balanceOf(address)(uint256)" "$ADDRESS" | cast from-wei
Now compare that with the same address’s ETH:
container : /work : mainnet
cast balance "$ADDRESS" --ether
These are not the same kind of thing at all, and conflating them is a common beginner error:
ETH balance WETH balance
│ │
│ tracked by Ethereum itself, │ an entry in the WETH contract's
│ in the account's own state │ own storage — just a number in
│ │ a mapping
▼ ▼
cast balance $ADDRESS cast call $WETH "balanceOf(address)" $ADDRESS
An ETH balance is part of the protocol. A token balance is an ordinary variable
inside somebody’s contract, and it means whatever that contract’s code says it
means. Every ERC-20 token in existence is a mapping(address => uint256) and an
agreement to respect it.
What a Call Actually Sends
Ethereum never sees the text balanceOf(address). It sees four bytes:
container : /work : mainnet
cast sig "balanceOf(address)"
0x70a08231
That is the first four bytes of keccak256("balanceOf(address)"), and it is
what the contract’s dispatcher compares against. Build the complete calldata
and the structure is visible:
container : /work : mainnet
cast calldata "balanceOf(address)" "$ADDRESS"
0x70a08231000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa96045
└──────┘└──────────────────────────────────────────────────────────────┘
selector the address, left-padded to a full 32-byte word
Four bytes of “which function”, then arguments in 32-byte words. That is the
whole calling convention. Everything cast call does is build one of these,
send it, and decode what comes back.
Underneath cast call
cast is a convenience layer over JSON-RPC, and you can drop below it:
container : /work : mainnet
cast rpc eth_getCode "$WETH" latest
cast rpc eth_getBalance "$WETH" latest
Those are the raw calls cast code and cast balance make, and they return raw
hex rather than anything friendly. The stack is short:
cast
│ JSON-RPC over HTTP
▼
Ethereum node
│ executes EVM bytecode
▼
WETH contract
Reading Storage Directly
Contract storage is an array of numbered 32-byte slots, and anyone can read any of them:
container : /work : mainnet
cast storage "$WETH" 0
cast storage "$WETH" 1
cast storage "$WETH" 2
0x577261707065642045746865720000000000000000000000000000000000001a
0x5745544800000000000000000000000000000000000000000000000000000008
0x0000000000000000000000000000000000000000000000000000000000000012
Those are not random. They are name, symbol, and decimals — the same three
values the function calls returned, in the order they are declared in the
source. Slot 2 is plainly 0x12, which is 18.
Slots 0 and 1 use Solidity’s encoding for short strings: the characters sit in the high bytes, and the lowest byte holds twice the length.
0x5772617070656420457468657200 … 1a
└────── "Wrapped Ether" ─────┘ └┘
0x1a = 26 = 13 × 2
Confirm it:
container : /work : mainnet
cast to-ascii 0x57726170706564204574686572
So the same data can be reached two ways, and the difference matters:
cast call cast storage
│ │
│ runs the contract's code │ reads the raw bytes
│ │
▼ ▼
a decoded, meaningful value a 32-byte word you must
interpret yourself
Mappings are less obliging, because their slots are hashed rather than
sequential — the cast index command in
Section 14 is how you find those.
What It Emits
Every WETH transfer emits the standard ERC-20 event:
event Transfer(
address indexed from,
address indexed to,
uint256 value
);
Its identifying topic is the hash of its signature:
container : /work : mainnet
cast keccak "Transfer(address,address,uint256)"
0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef
That exact value appears in hundreds of millions of Ethereum logs — every ERC-20 transfer ever made, from every token. Fetch a few real ones from the last handful of blocks:
container : /work : mainnet
LATEST=$(cast block-number)
START=$((LATEST - 20))
cast logs \
--from-block "$START" \
--to-block "$LATEST" \
--address "$WETH" \
"Transfer(address,address,uint256)"
Keep the range small. Public endpoints reject wide log queries, and WETH is busy enough that twenty blocks is plenty.
Each result has from and to in its topics, because they are indexed, and
the amount in data, because it is not.
The Whole Picture
Ethereum Mainnet
│
▼
WETH contract account
0xC02aaA…756Cc2
╱ │ ╲
╱ │ ╲
▼ ▼ ▼
bytecode storage ETH balance
│ │
│ ├── balances mapping
│ └── allowances mapping
│
├── name() cast call
├── symbol()
├── balanceOf()
├── transfer()
├── deposit()
└── withdraw()
│
▼
logs cast logs
│
├── Transfer
├── Approval
├── Deposit
└── Withdrawal
Bytecode, storage, balance, and logs. That is all a contract is, and you have now read every one of them on a live contract without an account, a key, or a single wei of gas.
Try It With Money That Is Not Real
The obvious next move is to stop reading and start writing, which a fork makes
free. Start Anvil against mainnet as
Section 14 describes, then call
WETH’s deposit() with one of the fake pre-funded accounts, watch
balanceOf rise, and call withdraw() to turn it back into ETH.
That is a complete transaction cycle against a real, heavily used contract, using money that does not exist. It is also the natural bridge to Writing a Smart Contract with Foundry, where the contract being called is one you wrote.
16. Container Lifecycle and Cleanup
Three verbs, and the difference between them matters:
podman stop foundry pause it; filesystem intact, resumes with `start`
podman start foundry bring it back, then `podman exec -it foundry bash`
podman rm -f foundry delete it; the container filesystem is gone
Stopping is cheap and safe. Do it whenever you are done for the day:
host : ~ : main
podman stop foundry
Removing is the destructive one. What it takes with it:
GONE KEPT
────────────────────────── ──────────────────────────
Foundry install (~/.foundry) everything in ~/tip-jar-work
apt packages (curl, git) which is TipJar.sol, the tests,
the container's shell history foundry.toml, lib/, out/
Your work is on the host, so removing the container costs you the toolchain, not
the project. Recreating it means repeating
Section 1 from podman run onward.
Not Reinstalling Every Time
If you expect to delete and recreate the container, snapshot it once Foundry is installed:
host : ~ : main
podman commit foundry foundry-tipjar
That writes the container’s current filesystem out as a reusable image. New containers start from it with Foundry already present:
host : ~ : main
podman run -dit \
--name foundry \
--volume ~/tip-jar-work:/work:Z \
--workdir /work \
foundry-tipjar sleep infinity
podman commit captures whatever state the container happened to be in, which
is convenient but not reproducible. For something you can rebuild from scratch,
write a Containerfile on the host instead:
FROM ubuntu:24.04
RUN apt-get update && \
apt-get install -y curl git ca-certificates && \
rm -rf /var/lib/apt/lists/*
RUN curl -L https://foundry.paradigm.xyz | bash && \
/root/.foundry/bin/foundryup
ENV PATH="/root/.foundry/bin:${PATH}"
WORKDIR /work
Build and use it the same way:
host : ~ : main
podman build -t foundry-tipjar .
podman run -dit \
--name foundry \
--volume ~/tip-jar-work:/work:Z \
--workdir /work \
foundry-tipjar sleep infinity
The ENV PATH line is what makes forge work without sourcing anything, which
is why the Containerfile version never hits the “command not found” problem
mentioned in Section 1.
Removing Everything
To leave no trace beyond your project files:
host : ~ : main
podman rm -f foundry
podman rmi foundry-tipjar ubuntu
~/tip-jar-work is untouched by all of this. Delete it yourself if you want the
project gone too.
17. Additional Resources
The Tools
- Foundry Book — the official documentation
castcommand reference — every subcommand, generated fromcast --helpanvilreference — flags for forking, block time, and account impersonation- foundry-rs/foundry — the source. The
CLI arguments are defined in
crates/cast/src/opts.rsif the docs are ever ambiguous - Podman documentation — for the container side
How Ethereum Works
- Ethereum developer docs — accounts, transactions, gas, and the state trie, explained properly
- evm.codes — an interactive opcode reference with gas
costs. Look up
CALLERandSSTOREand the numbers in this tutorial stop being arbitrary - The Yellow Paper — the formal specification, if you want the actual definitions
The Standards Themselves
- All EIPs — searchable
- EIP-155 — chain IDs and replay
protection, the
31337on Anvil’s startup screen - EIP-1559 — the base fee mechanism
behind
cast base-fee
Looking at the Real Chain
- Etherscan — where to find the transaction hashes Section 14 asks for, and to read verified source for any contract
- Sepolia Etherscan — the same for the testnet
- Google Cloud Sepolia faucet — free testnet ETH, for when you want to send a real transaction
- Openchain signature database — what
cast 4bytequeries when turning a selector back into a function name
The Rest of the Series
- Writing a Smart Contract with Foundry — build, deploy, and drive a tip jar from the shell
- Adding a Tip Jar to a Webpage — a browser frontend for the contract, using ethers.js and MetaMask
- Invariant Testing with Foundry — properties that must hold after any sequence of calls
Security Note
Using Anvil’s default private keys directly in terminal commands is fine for local development, because the keys are public and the ETH is fake.
For real networks, never expose real private keys in:
- shell history
- source code
- Git repositories
- frontend JavaScript
- committed
.envfiles - documentation
Use an encrypted keystore, hardware wallet, or another secure signing method instead.
The container helps a little here, but do not overestimate it. It keeps the
Foundry install and this tutorial’s throwaway keys off the host, and a bind mount
means the container can only see ~/tip-jar-work rather than your whole home
directory. It does nothing about a real key you paste into a command — that key
still ends up in the container’s shell history, and the container still has
unrestricted outbound network access.
Summary
Anvil provides the local blockchain:
Anvil
└── http://127.0.0.1:8545
cast is a command-line client that talks to it, and to any other Ethereum
node:
cast ──JSON-RPC──▶ Anvil or mainnet
Using cast, you can inspect account balances, send ETH, submit transactions,
inspect blocks, call smart contracts, and read chain state — locally for free,
or against the real network by changing one variable.
The next step is to put your own contract on that chain:
Ethereum from the Command Line you are here
↓
Writing a Smart Contract with Foundry build, deploy, and drive a tip jar
↓
├── Adding a Tip Jar to a Webpage a browser frontend, via MetaMask
└── Invariant Testing with Foundry the bugs ordinary tests miss