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 blockchain
  • cast — 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

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 CALLER and SSTORE and 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 31337 on Anvil’s startup screen
  • EIP-1559 — the base fee mechanism behind cast base-fee

Looking at the Real Chain

The Rest of the Series


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 .env files
  • 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