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 contracts
  • anvil — run a local Ethereum blockchain
  • cast — 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 contracts
  • anvil — run a local Ethereum blockchain
  • cast — 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 through tip(). 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() resets totalTips to zero, so totalTips tracks tips since the last withdrawal rather than the lifetime total. The per-address tipsByAddress mapping 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:

  • _tip takes string memory, not string calldata. The receive() 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 TipReceived event still fires, which is what matters.
  • require(msg.value > 0, ...) now guards the receive() 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

Solidity and the EVM

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 owner pattern in TipJar
  • 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


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 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.