This tutorial writes, tests, deploys, and drives a TipJar smart contract
entirely from the command line.
It follows on from Ethereum from the Command Line, which covers Anvil, accounts, transactions, and blocks. You do not have to have read it — the Prerequisites below get you to the same starting point — but it explains most of what this one assumes. Two tutorials follow on from this one: Adding a Tip Jar to a Webpage puts a frontend on the contract, and Invariant Testing with Foundry tests it harder.
A tip jar: about the smallest contract that is still genuinely useful.
anyone ──── sends ETH + a message ────▶ TipJar
│ records who tipped and how much
│ emits an event for each tip
▼
owner ◀─── withdraws the balance ───── only the owner may do this
It is a good first contract because it exercises nearly everything that makes contracts different from ordinary programs, and almost nothing else. It receives money, so you meet Ethereum’s units and its rules for accepting payment. It has an owner, so you meet on-chain access control. It keeps records, so you meet contract storage and its costs. And it pays money out, which is where real contracts most often go wrong.
You will use:
forge— compile, test, and deploy Solidity contractsanvil— run a local Ethereum blockchaincast— call contracts and send transactions
All three run inside a Podman container, so nothing is installed on the host.
No webpage or frontend is required.
The tutorial has two halves. The first uses cast against a bare local
blockchain to see accounts, transactions, and blocks. The second deploys the
TipJar contract and drives it from the shell.
You will use:
forge— compile, test, and deploy Solidity contractsanvil— run a local Ethereum blockchaincast— call contracts and send transactions
All three run inside a Podman container, so nothing is installed on the host. No webpage or frontend is required.
Prerequisites
If you already have the container and Anvil running from the previous tutorial, skip this. Otherwise, these four blocks get you there.
Start the container and open a shell in it:
host : ~ : main
mkdir -p ~/tip-jar-work
podman run -dit \
--name foundry \
--volume ~/tip-jar-work:/work:Z \
--workdir /work \
--publish 8545:8545 \
--publish 5173:5173 \
ubuntu sleep infinity
podman exec -it foundry bash
8545 Anvil's JSON-RPC, so MetaMask can reach the chain
5173 Vite's dev server, so the browser can load the page
Install Foundry inside it. git is required — forge init uses it to fetch
forge-std — and jq is used in
Section 17:
container : /work : main
apt-get update
apt-get install -y curl git ca-certificates jq
curl -L https://foundry.paradigm.xyz | bash
source ~/.bashrc
foundryup
forge --version
If forge is not found, run export PATH="$HOME/.foundry/bin:$PATH".
Start the local blockchain in a second shell, and leave it running:
host → container : /work : anvil
podman exec -it foundry bash
anvil
Set the environment variables back in the first shell. The keys are Anvil’s published development keys, printed on its startup screen and identical on every machine:
container : /work : main
export RPC_URL=http://127.0.0.1:8545
export OWNER_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
export TIPPER_KEY=0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d
export OWNER=$(cast wallet address --private-key "$OWNER_KEY")
export TIPPER=$(cast wallet address --private-key "$TIPPER_KEY")
$OWNER will deploy the tip jar and own it; $TIPPER will send tips. One
variable is still missing — TIPJAR, the contract’s address — because the
contract does not exist until
Section 4 deploys it.
Those keys are safe only because the chain is local and the ETH is fake. Never use them with real funds.
Where Each Command Runs
Every command block is preceded by a line saying where it belongs:
`container : /work/tip-jar : main`
│ │ │
│ │ └── which window
│ └── the working directory
└── host machine, or inside the container
host your normal shell
container inside the Podman container, after `podman exec`
main the shell you work in
anvil the shell running the local blockchain
1. Create the Project
container : /work : main
forge init tip-jar --empty
cd tip-jar
2. Create src/TipJar.sol
pragma solidity ^0.8.20;
contract TipJar {
address public immutable owner;
mapping(address => uint256) public tipsByAddress;
uint256 public totalTips;
event TipReceived(
address indexed sender,
uint256 amount,
string message
);
event Withdrawn(address indexed owner, uint256 amount);
constructor() {
owner = msg.sender;
}
function tip(string calldata message) external payable {
require(msg.value > 0, "Tip must be greater than zero");
tipsByAddress[msg.sender] += msg.value;
totalTips += msg.value;
emit TipReceived(msg.sender, msg.value, message);
}
function getBalance() external view returns (uint256) {
return address(this).balance;
}
function withdraw() external {
require(msg.sender == owner, "Only owner can withdraw");
uint256 amount = address(this).balance;
require(amount > 0, "Nothing to withdraw");
totalTips = 0;
(bool success, ) = payable(owner).call{value: amount}("");
require(success, "Withdrawal failed");
emit Withdrawn(owner, amount);
}
}
Two details worth noting before we run it:
- The contract has no
receive()function, so ETH can only arrive throughtip(). A plain ETH transfer to the contract address will revert. Section 20 adds one, and shows what can go wrong when it is added carelessly. withdraw()resetstotalTipsto zero, sototalTipstracks tips since the last withdrawal rather than the lifetime total. The per-addresstipsByAddressmapping is never cleared.
Where msg Comes From
The contract refers to msg.sender and msg.value without declaring either
one. There is no import, no parameter, no variable. They are built into Solidity
and are always in scope, like require.
They are also not really structs. Each field compiles to a single EVM
instruction, so msg.sender is not a lookup — it is the CALLER opcode, two
gas, reading a value the machine already had before your code started running.
There are three such globals, and the useful thing to know about them is how long each one holds still:
msg this call changes at every contract-to-contract hop
tx this transaction fixed from signature to final receipt
block this block identical for every transaction in the block
Between them they describe the entire context a contract executes in:
msg.sender address who made THIS call
msg.value uint256 wei attached to THIS call
msg.data bytes the full calldata
msg.sig bytes4 first four bytes of calldata: the selector
tx.origin address the account that signed the transaction
tx.gasprice uint256 the gas price it was submitted with
block.number uint256 current block height
block.timestamp uint256 seconds since the Unix epoch
block.chainid uint256 1 for mainnet, 31337 for Anvil
block.basefee uint256 EIP-1559 base fee, in wei
block.coinbase address the validator being paid for this block
block.gaslimit uint256 the most gas this block may consume
block.prevrandao uint256 randomness supplied by the beacon chain
You Have Already Seen block
Everything under block is a field of the block header, which means a contract
reading block.basefee and you running cast base-fee are reading the same
number out of the same place. Anvil’s startup screen prints Chain ID and
Base Fee, which are block.chainid and block.basefee, and the header dump
from cast block latest is most of the rest of the list. Both are covered in
the previous tutorial.
Two of them are traps worth knowing about now.
block.timestamp is set by whoever proposes the block, within a tolerance of a
few seconds. It is fine for “has a day passed”, useless for anything needing
precision, and must never be treated as unpredictable.
block.prevrandao is not a random number generator either. Every node has to
agree on it, so every node can see it, and it is known in advance to the
proposer. Any on-chain lottery built on these two is winnable. It was called
block.difficulty before the Merge, which is why older tutorials show a
different name.
msg Changes, tx Does Not
This is the distinction that actually matters in practice. A new call frame is
created every time one contract calls another, and msg describes that frame,
not the transaction as a whole:
tipper ──tx──▶ TipJar.tip()
msg.sender = tipper
tx.origin = tipper
tipper ──tx──▶ SomeRouter ──call──▶ TipJar.tip()
msg.sender = SomeRouter ← changed
tx.origin = tipper ← unchanged
msg.value shifts the same way: the inner call carries whatever the router
chose to forward, which need not be what the tipper sent.
So use msg.sender for authorisation and never tx.origin. A check against
tx.origin passes for anyone who can get you to call their contract, because
your signature is still at the bottom of the stack. The check in withdraw() is
the correct form:
require(msg.sender == owner, "Only owner can withdraw");
TipJar uses exactly three of these fields — msg.sender in the constructor
and in both public functions, and msg.value in tip(). The constructor is the
subtlest of them: owner = msg.sender records whoever deployed the contract,
which is why Section 4 signs the deployment with
$OWNER_KEY, and why the tests in Section 19
have to go out of their way to control who the deployer is.
One field has been removed rather than renamed: msg.gas became the free
function gasleft().
3. Compile
container : /work/tip-jar : main
forge build
The compiled artifact will appear under:
out/TipJar.sol/TipJar.json
4. Deploy the Contract
Sign the deployment with $OWNER_KEY. Whoever deploys the contract runs its
constructor, and the constructor sets owner = msg.sender, so this is what makes
$OWNER the tip jar’s owner:
container : /work/tip-jar : main
forge create src/TipJar.sol:TipJar \
--private-key "$OWNER_KEY" \
--rpc-url "$RPC_URL" \
--broadcast
You should see something like:
Deployer: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 ──▶ matches $OWNER
Deployed to: 0x... ──▶ $TIPJAR
Transaction hash: 0x...
Deployer is the address that $OWNER_KEY signed with, so it should match
$OWNER. The address on the Deployed to line is the last variable you need:
container : /work/tip-jar : main
export TIPJAR=0xPASTE_THE_DEPLOYED_TO_ADDRESS_HERE
Check it:
container : /work/tip-jar : main
echo "$TIPJAR"
Unlike the account addresses, this one is not fixed. A contract address is
computed from the deploying account and how many transactions it has already
sent, so it depends on what you did earlier and changes every time you restart
Anvil. Always copy the address your own forge create printed.
5. Verify the Contract Exists
container : /work/tip-jar : main
cast code "$TIPJAR" \
--rpc-url "$RPC_URL"
A deployed contract returns bytecode beginning with:
0x...
If the result is only:
0x
there is no contract at that address, which usually means $TIPJAR was
mistyped or is left over from an earlier Anvil run.
6. Read the Owner
container : /work/tip-jar : main
cast call "$TIPJAR" \
"owner()(address)" \
--rpc-url "$RPC_URL"
This should match:
container : /work/tip-jar : main
echo "$OWNER"
7. Check the Initial Balance
container : /work/tip-jar : main
cast balance "$TIPJAR" \
--ether \
--rpc-url "$RPC_URL"
Initially:
0.000000000000000000
You can also use the contract function:
container : /work/tip-jar : main
cast call "$TIPJAR" \
"getBalance()(uint256)" \
--rpc-url "$RPC_URL"
8. Send a Tip
Sign with $TIPPER_KEY, so the tip comes from $TIPPER:
container : /work/tip-jar : main
cast send "$TIPJAR" \
"tip(string)" "Great work!" \
--value 0.1ether \
--private-key "$TIPPER_KEY" \
--rpc-url "$RPC_URL"
This sends 0.1 ETH to the contract and stores the message.
9. Check the Contract Balance
container : /work/tip-jar : main
cast balance "$TIPJAR" \
--ether \
--rpc-url "$RPC_URL"
Expected:
0.100000000000000000
10. Call getBalance()
container : /work/tip-jar : main
cast call "$TIPJAR" \
"getBalance()(uint256)" \
--rpc-url "$RPC_URL"
For 0.1 ETH the result is:
100000000000000000
That value is in wei. Convert it to ETH:
container : /work/tip-jar : main
cast from-wei 100000000000000000
11. Check How Much the Tipper Has Tipped
container : /work/tip-jar : main
cast call "$TIPJAR" \
"tipsByAddress(address)(uint256)" "$TIPPER" \
--rpc-url "$RPC_URL"
Expected:
100000000000000000
Or, converted:
container : /work/tip-jar : main
TIP_AMOUNT=$(cast call "$TIPJAR" \
"tipsByAddress(address)(uint256)" "$TIPPER" \
--rpc-url "$RPC_URL")
cast from-wei "$TIP_AMOUNT"
Expected:
0.100000000000000000
12. Check Total Tips
container : /work/tip-jar : main
cast call "$TIPJAR" \
"totalTips()(uint256)" \
--rpc-url "$RPC_URL"
Expected after one 0.1 ETH tip:
100000000000000000
13. Send Another Tip
container : /work/tip-jar : main
cast send "$TIPJAR" \
"tip(string)" "Second tip" \
--value 0.25ether \
--private-key "$TIPPER_KEY" \
--rpc-url "$RPC_URL"
Check the contract:
container : /work/tip-jar : main
cast balance "$TIPJAR" \
--ether \
--rpc-url "$RPC_URL"
Expected:
0.350000000000000000
14. Test the Owner Restriction
Try withdrawing with $TIPPER_KEY:
container : /work/tip-jar : main
cast send "$TIPJAR" \
"withdraw()" \
--private-key "$TIPPER_KEY" \
--rpc-url "$RPC_URL"
This should fail. $TIPPER is not the address stored in the contract’s owner
field, and the Solidity check is:
require(msg.sender == owner, "Only owner can withdraw");
15. Withdraw as the Owner
Sign with $OWNER_KEY, the key that deployed the contract:
container : /work/tip-jar : main
cast send "$TIPJAR" \
"withdraw()" \
--private-key "$OWNER_KEY" \
--rpc-url "$RPC_URL"
This transfers the contract’s ETH to the owner.
16. Confirm the Contract Is Empty
container : /work/tip-jar : main
cast balance "$TIPJAR" \
--ether \
--rpc-url "$RPC_URL"
Expected:
0.000000000000000000
Check totalTips:
container : /work/tip-jar : main
cast call "$TIPJAR" \
"totalTips()(uint256)" \
--rpc-url "$RPC_URL"
Expected:
0
The tipsByAddress mapping is not cleared, so the contract still records how
much each address has tipped historically:
container : /work/tip-jar : main
cast call "$TIPJAR" \
"tipsByAddress(address)(uint256)" "$TIPPER" \
--rpc-url "$RPC_URL"
Expected:
350000000000000000
17. Inspect a Transaction
Every cast send prints a receipt containing a transactionHash field, and
forge create prints the same value on its Transaction hash line. Put one in a
variable:
container : /work/tip-jar : main
export TX=0xPASTE_A_TRANSACTION_HASH_HERE
Then look it up. cast tx shows the transaction as it was submitted, that is,
what the sender asked for:
container : /work/tip-jar : main
cast tx "$TX" \
--rpc-url "$RPC_URL"
cast receipt shows what happened when it was mined, including status,
gasUsed, the block it landed in, and any events the contract emitted:
container : /work/tip-jar : main
cast receipt "$TX" \
--rpc-url "$RPC_URL"
The receipt for one of the tip() calls is where you can see the TipReceived
event in the logs field.
If you would rather not copy hashes by hand, cast send can print JSON, so the
hash can be captured straight into a variable:
container : /work/tip-jar : main
export TX=$(cast send "$TIPJAR" \
"tip(string)" "Captured hash" \
--value 0.01ether \
--private-key "$TIPPER_KEY" \
--rpc-url "$RPC_URL" \
--json | jq -r .transactionHash)
echo "$TX"
jq was installed alongside Foundry in the
Prerequisites. Note that this sends a real,
if small, tip, so the contract balance is no longer zero after running it.
Withdraw again if you want to get back to the state at the end of
Section 16.
Reading the Tip Messages
Every tip so far carried a message — "Great work!" in
Section 8 — and we have never read one back. Doing so
takes eight small steps, and each one is worth understanding.
1. The message is not in storage.
Ask the contract what it knows about the tipper:
container : /work/tip-jar : main
cast call "$TIPJAR" \
"tipsByAddress(address)(uint256)" "$TIPPER" \
--rpc-url "$RPC_URL"
You get an amount, and that is all there is. TipJar has no variable holding
messages and no getter that returns one. Look back at tip() and you will see
the message is passed straight to emit and never stored:
emit TipReceived(msg.sender, msg.value, message);
That is a deliberate trade. A log costs roughly 8 gas per byte; the same string in storage costs 20,000 gas per 32-byte word, and the contract itself has no way to read a log back. Anything only humans need should be an event.
2. Find the logs.
cast logs searches the chain for events matching a signature:
container : /work/tip-jar : main
cast logs \
"TipReceived(address indexed sender, uint256 amount, string message)" \
--from-block 0 \
--rpc-url "$RPC_URL"
One entry per tip, each looking like this:
- address: 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512
blockNumber: 3
data: 0x000000000000000000000000000000000000000000000000016345785d…
topics: [
0x8d379bddc159e67937283b53edd0858bdd6f8ba659d7bc286617c5afdb4f4780
0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8
]
--from-block 0 scans from genesis, which is instant on Anvil and a bad idea on
mainnet.
3. Understand why there are two places to look.
The event declaration splits its arguments in two:
event TipReceived(
address indexed sender, // ──▶ topics
uint256 amount, // ──▶ data
string message // ──▶ data
);
indexed arguments become topics, which are searchable — that is the whole
point of marking one. Everything else is ABI-encoded into data, which is
cheaper but can only be read by decoding the whole thing.
topics[0] the event signature hash, identifying which event this is
topics[1] sender, an address padded out to 32 bytes
data amount and message, packed together
topics[0] is keccak256("TipReceived(address,uint256,string)"), which is why
cast logs could filter on a signature.
4. Read the data by hand, once.
The data field is just 32-byte words end to end:
0000…016345785d8a0000 amount 100000000000000000 wei = 0.1 ETH
0000…0000000000000040 offset the string starts at byte 64
0000…000000000000000b length 11 bytes
477265617420776f726b21 bytes "Great work!", padded to a full word
Two words of bookkeeping before the text: a variable-length type is encoded as a pointer, then a length, then the bytes. The message itself is plain ASCII:
container : /work/tip-jar : main
cast to-ascii 0x477265617420776f726b21
5. Ask for JSON instead.
Reading that by eye does not scale. --json turns the same output into
something a program can consume:
container : /work/tip-jar : main
cast logs \
"TipReceived(address indexed sender, uint256 amount, string message)" \
--from-block 0 \
--rpc-url "$RPC_URL" \
--json
6. Pull out one field.
jq selects the data of the first log. .[0] is the first element,
and -r prints it raw rather than JSON-quoted:
container : /work/tip-jar : main
export DATA=$(cast logs \
"TipReceived(address indexed sender, uint256 amount, string message)" \
--from-block 0 \
--rpc-url "$RPC_URL" \
--json | jq -r '.[0].data')
echo "$DATA"
Use .[-1].data for the most recent tip instead of the first.
7. Let cast decode it.
container : /work/tip-jar : main
cast decode-event --sig "TipReceived(uint256,string)" "$DATA"
100000000000000000
"Great work!"
The signature here is deliberately not the real one. cast decode-event is
given only the data field, and sender is not in data — it is up in
topics[1]. So you list the non-indexed arguments only. Passing the full
TipReceived(address,uint256,string) fails, because it would go looking for an
address that is not there.
8. Put it in one line.
With the variable inlined, the whole thing becomes a single command:
container : /work/tip-jar : main
cast decode-event --sig "TipReceived(uint256,string)" \
$(cast logs \
"TipReceived(address indexed sender, uint256 amount, string message)" \
--from-block 0 \
--rpc-url "$RPC_URL" \
--json | jq -r '.[0].data')
Who Sent It, and All of Them at Once
The sender is the topic we skipped, and it only needs unpadding:
container : /work/tip-jar : main
cast parse-bytes32-address \
0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8
That is $TIPPER. Being indexed also means you can filter on it, by passing
it as an extra argument after the signature:
container : /work/tip-jar : main
cast logs \
"TipReceived(address indexed sender, uint256 amount, string message)" \
"$TIPPER" \
--from-block 0 \
--rpc-url "$RPC_URL"
And to read every message ever tipped, loop over the logs instead of taking
.[0]:
container : /work/tip-jar : main
cast logs \
"TipReceived(address indexed sender, uint256 amount, string message)" \
--from-block 0 \
--rpc-url "$RPC_URL" \
--json \
| jq -r '.[].data' \
| while read -r d; do
cast decode-event --sig "TipReceived(uint256,string)" "$d"
done
This is, in miniature, exactly what a block explorer or a tip-jar web page does: it never calls the contract for this, it reads the logs and decodes them.
There is also a shortcut for a single transaction. cast run replays it and
prints a trace with the events already decoded:
container : /work/tip-jar : main
cast run "$TX" --rpc-url "$RPC_URL"
18. Inspect the Blockchain
Current block number:
container : /work/tip-jar : main
cast block-number \
--rpc-url "$RPC_URL"
Latest block:
container : /work/tip-jar : main
cast block latest \
--rpc-url "$RPC_URL"
19. Add Automated Tests
Driving the contract by hand is useful for learning, but tests are how you keep
it working. Create test/TipJar.t.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Test} from "forge-std/Test.sol";
import {TipJar} from "../src/TipJar.sol";
contract TipJarTest is Test {
TipJar private tipJar;
address private owner = makeAddr("owner");
address private tipper = makeAddr("tipper");
function setUp() public {
vm.prank(owner);
tipJar = new TipJar();
vm.deal(tipper, 1 ether);
}
function testOwnerIsDeployer() public view {
assertEq(tipJar.owner(), owner);
}
function testTip() public {
vm.prank(tipper);
tipJar.tip{value: 0.25 ether}("Excellent!");
assertEq(address(tipJar).balance, 0.25 ether);
assertEq(tipJar.totalTips(), 0.25 ether);
assertEq(tipJar.tipsByAddress(tipper), 0.25 ether);
}
function testOwnerCanWithdraw() public {
vm.prank(tipper);
tipJar.tip{value: 0.25 ether}("Excellent!");
uint256 ownerBalanceBefore = owner.balance;
vm.prank(owner);
tipJar.withdraw();
assertEq(address(tipJar).balance, 0);
assertEq(tipJar.totalTips(), 0);
assertEq(owner.balance, ownerBalanceBefore + 0.25 ether);
}
function testNonOwnerCannotWithdraw() public {
vm.prank(tipper);
tipJar.tip{value: 0.25 ether}("Excellent!");
vm.prank(tipper);
vm.expectRevert("Only owner can withdraw");
tipJar.withdraw();
}
function testCannotSendZeroTip() public {
vm.prank(tipper);
vm.expectRevert("Tip must be greater than zero");
tipJar.tip{value: 0}("No tip");
}
}
The owner is a plain address created with makeAddr, and the contract is
deployed through vm.prank(owner). That matters: if the test contract itself
were the owner, withdraw() would try to send ETH to a contract with no
receive() function and revert.
Run:
container : /work/tip-jar : main
forge test
More verbose:
container : /work/tip-jar : main
forge test -vv
Full traces:
container : /work/tip-jar : main
forge test -vvvv
20. Extension: Accept Plain ETH Transfers
Try sending ETH to the contract without naming a function:
container : /work/tip-jar : main
cast send "$TIPJAR" \
--value 0.05ether \
--private-key "$TIPPER_KEY" \
--rpc-url "$RPC_URL"
It reverts. A transaction with no function signature carries empty calldata, and
TipJar has nothing to run in that case. Contracts only accept a plain ETH
transfer if they declare a receive() function, and only handle calls to unknown
selectors if they declare a fallback(). TipJar has neither, so this also
reverts:
container : /work/tip-jar : main
cast send "$TIPJAR" \
"notAFunction()" \
--private-key "$TIPPER_KEY" \
--rpc-url "$RPC_URL"
Accepting plain transfers is worth having. It is what a wallet does when someone
types the tip jar’s address into a send field, without any idea that a tip()
function exists.
The Naive Version
The obvious thing to write is an empty receive():
receive() external payable {}
Rebuild, and redeploy, since a code change means a new contract:
container : /work/tip-jar : main
forge build
forge create src/TipJar.sol:TipJar \
--private-key "$OWNER_KEY" \
--rpc-url "$RPC_URL" \
--broadcast
export TIPJAR=0xTHE_NEW_DEPLOYED_ADDRESS
Now the plain transfer succeeds:
container : /work/tip-jar : main
cast send "$TIPJAR" \
--value 0.05ether \
--private-key "$TIPPER_KEY" \
--rpc-url "$RPC_URL"
But look at what the contract thinks happened:
container : /work/tip-jar : main
cast balance "$TIPJAR" --rpc-url "$RPC_URL"
cast call "$TIPJAR" \
"totalTips()(uint256)" \
--rpc-url "$RPC_URL"
cast call "$TIPJAR" \
"tipsByAddress(address)(uint256)" "$TIPPER" \
--rpc-url "$RPC_URL"
50000000000000000 the ETH is really there
0 but totalTips never moved
0 and nobody is credited for it
No ETH is lost — withdraw() sends address(this).balance, so the owner still
gets it — but the contract’s own accounting now disagrees with its balance, and
no event was emitted, so nothing downstream can even see the tip arrive.
The Fix
Route both entry points through one internal function, so there is a single place where a tip is recorded:
function tip(string calldata message) external payable {
_tip(message);
}
receive() external payable {
_tip("");
}
function _tip(string memory message) private {
require(msg.value > 0, "Tip must be greater than zero");
tipsByAddress[msg.sender] += msg.value;
totalTips += msg.value;
emit TipReceived(msg.sender, msg.value, message);
}
Three things to notice:
_tiptakesstring memory, notstring calldata. Thereceive()path has no calldata to point into, so the empty string it passes has to live in memory.- A plain transfer has no room for a message, so it records an empty one. The
TipReceivedevent still fires, which is what matters. require(msg.value > 0, ...)now guards thereceive()path too, so a zero-value transfer to the contract reverts rather than silently doing nothing.
Rebuild, redeploy, and send another plain transfer. This time the accounting follows the ETH:
container : /work/tip-jar : main
cast call "$TIPJAR" \
"totalTips()(uint256)" \
--rpc-url "$RPC_URL"
50000000000000000
And the tipper is credited, exactly as if they had called tip():
container : /work/tip-jar : main
cast call "$TIPJAR" \
"tipsByAddress(address)(uint256)" "$TIPPER" \
--rpc-url "$RPC_URL"
The tests from Section 19 need no changes, since
tip() behaves exactly as before:
container : /work/tip-jar : main
forge test
And that is the unsettling part. Every unit test passes against both the broken
version and the fixed one, because every test calls tip() and none of them
touches the path that was just added.
That is one of two directions to go from here:
Adding a Tip Jar to a Webpage put the contract behind a browser UI,
with MetaMask signing instead of a key
in an environment variable
Invariant Testing with Foundry catch the class of bug the unit tests
just failed to notice
They are independent, and either can be read first.
21. Format and Rebuild
container : /work/tip-jar : main
forge fmt
forge build
forge test
This is a good normal development loop.
22. Useful Commands
Every command in this section runs in container : /work/tip-jar : main.
Compile
forge build
Test
forge test
Run one test contract only
forge test --match-contract TipJarTest -vv
Start local Ethereum
anvil
Deploy
forge create src/TipJar.sol:TipJar \
--private-key "$OWNER_KEY" \
--rpc-url "$RPC_URL" \
--broadcast
Read contract state
cast call "$TIPJAR" \
"owner()(address)" \
--rpc-url "$RPC_URL"
Send a transaction
cast send "$TIPJAR" \
"tip(string)" "hello" \
--value 0.1ether \
--private-key "$TIPPER_KEY" \
--rpc-url "$RPC_URL"
Check ETH balance
cast balance "$TIPJAR" \
--ether \
--rpc-url "$RPC_URL"
ETH to wei
cast to-wei 0.1 ether
Wei to ETH
cast from-wei 100000000000000000
Derive an address from a private key
cast wallet address \
--private-key "$OWNER_KEY"
Inspect contract bytecode
cast code "$TIPJAR" \
--rpc-url "$RPC_URL"
23. Minimal Complete Session
Terminal 1:
host → container : /work : anvil
podman start foundry
podman exec -it foundry bash
anvil
Terminal 2:
host → container : /work/tip-jar : main
podman exec -it foundry bash
cd tip-jar
# From Anvil's "Listening on" line
export RPC_URL=http://127.0.0.1:8545
# From Anvil's "Private Keys" section, indexes (0) and (1)
export OWNER_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
export TIPPER_KEY=0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d
# Derived from the keys; should match "Available Accounts" (0) and (1)
export OWNER=$(cast wallet address --private-key "$OWNER_KEY")
export TIPPER=$(cast wallet address --private-key "$TIPPER_KEY")
forge build
forge test
forge create src/TipJar.sol:TipJar \
--private-key "$OWNER_KEY" \
--rpc-url "$RPC_URL" \
--broadcast
Copy the address from the Deployed to line that forge create just printed:
container : /work/tip-jar : main
export TIPJAR=0xYOUR_DEPLOYED_ADDRESS
Send a tip:
container : /work/tip-jar : main
cast send "$TIPJAR" \
"tip(string)" "Great work!" \
--value 0.1ether \
--private-key "$TIPPER_KEY" \
--rpc-url "$RPC_URL"
Check the balance:
container : /work/tip-jar : main
cast balance "$TIPJAR" --ether --rpc-url "$RPC_URL"
Withdraw:
container : /work/tip-jar : main
cast send "$TIPJAR" \
"withdraw()" \
--private-key "$OWNER_KEY" \
--rpc-url "$RPC_URL"
Check the final balance:
container : /work/tip-jar : main
cast balance "$TIPJAR" --ether --rpc-url "$RPC_URL"
24. Mental Model
The command-line workflow is:
TipJar.sol
↓
forge build
↓
forge test
↓
anvil
↓
forge create
↓
deployed TipJar
↓
cast call / cast send
The three tools have distinct roles:
forge = build, test, deploy
anvil = local Ethereum blockchain
cast = interact with Ethereum
25. Anvil’s Amnesia and Removing a Contract
Anvil keeps its whole chain in memory. Stop it and start it again:
container : /work : anvil
anvil
and the old chain is gone — every deployment, transaction, and balance with it.
The new screen prints the same accounts and private keys as before, because
Anvil derives them from the same default mnemonic every time. So RPC_URL,
OWNER_KEY, TIPPER_KEY, OWNER, and TIPPER all stay valid. Only TIPJAR
goes stale, along with any transaction hash you saved in TX:
container : /work/tip-jar : main
forge create src/TipJar.sol:TipJar \
--private-key "$OWNER_KEY" \
--rpc-url "$RPC_URL" \
--broadcast
export TIPJAR=0xNEW_DEPLOYED_ADDRESS
A cast command that returns 0x or an empty result right after a restart is
usually a TIPJAR still pointing at the contract on the old chain.
Removing a Contract
Throwing away a contract is trivial here and impossible on a real network, and the gap between those two facts is worth understanding before you deploy anything for real.
Locally there are three levels of demolition:
Ctrl-C, then anvil new chain at block 0, every contract gone
podman stop foundry same, since the chain only lived in memory
podman rm -f foundry the toolchain goes too
Confirm it worked the way Section 5 did:
container : /work/tip-jar : main
cast code "$TIPJAR" \
--rpc-url "$RPC_URL"
A bare 0x means there is no contract at that address any more.
On a real chain, none of this is available. Deployed code is permanent. There is no delete, no owner override, and no way to ask the network to forget an address.
Solidity does have selfdestruct(address payable recipient), and until March
2024 it did erase a contract’s code and storage. EIP-6780, part of the Dencun
upgrade, removed almost all of that:
created in THIS transaction still fully deleted
created in an earlier block balance is swept to the recipient,
code and storage remain
Since any contract you deployed before today was created in an earlier
transaction, selfdestruct on it now amounts to “send me the ETH”. The address
keeps its bytecode, cast code still returns it, and every function still works.
The change was made because wholesale state deletion is hostile to the tree
structures Ethereum is moving toward, so it is not coming back.
Deletion was never quite as complete as it sounded, either. Historical blocks
always still contained the contract, and CREATE2 could put different code at
the same address afterwards.
So the real options are these:
abandon it withdraw the funds and stop referring to it. Nobody pays
for an unused contract, and addresses are cheap. This is
the usual answer.
kill switch a `bool stopped` that every function checks, so the code
survives but refuses to do anything.
proxy users call a small contract that delegates to an
implementation address you can change. This is how
upgradeable contracts work, and it hands the proxy owner
the power to alter behaviour later.
TipJar has none of them. No selfdestruct, no pause, and an immutable
owner, so on a real network it would sit there permanently with withdraw() as
the only control anyone has over it. Deploying to Anvil costs nothing and can be
redone endlessly; deploying to a real network, as the
previous tutorial does read-only, would be
final.
One piece of selfdestruct did survive: it still forces ETH into any address
without running code there. That is why a contract’s balance can always exceed
its own accounting, and why withdraw() sends address(this).balance rather
than totalTips.
26. Additional Resources
The Tools
- Foundry Book — the official documentation
castcommand reference — every subcommand, generated fromcast --help- Writing tests with
forge— cheatcodes, fuzzing, and invariant testing, which Section 19 only opens the door to - Invariant Testing with Foundry — the follow-up tutorial: properties that must hold after any sequence of calls
- Adding a Tip Jar to a Webpage — the other follow-up: ethers.js, MetaMask, and a browser frontend for this contract
- foundry-rs/foundry — the source
- forge-std — the standard library
behind
forge-std/Test.sol
Solidity and the EVM
- Solidity documentation — the language reference, including the globals in Section 2
- Solidity by Example — short, runnable examples; the fastest way to see a pattern you have not met before
- The ABI specification — why event data is laid out the way Section 17 decodes it
- evm.codes — an interactive opcode reference with gas
costs. Look up
CALLERandSSTOREand the numbers here stop being arbitrary - EIP-6780 — the change that made
selfdestructalmost useless, discussed in Section 25
Writing Contracts That Do Not Lose Money
- OpenZeppelin Contracts
and their documentation — audited
implementations of ownership, pausing, tokens, and proxies. Prefer these to
writing your own, including the
ownerpattern inTipJar - Smart Contract Best Practices — the standard catalogue of ways contracts go wrong
- Ethernaut — contracts deliberately
built to be broken, solved one level at a time. Excellent, and playable
entirely with
cast - Damn Vulnerable DeFi — the same idea at a much harder level, written for Foundry
Elsewhere
- Ethereum from the Command Line — the companion tutorial: Anvil, accounts, transactions, blocks, and reading the real chain
- Etherscan — read the verified source of any deployed contract
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 that blockchain:
cast ──JSON-RPC──▶ Anvil
Using cast, you can:
- inspect account balances
- send ETH
- submit transactions
- inspect blocks
- call smart contracts
- read blockchain state
And forge compiles, tests, and deploys the contracts that cast talks to.
That is the whole local development loop, with no frontend anywhere in sight.