[{"content":"The previous tutorials drove the TipJar contract entirely from the shell. This one puts it behind a webpage: a Connect button, a tip form, a live balance, and a withdraw button that only appears for the owner.\nThe browser flow is:\nWebpage → ethers.js → MetaMask → Anvil → TipJar contract MetaMask holds the keys and signs the transactions. No private key goes anywhere near the JavaScript. Frontend source is visible to anyone who visits the page, so a key in it is a key you have given away.\nIt follows on from Writing a Smart Contract with Foundry, which builds and deploys the contract used here. Its sibling is Invariant Testing with Foundry, which goes the other direction and tests the contract harder.\nWhat You Will Use anvil — the local blockchain, as before forge — to deploy, and to generate the ABI MetaMask — a browser extension that holds keys and signs transactions ethers.js — the library your page uses to talk to MetaMask Vite — a dev server, so the page is served over HTTP rather than file:// Prerequisites This tutorial differs from the others in one important way: the browser runs on the host, not in the container. MetaMask is a browser extension, so it must be able to reach both the dev server and Anvil\u0026rsquo;s JSON-RPC endpoint from outside the container.\nThat means two ports have to be published, which the container from the earlier tutorials does not do. If you already have one, replace it:\nhost : ~ : main\npodman rm -f foundry Then create it with the ports exposed:\nhost : ~ : main\nmkdir -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\u0026#39;s JSON-RPC, so MetaMask can reach the chain 5173 Vite\u0026#39;s dev server, so the browser can load the page Install Foundry and Node. The base image has neither, and this is the first tutorial in the series that needs npm:\ncontainer : /work : main\nexport DEBIAN_FRONTEND=noninteractive export TZ=Etc/UTC apt-get update apt-get install -y curl git ca-certificates jq nodejs npm curl -L https://foundry.paradigm.xyz | bash echo \u0026#39;export PATH=\u0026#34;$PATH:/root/.foundry/bin\u0026#34;\u0026#39; \u0026gt;\u0026gt; ~/.bashrc source ~/.bashrc foundryup forge --version node --version Setup an empty project\ncd /work forge init tip-jar --empty cd tip-jar You also need the TipJar project itself. If you do not already have it, create it and paste in the contract from Section 2 of the previous tutorial:\ncontainer : /work : main\nFinally, set the environment variables. These are Anvil\u0026rsquo;s published development keys, identical on every machine:\ncontainer : /work/tip-jar : main\nexport RPC_URL=http://127.0.0.1:8545 export OWNER_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 export TIPPER_KEY=0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d export OWNER=$(cast wallet address --private-key \u0026#34;$OWNER_KEY\u0026#34;) export TIPPER=$(cast wallet address --private-key \u0026#34;$TIPPER_KEY\u0026#34;) Where Each Command Runs Every command block is preceded by a line saying where it belongs:\n`container : /work/tip-jar : main` │ │ │ │ │ └── which window │ └── the working directory └── host machine, or inside the container This tutorial uses three shells and a browser:\nmain the shell you work in anvil the shell running the local blockchain web the shell running the Vite dev server browser on the host, with MetaMask installed 1. Start Anvil In a second shell, and note the extra flag:\nhost → container : /work/tip-jar : anvil\npodman exec -it foundry bash cd /work/tip-jar anvil --host 0.0.0.0 By default Anvil binds to 127.0.0.1, which inside a container means \u0026ldquo;reachable only from inside this container\u0026rdquo;. --host 0.0.0.0 makes it listen on all interfaces so the published port actually forwards, and MetaMask on the host can reach it.\nAnvil is now available at:\nhttp://127.0.0.1:8545 from inside the container http://127.0.0.1:8545 from the host, via --publish with chain ID:\n31337 Leave it running.\n2. Deploy the TipJar Contract Back in the main shell:\ncontainer : /work/tip-jar : main\nforge build forge create src/TipJar.sol:TipJar \\ --private-key \u0026#34;$OWNER_KEY\u0026#34; \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; \\ --broadcast You should get output similar to:\nDeployer: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 Deployed to: 0x... Transaction hash: 0x... Save the address printed after Deployed to:, because the frontend needs it too:\ncontainer : /work/tip-jar : main\nexport TIPJAR=0xYOUR_CONTRACT_ADDRESS Deploying with $OWNER_KEY is what makes Anvil account (0) the owner, since the constructor records msg.sender. That matters in Section 11, where only that account can withdraw.\nAnvil resets when it is restarted. If you restart it, deploy again and update the address in the frontend as well as in $TIPJAR.\n3. Set up MetaMask https://support.metamask.io/start/getting-started-with-metamask/\n3. Add Anvil to MetaMask In the browser, add a custom network:\nNetwork name: Anvil RPC URL: http://127.0.0.1:8545 Chain ID: 31337 Currency: ETH You can also let the JavaScript in Section 7 ask MetaMask to add the network automatically, which is what wallet_addEthereumChain does.\n4. Import Test Accounts into MetaMask Add wallet Via a private key For sending tips, import Anvil account (1):\n0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d For testing owner withdrawals, import account (0):\n0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 These are Anvil\u0026rsquo;s well-known development accounts, and they are the same $TIPPER_KEY and $OWNER_KEY the shell has been using.\nNever use these keys for real funds, for a public testnet account you care about, or on mainnet. They are published in Anvil\u0026rsquo;s documentation; anyone can sweep them. Consider using a separate browser profile for local development so they never sit alongside a real wallet.\n5. Set Up the Frontend Create a Vite project alongside the contract, and add ethers:\ncontainer : /work/tip-jar : main\nnpm_config_yes=true npm create vite@latest frontend -- --template vanilla --no-interactive --no-immediate \u0026amp;\u0026amp; npm --prefix frontend install cd frontend npm install --no-audit --no-fund --progress=false npm install ethers --no-audit --no-fund --progress=false The parts that matter:\nfrontend/ ├── index.html ├── package.json └── src/ └── tipjar.js You can delete the rest of the scaffolding Vite generates.\n6. Add the HTML Replace the contents of frontend/index.html\u0026rsquo;s \u0026lt;body\u0026gt; with:\n\u0026lt;section\u0026gt; \u0026lt;h1\u0026gt;Tip Jar\u0026lt;/h1\u0026gt; \u0026lt;button id=\u0026#34;connect\u0026#34;\u0026gt;Connect wallet\u0026lt;/button\u0026gt; \u0026lt;p\u0026gt; Wallet: \u0026lt;span id=\u0026#34;wallet\u0026#34;\u0026gt;Not connected\u0026lt;/span\u0026gt; \u0026lt;/p\u0026gt; \u0026lt;p\u0026gt; Contract balance: \u0026lt;strong\u0026gt;\u0026lt;span id=\u0026#34;balance\u0026#34;\u0026gt;0\u0026lt;/span\u0026gt; ETH\u0026lt;/strong\u0026gt; \u0026lt;/p\u0026gt; \u0026lt;p\u0026gt; Contract owner: \u0026lt;span id=\u0026#34;owner\u0026#34;\u0026gt;Unknown\u0026lt;/span\u0026gt; \u0026lt;/p\u0026gt; \u0026lt;div\u0026gt; \u0026lt;label for=\u0026#34;amount\u0026#34;\u0026gt;Tip amount\u0026lt;/label\u0026gt; \u0026lt;input id=\u0026#34;amount\u0026#34; type=\u0026#34;number\u0026#34; min=\u0026#34;0\u0026#34; step=\u0026#34;0.01\u0026#34; value=\u0026#34;0.1\u0026#34; /\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;div\u0026gt; \u0026lt;label for=\u0026#34;message\u0026#34;\u0026gt;Message\u0026lt;/label\u0026gt; \u0026lt;input id=\u0026#34;message\u0026#34; type=\u0026#34;text\u0026#34; value=\u0026#34;Great work!\u0026#34; /\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;button id=\u0026#34;tip\u0026#34; disabled\u0026gt;Send tip\u0026lt;/button\u0026gt; \u0026lt;button id=\u0026#34;withdraw\u0026#34; disabled\u0026gt;Withdraw\u0026lt;/button\u0026gt; \u0026lt;button id=\u0026#34;refresh\u0026#34; disabled\u0026gt;Refresh balance\u0026lt;/button\u0026gt; \u0026lt;pre id=\u0026#34;status\u0026#34;\u0026gt;\u0026lt;/pre\u0026gt; \u0026lt;/section\u0026gt; \u0026lt;script type=\u0026#34;module\u0026#34; src=\u0026#34;/src/tipjar.js\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; 7. Create src/tipjar.js Replace PASTE_CONTRACT_ADDRESS_HERE with the address forge create printed in Section 2 — the same value as $TIPJAR.\nimport { BrowserProvider, Contract, formatEther, parseEther, } from \u0026#34;ethers\u0026#34;; const CONTRACT_ADDRESS = \u0026#34;PASTE_CONTRACT_ADDRESS_HERE\u0026#34;; const ANVIL_CHAIN_ID = \u0026#34;0x7a69\u0026#34;; // 31337 const TIP_JAR_ABI = [ \u0026#34;function owner() view returns (address)\u0026#34;, \u0026#34;function getBalance() view returns (uint256)\u0026#34;, \u0026#34;function tipsByAddress(address) view returns (uint256)\u0026#34;, \u0026#34;function tip(string message) payable\u0026#34;, \u0026#34;function withdraw()\u0026#34;, \u0026#34;event TipReceived(address indexed sender, uint256 amount, string message)\u0026#34;, \u0026#34;event Withdrawn(address indexed owner, uint256 amount)\u0026#34;, ]; const connectButton = document.querySelector(\u0026#34;#connect\u0026#34;); const tipButton = document.querySelector(\u0026#34;#tip\u0026#34;); const withdrawButton = document.querySelector(\u0026#34;#withdraw\u0026#34;); const refreshButton = document.querySelector(\u0026#34;#refresh\u0026#34;); const walletElement = document.querySelector(\u0026#34;#wallet\u0026#34;); const ownerElement = document.querySelector(\u0026#34;#owner\u0026#34;); const balanceElement = document.querySelector(\u0026#34;#balance\u0026#34;); const amountInput = document.querySelector(\u0026#34;#amount\u0026#34;); const messageInput = document.querySelector(\u0026#34;#message\u0026#34;); const statusElement = document.querySelector(\u0026#34;#status\u0026#34;); let provider; let signer; let contract; let connectedAddress; function setStatus(message) { statusElement.textContent = message; } function getErrorMessage(error) { return ( error?.shortMessage || error?.reason || error?.info?.error?.message || error?.message || String(error) ); } async function selectAnvilNetwork() { try { await window.ethereum.request({ method: \u0026#34;wallet_switchEthereumChain\u0026#34;, params: [{ chainId: ANVIL_CHAIN_ID }], }); } catch (error) { if (error.code !== 4902) { throw error; } await window.ethereum.request({ method: \u0026#34;wallet_addEthereumChain\u0026#34;, params: [ { chainId: ANVIL_CHAIN_ID, chainName: \u0026#34;Anvil\u0026#34;, nativeCurrency: { name: \u0026#34;Ether\u0026#34;, symbol: \u0026#34;ETH\u0026#34;, decimals: 18, }, rpcUrls: [\u0026#34;http://127.0.0.1:8545\u0026#34;], }, ], }); } } async function connectWallet() { try { if (!window.ethereum) { throw new Error(\u0026#34;MetaMask is not installed.\u0026#34;); } setStatus(\u0026#34;Connecting to MetaMask...\u0026#34;); await window.ethereum.request({ method: \u0026#34;eth_requestAccounts\u0026#34;, }); await selectAnvilNetwork(); provider = new BrowserProvider(window.ethereum); signer = await provider.getSigner(); connectedAddress = await signer.getAddress(); contract = new Contract( CONTRACT_ADDRESS, TIP_JAR_ABI, signer, ); walletElement.textContent = connectedAddress; connectButton.textContent = \u0026#34;Wallet connected\u0026#34;; tipButton.disabled = false; refreshButton.disabled = false; await refreshContract(); setStatus(\u0026#34;Connected to Anvil.\u0026#34;); } catch (error) { console.error(error); setStatus(`Connection failed: ${getErrorMessage(error)}`); } } async function refreshContract() { if (!contract) { return; } try { const [balance, owner] = await Promise.all([ contract.getBalance(), contract.owner(), ]); balanceElement.textContent = formatEther(balance); ownerElement.textContent = owner; withdrawButton.disabled = owner.toLowerCase() !== connectedAddress.toLowerCase(); } catch (error) { console.error(error); setStatus( `Could not read contract: ${getErrorMessage(error)}. ` + \u0026#34;Check that Anvil is running and the contract address is correct.\u0026#34;, ); } } async function sendTip() { try { const amount = amountInput.value.trim(); const message = messageInput.value.trim(); if (!amount || Number(amount) \u0026lt;= 0) { throw new Error(\u0026#34;Enter a tip greater than zero.\u0026#34;); } if (!message) { throw new Error(\u0026#34;Enter a message.\u0026#34;); } tipButton.disabled = true; setStatus(\u0026#34;Confirm the tip in MetaMask...\u0026#34;); const transaction = await contract.tip(message, { value: parseEther(amount), }); setStatus(`Transaction submitted:\\n${transaction.hash}`); await transaction.wait(); setStatus(`Tip confirmed:\\n${transaction.hash}`); await refreshContract(); } catch (error) { console.error(error); setStatus(`Tip failed: ${getErrorMessage(error)}`); } finally { tipButton.disabled = false; } } async function withdraw() { try { withdrawButton.disabled = true; setStatus(\u0026#34;Confirm the withdrawal in MetaMask...\u0026#34;); const transaction = await contract.withdraw(); setStatus(`Withdrawal submitted:\\n${transaction.hash}`); await transaction.wait(); setStatus(`Withdrawal confirmed:\\n${transaction.hash}`); await refreshContract(); } catch (error) { console.error(error); setStatus(`Withdrawal failed: ${getErrorMessage(error)}`); } finally { await refreshContract(); } } connectButton.addEventListener(\u0026#34;click\u0026#34;, connectWallet); tipButton.addEventListener(\u0026#34;click\u0026#34;, sendTip); withdrawButton.addEventListener(\u0026#34;click\u0026#34;, withdraw); refreshButton.addEventListener(\u0026#34;click\u0026#34;, refreshContract); window.ethereum?.on(\u0026#34;accountsChanged\u0026#34;, () =\u0026gt; { window.location.reload(); }); window.ethereum?.on(\u0026#34;chainChanged\u0026#34;, () =\u0026gt; { window.location.reload(); }); There is a lot here, but it is four ideas:\nBrowserProvider wraps window.ethereum, the object MetaMask injects signer an account that can sign; comes from the provider Contract address + ABI + signer, giving you contract.tip(...) parseEther the same ETH-to-wei conversion cast to-wei does The ABI is the bridge. It is the same information cast needs when you type \u0026quot;tip(string)\u0026quot; on the command line, just declared up front so ethers can encode calls for you.\nTwo details worth noticing. refreshContract enables the withdraw button only when the connected address matches owner(), which is a courtesy, not security — the contract\u0026rsquo;s own require is what actually enforces it. And the accountsChanged and chainChanged handlers reload the page, because a signer bound to the old account would otherwise keep signing as the wrong person.\n8. Run the Frontend host → container : /work/tip-jar/frontend : web\npodman exec -it foundry bash cd tip-jar/frontend npm run dev -- --host 0.0.0.0 The --host 0.0.0.0 is needed for the same reason Anvil needed it: Vite binds to localhost by default, which inside a container is unreachable from the host.\nVite will print something similar to:\nhttp://localhost:5173 Open that in a browser with MetaMask installed.\n9. Send a Tip On the webpage:\nClick Connect wallet. Connect Anvil account (1), the tipper. Make sure MetaMask is on the Anvil network. Enter 0.1 as the tip amount. Enter a message, for example Great work!. Click Send tip. Approve the transaction in MetaMask. Once mined, the page should show:\nContract balance: 0.1 ETH Watch the Anvil window while you do this. It logs the eth_sendRawTransaction and mines a block, exactly as it did for cast send. The browser is doing nothing the command line was not.\n10. Verify from the Command Line The webpage says the tip arrived. Confirm it independently, which is the whole advantage of having both interfaces:\ncontainer : /work/tip-jar : main\ncast balance \u0026#34;$TIPJAR\u0026#34; \\ --ether \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; 0.100000000000000000 And through the contract\u0026rsquo;s own function:\ncontainer : /work/tip-jar : main\ncast call \u0026#34;$TIPJAR\u0026#34; \\ \u0026#34;getBalance()(uint256)\u0026#34; \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; The message you typed into the form is in the event log, not in storage, and can be read back exactly as Section 17 of the previous tutorial describes:\ncontainer : /work/tip-jar : main\ncast logs \\ \u0026#34;TipReceived(address indexed sender, uint256 amount, string message)\u0026#34; \\ --from-block 0 \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; 11. Withdraw from the Webpage Switch MetaMask to Anvil account (0) — the account that deployed the contract, and therefore its owner.\nReload the page, or reconnect MetaMask. The Withdraw button becomes enabled.\nClick it and approve the transaction. The contract balance returns to:\n0 ETH Try it the other way round to see the contract defend itself: switch back to account (1), and the button greys out. That is only the UI being polite. The real check is require(msg.sender == owner, ...) inside withdraw(), and a crafted call from account (1) would still revert — which is exactly what happened on the command line in Section 14 of the previous tutorial.\n12. Using Foundry\u0026rsquo;s Generated ABI The example above declares the ABI by hand:\nconst TIP_JAR_ABI = [ \u0026#34;function owner() view returns (address)\u0026#34;, \u0026#34;function getBalance() view returns (uint256)\u0026#34;, \u0026#34;function tipsByAddress(address) view returns (uint256)\u0026#34;, \u0026#34;function tip(string message) payable\u0026#34;, \u0026#34;function withdraw()\u0026#34;, ]; That is fine for five functions and hopeless for fifty. Worse, it drifts: change the Solidity and the JavaScript silently keeps the old signature.\nFoundry already writes a complete ABI during forge build:\nout/TipJar.sol/TipJar.json Extract just the ABI array from it:\ncontainer : /work/tip-jar : main\njq \u0026#39;.abi\u0026#39; \\ out/TipJar.sol/TipJar.json \\ \u0026gt; frontend/src/TipJar.abi.json Then import it instead:\nimport TipJarABI from \u0026#34;./TipJar.abi.json\u0026#34;; contract = new Contract( CONTRACT_ADDRESS, TipJarABI, signer, ); Re-run that jq line after any contract change and the frontend cannot fall out of sync. In a real project it belongs in a build script.\n13. The Whole Development Loop Three shells, in order:\nhost → container : /work/tip-jar : anvil\npodman exec -it foundry bash cd tip-jar anvil --host 0.0.0.0 container : /work/tip-jar : main\nforge build forge create src/TipJar.sol:TipJar \\ --private-key \u0026#34;$OWNER_KEY\u0026#34; \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; \\ --broadcast export TIPJAR=0xYOUR_CONTRACT_ADDRESS jq \u0026#39;.abi\u0026#39; out/TipJar.sol/TipJar.json \u0026gt; frontend/src/TipJar.abi.json host → container : /work/tip-jar/frontend : web\npodman exec -it foundry bash cd tip-jar/frontend npm run dev -- --host 0.0.0.0 Which assembles into:\nSolidity ↓ forge build ↓ forge create ↓ Anvil ↑ MetaMask ↑ ethers.js ↑ Webpage Read it as two halves meeting at Anvil. Everything above the chain is your code being compiled and deployed; everything below it is the browser asking MetaMask to sign, so the transaction arrives the same way cast send did.\n14. The Rule That Matters For local development, Anvil\u0026rsquo;s default keys on the command line are fine. They are public, and the ETH is fake.\nFor anything real, the model is:\nBrowser → MetaMask → Smart Contract Never this:\nBrowser JavaScript → Private Key → Smart Contract A private key in a webpage should be considered compromised the moment the page is served. This is not a matter of obfuscation or minification — the browser has to be able to read the code in order to run it, and so can anyone else. Signing is the wallet\u0026rsquo;s job precisely so your application never has to hold the secret.\nThe same applies to any key you paste into a terminal on a machine you do not control, or commit to a repository. Assume anything that leaves your control is public.\n15. Additional Resources ethers.js documentation — v6, which is what the code above uses. Note that v5 examples on the web will not work unchanged MetaMask developer docs — the provider API, including wallet_addEthereumChain and wallet_switchEthereumChain EIP-1193 — the standard behind window.ethereum, which every browser wallet implements viem — the main modern alternative to ethers, worth knowing about if you start a new project Vite — the dev server used here The Rest of the Series Ethereum from the Command Line — Anvil, accounts, transactions, and reading the real chain Writing a Smart Contract with Foundry — the contract this page talks to Invariant Testing with Foundry — the sibling of this tutorial: properties that must hold after any sequence of calls Summary Webpage your HTML and JavaScript ↓ ethers.js encodes calls using the ABI ↓ MetaMask holds the key, signs, submits ↓ Anvil mines the transaction ↓ TipJar runs, and updates its state Nothing in that chain is new except the top two rows. The contract, the chain, and the transactions are the same ones the command-line tutorials produced — the browser is just another JSON-RPC client, and the only genuinely new idea is that the key now lives in a wallet instead of an environment variable.\n","permalink":"https://cedarpiano.fyi/posts/tipjar-webpage/","summary":"\u003cp\u003eThe previous tutorials drove the \u003ccode\u003eTipJar\u003c/code\u003e contract entirely from the shell. This\none puts it behind a webpage: a Connect button, a tip form, a live balance, and\na withdraw button that only appears for the owner.\u003c/p\u003e\n\u003cp\u003eThe browser flow is:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003eWebpage → ethers.js → MetaMask → Anvil → TipJar contract\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eMetaMask holds the keys and signs the transactions. \u003cstrong\u003eNo private key goes\nanywhere near the JavaScript.\u003c/strong\u003e Frontend source is visible to anyone who visits\nthe page, so a key in it is a key you have given away.\u003c/p\u003e","title":"Adding a Tip Jar to a Webpage"},{"content":"Unit tests check that a function does what you expected. Invariant tests check something harder: that a property of your contract holds no matter what anybody does to it. This tutorial writes both kinds against a TipJar contract, watches the invariant catch a bug every unit test missed, and then shows why the obvious invariant is still wrong.\nIt follows on from Writing a Smart Contract with Foundry, and picks up exactly where that one ends. Its sibling, Adding a Tip Jar to a Webpage, takes the same contract in the other direction and puts a browser frontend on it. Neither depends on the other.\nWhat You Will Use forge — specifically forge test, its fuzzer, and its invariant runner anvil — not needed here; invariant tests run entirely inside forge Prerequisites You need the TipJar project from the previous tutorial, with the fixed receive() from its Section 20. If you have it, skip ahead.\nIf not, get the container and toolchain up:\nhost : ~ : main\nmkdir -p ~/tip-jar-work podman run -dit \\ --name foundry \\ --volume ~/tip-jar-work:/work:Z \\ --workdir /work \\ ubuntu sleep infinity podman exec -it foundry bash container : /work : main\napt-get update apt-get install -y curl git ca-certificates curl -L https://foundry.paradigm.xyz | bash source ~/.bashrc foundryup forge init tip-jar --empty cd tip-jar Then create src/TipJar.sol with the finished contract — the version that routes both entry points through one internal function:\ncontainer : /work/tip-jar : main\n// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; contract TipJar { address public immutable owner; mapping(address =\u0026gt; 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 { _tip(message); } receive() external payable { _tip(\u0026#34;\u0026#34;); } function _tip(string memory message) private { require(msg.value \u0026gt; 0, \u0026#34;Tip must be greater than zero\u0026#34;); 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, \u0026#34;Only owner can withdraw\u0026#34;); uint256 amount = address(this).balance; require(amount \u0026gt; 0, \u0026#34;Nothing to withdraw\u0026#34;); totalTips = 0; (bool success, ) = payable(owner).call{value: amount}(\u0026#34;\u0026#34;); require(success, \u0026#34;Withdrawal failed\u0026#34;); emit Withdrawn(owner, amount); } } Check it builds:\ncontainer : /work/tip-jar : main\nforge build Nothing in this tutorial needs Anvil. forge test runs its own EVM in process, which is why invariant tests can execute thousands of transactions in seconds.\nWhere Each Command Runs Every command block is preceded by a line saying where it belongs:\n`container : /work/tip-jar : main` │ │ │ │ │ └── which window │ └── the working directory └── host machine, or inside the container Only one shell is needed for this tutorial.\n1. Why Unit Tests Missed It The bug in Section 20 of the previous tutorial is the interesting kind: the code was not wrong in any single step, it just let two pieces of state drift apart. Adding a bare receive() let ETH arrive without totalTips or tipsByAddress moving, so the contract\u0026rsquo;s balance and its own bookkeeping quietly disagreed.\nEvery unit test from Section 19 still passed, because every one of them calls tip(). Nobody thought to write a test for the path they had just created.\nInvariant tests attack that blind spot from the other direction. Instead of \u0026ldquo;given this input, expect that output\u0026rdquo;, you state a property that must hold no matter what happens, and Foundry generates long random sequences of calls trying to break it.\nHere the property is the one the naive receive() violated: the contract\u0026rsquo;s recorded tips should equal the ETH it holds.\n2. A Handler Point the fuzzer at a handler rather than at TipJar directly. A handler is a contract that stands in for the outside world: it holds the ETH, keeps the random inputs in a sensible range, and decides which paths are worth exercising.\nCreate test/TipJarInvariant.t.sol:\n// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import {Test} from \u0026#34;forge-std/Test.sol\u0026#34;; import {TipJar} from \u0026#34;../src/TipJar.sol\u0026#34;; contract TipJarHandler is Test { TipJar public immutable tipJar; uint256 public totalTipped; uint256 public totalWithdrawn; constructor() { // The handler deploys the jar, so the handler is the owner. tipJar = new TipJar(); } // Required so the handler can receive its own withdrawals. receive() external payable {} function tip(uint96 amount, string calldata message) external { uint256 value = bound(amount, 1, 10 ether); vm.deal(address(this), address(this).balance + value); tipJar.tip{value: value}(message); totalTipped += value; } function sendPlainEth(uint96 amount) external { uint256 value = bound(amount, 1, 10 ether); vm.deal(address(this), address(this).balance + value); (bool ok, ) = address(tipJar).call{value: value}(\u0026#34;\u0026#34;); if (ok) { totalTipped += value; } } function withdraw() external { if (address(tipJar).balance == 0) { return; } totalWithdrawn += address(tipJar).balance; tipJar.withdraw(); } } contract TipJarInvariantTest is Test { TipJarHandler private handler; TipJar private tipJar; function setUp() public { handler = new TipJarHandler(); tipJar = handler.tipJar(); targetContract(address(handler)); } function invariant_accountingMatchesBalance() public view { assertEq(tipJar.totalTips(), address(tipJar).balance); } function invariant_noEthCreatedOrDestroyed() public view { assertEq( address(tipJar).balance, handler.totalTipped() - handler.totalWithdrawn() ); } } Notes on the mechanics:\nbound squeezes a fuzzed number into a range. Without it, most runs would use an absurd amount and fail for reasons that teach you nothing. vm.deal sets a balance rather than adding to it. That is why the handler passes address(this).balance + value, instead of quietly destroying whatever it withdrew earlier. sendPlainEth uses a low-level call and inspects the result instead of reverting. That way the same handler works both before and after receive() exists. targetContract restricts the fuzzer to the handler. Without it, Foundry targets every contract created in setUp(). totalTipped and totalWithdrawn are ghost variables: bookkeeping that lives only in the test, so the invariant has something independent to compare the contract against. Invariant tests run as part of the normal test command:\ncontainer : /work/tip-jar : main\nforge test --match-contract TipJarInvariantTest -vv How hard it searches is configured in foundry.toml, in the project root:\n[invariant] runs = 256 depth = 50 fail_on_revert = true runs is how many random sequences to try, depth is how many calls per sequence. fail_on_revert = true is worth turning on while writing a handler: it tells you when handler calls are reverting and therefore testing nothing.\n3. Watching It Catch the Bug Both invariants pass against the fixed contract. To see the point of them, put the broken version back:\nreceive() external payable {} Then rerun:\ncontainer : /work/tip-jar : main\nforge test --match-contract TipJarInvariantTest -vv invariant_accountingMatchesBalance fails, and Foundry prints the call sequence that broke it — a sendPlainEth that moved the balance without moving totalTips. Nobody had to think of that case.\nNote that invariant_noEthCreatedOrDestroyed still passes, because no ETH went missing. The two invariants are checking genuinely different things: one says the contract\u0026rsquo;s story about itself is consistent, the other says nothing leaked.\n4. Why assertEq Is Still Too Strong There is a catch, and it is a good lesson in its own right. A contract cannot refuse ETH sent by a selfdestruct, and no code of yours runs when it arrives. Add this to the invariant file:\ncontract ForceFeeder { constructor(address payable target) payable { selfdestruct(target); } } And a unit test that uses it:\nfunction testBalanceCanExceedAccounting() public { vm.deal(address(this), 1 ether); new ForceFeeder{value: 1 ether}(payable(address(tipJar))); assertEq(address(tipJar).balance, 1 ether); assertEq(tipJar.totalTips(), 0); } The ETH lands, totalTips stays at zero, and there is nothing TipJar can do about it. So totalTips == balance is not a property any contract can actually guarantee. The honest version is one-directional:\nfunction invariant_accountingNeverExceedsBalance() public view { assertLe(tipJar.totalTips(), address(tipJar).balance); } Tips are always covered by real ETH, but the balance may be larger than the contract knows about. This is also the reason withdraw() sends address(this).balance rather than totalTips: reading the balance sweeps force-fed ETH out too, where trusting the accounting would strand it in the contract forever.\nThe general rule is worth remembering beyond this contract: a contract\u0026rsquo;s internal accounting and its actual balance are two different numbers, and code that assumes they are equal is a common source of stuck funds.\n5. Where to Go Next The general rule from Section 4 is worth carrying beyond this contract: a contract\u0026rsquo;s internal accounting and its actual balance are two different numbers, and code that assumes they are equal is a common source of stuck funds.\nTwo directions from here.\nFuzz your own properties. Anything you can state as \u0026ldquo;this should always be true\u0026rdquo; is a candidate. Balances that should sum to a total, a supply that should never exceed a cap, an access check that should never let a non-owner through.\nRead the handler patterns properly. Real invariant suites spend most of their effort on the handler — restricting the fuzzer to sensible inputs, and making sure calls are not silently reverting and therefore testing nothing. fail_on_revert = true is how you find that out.\n6. Additional Resources Invariant testing — the official reference for handlers, targets, and configuration Fuzz testing — the property-based layer invariant tests are built on forge test reference — cheatcodes including bound, vm.deal, and vm.prank forge-std — the source of Test, bound, and the vm cheatcode interface EIP-6780 — why selfdestruct no longer deletes contracts, though it still force-feeds ETH as Section 4 relies on Adding a Tip Jar to a Webpage — this tutorial\u0026rsquo;s sibling: ethers.js, MetaMask, and a browser frontend Ethereum from the Command Line — the first tutorial in this series Writing a Smart Contract with Foundry — the second, which builds the contract tested here Summary unit test given this input, expect that output fuzz test for any input in a range, this should hold invariant test after ANY sequence of calls, this should hold Unit tests check the paths you thought of. Invariant tests are how you find out about the ones you did not — which, as the naive receive() showed, are generally the paths that lose money.\n","permalink":"https://cedarpiano.fyi/posts/invariant-testing-foundry/","summary":"\u003cp\u003eUnit tests check that a function does what you expected. Invariant tests check\nsomething harder: that a property of your contract holds no matter what anybody\ndoes to it. This tutorial writes both kinds against a \u003ccode\u003eTipJar\u003c/code\u003e contract, watches\nthe invariant catch a bug every unit test missed, and then shows why the\nobvious invariant is still wrong.\u003c/p\u003e\n\u003cp\u003eIt follows on from\n\u003ca href=\"/posts/smart-contract-foundry/\"\u003eWriting a Smart Contract with Foundry\u003c/a\u003e, and\npicks up exactly where that one ends. Its sibling,\n\u003ca href=\"/posts/tipjar-webpage/\"\u003eAdding a Tip Jar to a Webpage\u003c/a\u003e, takes the same contract\nin the other direction and puts a browser frontend on it. Neither depends on the\nother.\u003c/p\u003e","title":"Invariant Testing with Foundry"},{"content":"This tutorial writes, tests, deploys, and drives a TipJar smart contract entirely from the command line.\nIt 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.\nA tip jar: about the smallest contract that is still genuinely useful.\nanyone ──── 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\u0026rsquo;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.\nYou will use:\nforge — 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.\nNo webpage or frontend is required.\nThe 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.\nYou will use:\nforge — 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.\nPrerequisites If you already have the container and Anvil running from the previous tutorial, skip this. Otherwise, these four blocks get you there.\nStart the container and open a shell in it:\nhost : ~ : main\nmkdir -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\u0026#39;s JSON-RPC, so MetaMask can reach the chain 5173 Vite\u0026#39;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:\ncontainer : /work : main\napt-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=\u0026quot;$HOME/.foundry/bin:$PATH\u0026quot;.\nStart the local blockchain in a second shell, and leave it running:\nhost → container : /work : anvil\npodman exec -it foundry bash anvil Set the environment variables back in the first shell. The keys are Anvil\u0026rsquo;s published development keys, printed on its startup screen and identical on every machine:\ncontainer : /work : main\nexport RPC_URL=http://127.0.0.1:8545 export OWNER_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 export TIPPER_KEY=0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d export OWNER=$(cast wallet address --private-key \u0026#34;$OWNER_KEY\u0026#34;) export TIPPER=$(cast wallet address --private-key \u0026#34;$TIPPER_KEY\u0026#34;) $OWNER will deploy the tip jar and own it; $TIPPER will send tips. One variable is still missing — TIPJAR, the contract\u0026rsquo;s address — because the contract does not exist until Section 4 deploys it.\nThose keys are safe only because the chain is local and the ETH is fake. Never use them with real funds.\nWhere Each Command Runs Every command block is preceded by a line saying where it belongs:\n`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\nforge 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 =\u0026gt; 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 \u0026gt; 0, \u0026#34;Tip must be greater than zero\u0026#34;); 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, \u0026#34;Only owner can withdraw\u0026#34;); uint256 amount = address(this).balance; require(amount \u0026gt; 0, \u0026#34;Nothing to withdraw\u0026#34;); totalTips = 0; (bool success, ) = payable(owner).call{value: amount}(\u0026#34;\u0026#34;); require(success, \u0026#34;Withdrawal failed\u0026#34;); emit Withdrawn(owner, amount); } } Two details worth noting before we run it:\nThe 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.\nThey 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.\nThere are three such globals, and the useful thing to know about them is how long each one holds still:\nmsg 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:\nmsg.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\u0026rsquo;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.\nTwo of them are traps worth knowing about now.\nblock.timestamp is set by whoever proposes the block, within a tolerance of a few seconds. It is fine for \u0026ldquo;has a day passed\u0026rdquo;, useless for anything needing precision, and must never be treated as unpredictable.\nblock.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.\nmsg 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:\ntipper ──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.\nSo 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:\nrequire(msg.sender == owner, \u0026#34;Only owner can withdraw\u0026#34;); 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.\nOne field has been removed rather than renamed: msg.gas became the free function gasleft().\n3. Compile container : /work/tip-jar : main\nforge build The compiled artifact will appear under:\nout/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\u0026rsquo;s owner:\ncontainer : /work/tip-jar : main\nforge create src/TipJar.sol:TipJar \\ --private-key \u0026#34;$OWNER_KEY\u0026#34; \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; \\ --broadcast You should see something like:\nDeployer: 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:\ncontainer : /work/tip-jar : main\nexport TIPJAR=0xPASTE_THE_DEPLOYED_TO_ADDRESS_HERE Check it:\ncontainer : /work/tip-jar : main\necho \u0026#34;$TIPJAR\u0026#34; 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.\n5. Verify the Contract Exists container : /work/tip-jar : main\ncast code \u0026#34;$TIPJAR\u0026#34; \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; A deployed contract returns bytecode beginning with:\n0x... If the result is only:\n0x there is no contract at that address, which usually means $TIPJAR was mistyped or is left over from an earlier Anvil run.\n6. Read the Owner container : /work/tip-jar : main\ncast call \u0026#34;$TIPJAR\u0026#34; \\ \u0026#34;owner()(address)\u0026#34; \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; This should match:\ncontainer : /work/tip-jar : main\necho \u0026#34;$OWNER\u0026#34; 7. Check the Initial Balance container : /work/tip-jar : main\ncast balance \u0026#34;$TIPJAR\u0026#34; \\ --ether \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; Initially:\n0.000000000000000000 You can also use the contract function:\ncontainer : /work/tip-jar : main\ncast call \u0026#34;$TIPJAR\u0026#34; \\ \u0026#34;getBalance()(uint256)\u0026#34; \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; 8. Send a Tip Sign with $TIPPER_KEY, so the tip comes from $TIPPER:\ncontainer : /work/tip-jar : main\ncast send \u0026#34;$TIPJAR\u0026#34; \\ \u0026#34;tip(string)\u0026#34; \u0026#34;Great work!\u0026#34; \\ --value 0.1ether \\ --private-key \u0026#34;$TIPPER_KEY\u0026#34; \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; This sends 0.1 ETH to the contract and stores the message.\n9. Check the Contract Balance container : /work/tip-jar : main\ncast balance \u0026#34;$TIPJAR\u0026#34; \\ --ether \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; Expected:\n0.100000000000000000 10. Call getBalance() container : /work/tip-jar : main\ncast call \u0026#34;$TIPJAR\u0026#34; \\ \u0026#34;getBalance()(uint256)\u0026#34; \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; For 0.1 ETH the result is:\n100000000000000000 That value is in wei. Convert it to ETH:\ncontainer : /work/tip-jar : main\ncast from-wei 100000000000000000 11. Check How Much the Tipper Has Tipped container : /work/tip-jar : main\ncast call \u0026#34;$TIPJAR\u0026#34; \\ \u0026#34;tipsByAddress(address)(uint256)\u0026#34; \u0026#34;$TIPPER\u0026#34; \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; Expected:\n100000000000000000 Or, converted:\ncontainer : /work/tip-jar : main\nTIP_AMOUNT=$(cast call \u0026#34;$TIPJAR\u0026#34; \\ \u0026#34;tipsByAddress(address)(uint256)\u0026#34; \u0026#34;$TIPPER\u0026#34; \\ --rpc-url \u0026#34;$RPC_URL\u0026#34;) cast from-wei \u0026#34;$TIP_AMOUNT\u0026#34; Expected:\n0.100000000000000000 12. Check Total Tips container : /work/tip-jar : main\ncast call \u0026#34;$TIPJAR\u0026#34; \\ \u0026#34;totalTips()(uint256)\u0026#34; \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; Expected after one 0.1 ETH tip:\n100000000000000000 13. Send Another Tip container : /work/tip-jar : main\ncast send \u0026#34;$TIPJAR\u0026#34; \\ \u0026#34;tip(string)\u0026#34; \u0026#34;Second tip\u0026#34; \\ --value 0.25ether \\ --private-key \u0026#34;$TIPPER_KEY\u0026#34; \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; Check the contract:\ncontainer : /work/tip-jar : main\ncast balance \u0026#34;$TIPJAR\u0026#34; \\ --ether \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; Expected:\n0.350000000000000000 14. Test the Owner Restriction Try withdrawing with $TIPPER_KEY:\ncontainer : /work/tip-jar : main\ncast send \u0026#34;$TIPJAR\u0026#34; \\ \u0026#34;withdraw()\u0026#34; \\ --private-key \u0026#34;$TIPPER_KEY\u0026#34; \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; This should fail. $TIPPER is not the address stored in the contract\u0026rsquo;s owner field, and the Solidity check is:\nrequire(msg.sender == owner, \u0026#34;Only owner can withdraw\u0026#34;); 15. Withdraw as the Owner Sign with $OWNER_KEY, the key that deployed the contract:\ncontainer : /work/tip-jar : main\ncast send \u0026#34;$TIPJAR\u0026#34; \\ \u0026#34;withdraw()\u0026#34; \\ --private-key \u0026#34;$OWNER_KEY\u0026#34; \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; This transfers the contract\u0026rsquo;s ETH to the owner.\n16. Confirm the Contract Is Empty container : /work/tip-jar : main\ncast balance \u0026#34;$TIPJAR\u0026#34; \\ --ether \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; Expected:\n0.000000000000000000 Check totalTips:\ncontainer : /work/tip-jar : main\ncast call \u0026#34;$TIPJAR\u0026#34; \\ \u0026#34;totalTips()(uint256)\u0026#34; \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; Expected:\n0 The tipsByAddress mapping is not cleared, so the contract still records how much each address has tipped historically:\ncontainer : /work/tip-jar : main\ncast call \u0026#34;$TIPJAR\u0026#34; \\ \u0026#34;tipsByAddress(address)(uint256)\u0026#34; \u0026#34;$TIPPER\u0026#34; \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; Expected:\n350000000000000000 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:\ncontainer : /work/tip-jar : main\nexport 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:\ncontainer : /work/tip-jar : main\ncast tx \u0026#34;$TX\u0026#34; \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; cast receipt shows what happened when it was mined, including status, gasUsed, the block it landed in, and any events the contract emitted:\ncontainer : /work/tip-jar : main\ncast receipt \u0026#34;$TX\u0026#34; \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; The receipt for one of the tip() calls is where you can see the TipReceived event in the logs field.\nIf you would rather not copy hashes by hand, cast send can print JSON, so the hash can be captured straight into a variable:\ncontainer : /work/tip-jar : main\nexport TX=$(cast send \u0026#34;$TIPJAR\u0026#34; \\ \u0026#34;tip(string)\u0026#34; \u0026#34;Captured hash\u0026#34; \\ --value 0.01ether \\ --private-key \u0026#34;$TIPPER_KEY\u0026#34; \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; \\ --json | jq -r .transactionHash) echo \u0026#34;$TX\u0026#34; 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.\nReading the Tip Messages Every tip so far carried a message — \u0026quot;Great work!\u0026quot; in Section 8 — and we have never read one back. Doing so takes eight small steps, and each one is worth understanding.\n1. The message is not in storage.\nAsk the contract what it knows about the tipper:\ncontainer : /work/tip-jar : main\ncast call \u0026#34;$TIPJAR\u0026#34; \\ \u0026#34;tipsByAddress(address)(uint256)\u0026#34; \u0026#34;$TIPPER\u0026#34; \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; 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:\nemit 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.\n2. Find the logs.\ncast logs searches the chain for events matching a signature:\ncontainer : /work/tip-jar : main\ncast logs \\ \u0026#34;TipReceived(address indexed sender, uint256 amount, string message)\u0026#34; \\ --from-block 0 \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; One entry per tip, each looking like this:\n- 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.\n3. Understand why there are two places to look.\nThe event declaration splits its arguments in two:\nevent 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.\ntopics[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(\u0026quot;TipReceived(address,uint256,string)\u0026quot;), which is why cast logs could filter on a signature.\n4. Read the data by hand, once.\nThe data field is just 32-byte words end to end:\n0000…016345785d8a0000 amount 100000000000000000 wei = 0.1 ETH 0000…0000000000000040 offset the string starts at byte 64 0000…000000000000000b length 11 bytes 477265617420776f726b21 bytes \u0026#34;Great work!\u0026#34;, 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:\ncontainer : /work/tip-jar : main\ncast to-ascii 0x477265617420776f726b21 5. Ask for JSON instead.\nReading that by eye does not scale. --json turns the same output into something a program can consume:\ncontainer : /work/tip-jar : main\ncast logs \\ \u0026#34;TipReceived(address indexed sender, uint256 amount, string message)\u0026#34; \\ --from-block 0 \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; \\ --json 6. Pull out one field.\njq selects the data of the first log. .[0] is the first element, and -r prints it raw rather than JSON-quoted:\ncontainer : /work/tip-jar : main\nexport DATA=$(cast logs \\ \u0026#34;TipReceived(address indexed sender, uint256 amount, string message)\u0026#34; \\ --from-block 0 \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; \\ --json | jq -r \u0026#39;.[0].data\u0026#39;) echo \u0026#34;$DATA\u0026#34; Use .[-1].data for the most recent tip instead of the first.\n7. Let cast decode it.\ncontainer : /work/tip-jar : main\ncast decode-event --sig \u0026#34;TipReceived(uint256,string)\u0026#34; \u0026#34;$DATA\u0026#34; 100000000000000000 \u0026#34;Great work!\u0026#34; 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.\n8. Put it in one line.\nWith the variable inlined, the whole thing becomes a single command:\ncontainer : /work/tip-jar : main\ncast decode-event --sig \u0026#34;TipReceived(uint256,string)\u0026#34; \\ $(cast logs \\ \u0026#34;TipReceived(address indexed sender, uint256 amount, string message)\u0026#34; \\ --from-block 0 \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; \\ --json | jq -r \u0026#39;.[0].data\u0026#39;) Who Sent It, and All of Them at Once The sender is the topic we skipped, and it only needs unpadding:\ncontainer : /work/tip-jar : main\ncast 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:\ncontainer : /work/tip-jar : main\ncast logs \\ \u0026#34;TipReceived(address indexed sender, uint256 amount, string message)\u0026#34; \\ \u0026#34;$TIPPER\u0026#34; \\ --from-block 0 \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; And to read every message ever tipped, loop over the logs instead of taking .[0]:\ncontainer : /work/tip-jar : main\ncast logs \\ \u0026#34;TipReceived(address indexed sender, uint256 amount, string message)\u0026#34; \\ --from-block 0 \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; \\ --json \\ | jq -r \u0026#39;.[].data\u0026#39; \\ | while read -r d; do cast decode-event --sig \u0026#34;TipReceived(uint256,string)\u0026#34; \u0026#34;$d\u0026#34; 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.\nThere is also a shortcut for a single transaction. cast run replays it and prints a trace with the events already decoded:\ncontainer : /work/tip-jar : main\ncast run \u0026#34;$TX\u0026#34; --rpc-url \u0026#34;$RPC_URL\u0026#34; 18. Inspect the Blockchain Current block number:\ncontainer : /work/tip-jar : main\ncast block-number \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; Latest block:\ncontainer : /work/tip-jar : main\ncast block latest \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; 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:\n// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import {Test} from \u0026#34;forge-std/Test.sol\u0026#34;; import {TipJar} from \u0026#34;../src/TipJar.sol\u0026#34;; contract TipJarTest is Test { TipJar private tipJar; address private owner = makeAddr(\u0026#34;owner\u0026#34;); address private tipper = makeAddr(\u0026#34;tipper\u0026#34;); 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}(\u0026#34;Excellent!\u0026#34;); 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}(\u0026#34;Excellent!\u0026#34;); 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}(\u0026#34;Excellent!\u0026#34;); vm.prank(tipper); vm.expectRevert(\u0026#34;Only owner can withdraw\u0026#34;); tipJar.withdraw(); } function testCannotSendZeroTip() public { vm.prank(tipper); vm.expectRevert(\u0026#34;Tip must be greater than zero\u0026#34;); tipJar.tip{value: 0}(\u0026#34;No tip\u0026#34;); } } 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.\nRun:\ncontainer : /work/tip-jar : main\nforge test More verbose:\ncontainer : /work/tip-jar : main\nforge test -vv Full traces:\ncontainer : /work/tip-jar : main\nforge test -vvvv 20. Extension: Accept Plain ETH Transfers Try sending ETH to the contract without naming a function:\ncontainer : /work/tip-jar : main\ncast send \u0026#34;$TIPJAR\u0026#34; \\ --value 0.05ether \\ --private-key \u0026#34;$TIPPER_KEY\u0026#34; \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; 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:\ncontainer : /work/tip-jar : main\ncast send \u0026#34;$TIPJAR\u0026#34; \\ \u0026#34;notAFunction()\u0026#34; \\ --private-key \u0026#34;$TIPPER_KEY\u0026#34; \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; Accepting plain transfers is worth having. It is what a wallet does when someone types the tip jar\u0026rsquo;s address into a send field, without any idea that a tip() function exists.\nThe Naive Version The obvious thing to write is an empty receive():\nreceive() external payable {} Rebuild, and redeploy, since a code change means a new contract:\ncontainer : /work/tip-jar : main\nforge build forge create src/TipJar.sol:TipJar \\ --private-key \u0026#34;$OWNER_KEY\u0026#34; \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; \\ --broadcast export TIPJAR=0xTHE_NEW_DEPLOYED_ADDRESS Now the plain transfer succeeds:\ncontainer : /work/tip-jar : main\ncast send \u0026#34;$TIPJAR\u0026#34; \\ --value 0.05ether \\ --private-key \u0026#34;$TIPPER_KEY\u0026#34; \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; But look at what the contract thinks happened:\ncontainer : /work/tip-jar : main\ncast balance \u0026#34;$TIPJAR\u0026#34; --rpc-url \u0026#34;$RPC_URL\u0026#34; cast call \u0026#34;$TIPJAR\u0026#34; \\ \u0026#34;totalTips()(uint256)\u0026#34; \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; cast call \u0026#34;$TIPJAR\u0026#34; \\ \u0026#34;tipsByAddress(address)(uint256)\u0026#34; \u0026#34;$TIPPER\u0026#34; \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; 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\u0026rsquo;s own accounting now disagrees with its balance, and no event was emitted, so nothing downstream can even see the tip arrive.\nThe Fix Route both entry points through one internal function, so there is a single place where a tip is recorded:\nfunction tip(string calldata message) external payable { _tip(message); } receive() external payable { _tip(\u0026#34;\u0026#34;); } function _tip(string memory message) private { require(msg.value \u0026gt; 0, \u0026#34;Tip must be greater than zero\u0026#34;); tipsByAddress[msg.sender] += msg.value; totalTips += msg.value; emit TipReceived(msg.sender, msg.value, message); } Three things to notice:\n_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 \u0026gt; 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:\ncontainer : /work/tip-jar : main\ncast call \u0026#34;$TIPJAR\u0026#34; \\ \u0026#34;totalTips()(uint256)\u0026#34; \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; 50000000000000000 And the tipper is credited, exactly as if they had called tip():\ncontainer : /work/tip-jar : main\ncast call \u0026#34;$TIPJAR\u0026#34; \\ \u0026#34;tipsByAddress(address)(uint256)\u0026#34; \u0026#34;$TIPPER\u0026#34; \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; The tests from Section 19 need no changes, since tip() behaves exactly as before:\ncontainer : /work/tip-jar : main\nforge 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.\nThat is one of two directions to go from here:\nAdding 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.\n21. Format and Rebuild container : /work/tip-jar : main\nforge fmt forge build forge test This is a good normal development loop.\n22. Useful Commands Every command in this section runs in container : /work/tip-jar : main.\nCompile 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 \u0026#34;$OWNER_KEY\u0026#34; \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; \\ --broadcast Read contract state cast call \u0026#34;$TIPJAR\u0026#34; \\ \u0026#34;owner()(address)\u0026#34; \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; Send a transaction cast send \u0026#34;$TIPJAR\u0026#34; \\ \u0026#34;tip(string)\u0026#34; \u0026#34;hello\u0026#34; \\ --value 0.1ether \\ --private-key \u0026#34;$TIPPER_KEY\u0026#34; \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; Check ETH balance cast balance \u0026#34;$TIPJAR\u0026#34; \\ --ether \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; 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 \u0026#34;$OWNER_KEY\u0026#34; Inspect contract bytecode cast code \u0026#34;$TIPJAR\u0026#34; \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; 23. Minimal Complete Session Terminal 1:\nhost → container : /work : anvil\npodman start foundry podman exec -it foundry bash anvil Terminal 2:\nhost → container : /work/tip-jar : main\npodman exec -it foundry bash cd tip-jar # From Anvil\u0026#39;s \u0026#34;Listening on\u0026#34; line export RPC_URL=http://127.0.0.1:8545 # From Anvil\u0026#39;s \u0026#34;Private Keys\u0026#34; section, indexes (0) and (1) export OWNER_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 export TIPPER_KEY=0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d # Derived from the keys; should match \u0026#34;Available Accounts\u0026#34; (0) and (1) export OWNER=$(cast wallet address --private-key \u0026#34;$OWNER_KEY\u0026#34;) export TIPPER=$(cast wallet address --private-key \u0026#34;$TIPPER_KEY\u0026#34;) forge build forge test forge create src/TipJar.sol:TipJar \\ --private-key \u0026#34;$OWNER_KEY\u0026#34; \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; \\ --broadcast Copy the address from the Deployed to line that forge create just printed:\ncontainer : /work/tip-jar : main\nexport TIPJAR=0xYOUR_DEPLOYED_ADDRESS Send a tip:\ncontainer : /work/tip-jar : main\ncast send \u0026#34;$TIPJAR\u0026#34; \\ \u0026#34;tip(string)\u0026#34; \u0026#34;Great work!\u0026#34; \\ --value 0.1ether \\ --private-key \u0026#34;$TIPPER_KEY\u0026#34; \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; Check the balance:\ncontainer : /work/tip-jar : main\ncast balance \u0026#34;$TIPJAR\u0026#34; --ether --rpc-url \u0026#34;$RPC_URL\u0026#34; Withdraw:\ncontainer : /work/tip-jar : main\ncast send \u0026#34;$TIPJAR\u0026#34; \\ \u0026#34;withdraw()\u0026#34; \\ --private-key \u0026#34;$OWNER_KEY\u0026#34; \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; Check the final balance:\ncontainer : /work/tip-jar : main\ncast balance \u0026#34;$TIPJAR\u0026#34; --ether --rpc-url \u0026#34;$RPC_URL\u0026#34; 24. Mental Model The command-line workflow is:\nTipJar.sol ↓ forge build ↓ forge test ↓ anvil ↓ forge create ↓ deployed TipJar ↓ cast call / cast send The three tools have distinct roles:\nforge = build, test, deploy anvil = local Ethereum blockchain cast = interact with Ethereum 25. Anvil\u0026rsquo;s Amnesia and Removing a Contract Anvil keeps its whole chain in memory. Stop it and start it again:\ncontainer : /work : anvil\nanvil and the old chain is gone — every deployment, transaction, and balance with it.\nThe 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:\ncontainer : /work/tip-jar : main\nforge create src/TipJar.sol:TipJar \\ --private-key \u0026#34;$OWNER_KEY\u0026#34; \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; \\ --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.\nRemoving 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.\nLocally there are three levels of demolition:\nCtrl-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:\ncontainer : /work/tip-jar : main\ncast code \u0026#34;$TIPJAR\u0026#34; \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; A bare 0x means there is no contract at that address any more.\nOn 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.\nSolidity does have selfdestruct(address payable recipient), and until March 2024 it did erase a contract\u0026rsquo;s code and storage. EIP-6780, part of the Dencun upgrade, removed almost all of that:\ncreated 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 \u0026ldquo;send me the ETH\u0026rdquo;. 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.\nDeletion 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.\nSo the real options are these:\nabandon 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.\nOne piece of selfdestruct did survive: it still forces ETH into any address without running code there. That is why a contract\u0026rsquo;s balance can always exceed its own accounting, and why withdraw() sends address(this).balance rather than totalTips.\n26. Additional Resources The Tools Foundry Book — the official documentation cast command reference — every subcommand, generated from cast --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 CALLER and SSTORE and the numbers here stop being arbitrary EIP-6780 — the change that made selfdestruct almost 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 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 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\u0026rsquo;s default private keys directly in terminal commands is fine for local development, because the keys are public and the ETH is fake.\nFor real networks, never expose real private keys in:\nshell history source code Git repositories frontend JavaScript committed .env files documentation Use an encrypted keystore, hardware wallet, or another secure signing method instead.\nThe container helps a little here, but do not overestimate it. It keeps the Foundry install and this tutorial\u0026rsquo;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\u0026rsquo;s shell history, and the container still has unrestricted outbound network access.\nSummary Anvil provides the local blockchain:\nAnvil └── http://127.0.0.1:8545 cast is a command-line client that talks to that blockchain:\ncast ──JSON-RPC──▶ Anvil Using cast, you can:\ninspect 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.\n","permalink":"https://cedarpiano.fyi/posts/smart-contract-foundry/","summary":"\u003cp\u003eThis tutorial writes, tests, deploys, and drives a \u003ccode\u003eTipJar\u003c/code\u003e smart contract\nentirely from the command line.\u003c/p\u003e\n\u003cp\u003eIt follows on from\n\u003ca href=\"/posts/ethereum-command-line/\"\u003eEthereum from the Command Line\u003c/a\u003e, which covers\nAnvil, accounts, transactions, and blocks. You do not have to have read it —\nthe Prerequisites below get you to the same starting point — but it explains\nmost of what this one assumes. Two tutorials follow on from this one:\n\u003ca href=\"/posts/tipjar-webpage/\"\u003eAdding a Tip Jar to a Webpage\u003c/a\u003e puts a frontend on the\ncontract, and \u003ca href=\"/posts/invariant-testing-foundry/\"\u003eInvariant Testing with Foundry\u003c/a\u003e\ntests it harder.\u003c/p\u003e","title":"Writing a Smart Contract with Foundry"},{"content":"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.\nWriting and deploying a contract is the subject of the follow-up, Writing a Smart Contract with Foundry.\nEthereum 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.\nEthereum 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.\nWhat 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.\nWhere 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:\n`container : /work : main` │ │ │ │ │ └── which window │ └── the working directory └── host machine, or inside the container The fields are:\nhost 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.\n1. Install Foundry in a Container Foundry\u0026rsquo;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.\nEverything below runs in Podman. Docker works identically; substitute docker for podman throughout.\nFirst, a directory on the host to hold the project, so your work survives the container:\nhost : ~ : main\nmkdir -p ~/tip-jar-work Now start a long-lived Ubuntu container with that directory mounted:\nhost : ~ : main\npodman run -dit \\ --name foundry \\ --volume ~/tip-jar-work:/work:Z \\ --workdir /work \\ ubuntu sleep infinity Three flags worth understanding:\n-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\u0026rsquo;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.\nThe :Z suffix tells Podman to relabel the directory for SELinux. It is required on Fedora and RHEL, and harmless everywhere else.\nNow open a shell inside it:\nhost : ~ : main\npodman 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.\nThe base Ubuntu image is deliberately bare, so install what Foundry needs:\ncontainer : /work : main\napt-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.\nThen the normal Foundry install:\ncontainer : /work : main\ncurl -L https://foundry.paradigm.xyz | bash source ~/.bashrc foundryup Verify:\ncontainer : /work : main\nforge --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:\ncontainer : /work : main\nexport PATH=\u0026#34;$HOME/.foundry/bin:$PATH\u0026#34; Getting Back In The container keeps running in the background, so you can leave and return:\nexit 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.\nThe one thing that does not survive is Foundry itself. It lives in the container\u0026rsquo;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.\n2. Start Anvil anvil is the third of Foundry\u0026rsquo;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.\nThree things make it a development tool rather than a real node:\nten 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.\nOpen a second terminal on the host and get a second shell into the same container:\nhost : ~ : anvil\npodman exec -it foundry bash Then start the local blockchain:\ncontainer : /work : anvil\nanvil Both shells are inside the same container, so Anvil\u0026rsquo;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.\nLeave it running for the rest of the tutorial. On startup it prints a screen like this, abridged here:\nAvailable 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\u0026#39;/60\u0026#39;/0\u0026#39;/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:\nAvailable 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.\nThose 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.\nHere is what each section of the screen means:\nAvailable 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\u0026#39;/60\u0026#39;/0\u0026#39;/0/0 m/44\u0026#39;/60\u0026#39;/0\u0026#39;/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.\n3. 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.\nStart with the RPC endpoint, taken from Anvil\u0026rsquo;s Listening on line:\ncontainer : /work : main\nexport 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:\ncontainer : /work : main\nexport OWNER_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 export TIPPER_KEY=0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d These are Anvil\u0026rsquo;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.\nYou do not need to copy the addresses. An Ethereum address is derived from its private key, so cast can work them out:\ncontainer : /work : main\nexport OWNER=$(cast wallet address --private-key \u0026#34;$OWNER_KEY\u0026#34;) export TIPPER=$(cast wallet address --private-key \u0026#34;$TIPPER_KEY\u0026#34;) Print them:\ncontainer : /work : main\necho \u0026#34;RPC: $RPC_URL\u0026#34; echo \u0026#34;Owner: $OWNER\u0026#34; echo \u0026#34;Tipper: $TIPPER\u0026#34; Expected:\nRPC: 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.\nThose 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.\n4. Check the Current Block container : /work : main\ncast block-number \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; You should see:\n0 Anvil starts with the genesis block, block 0.\n5. Check the Account Balances container : /work : main\ncast balance \\ \u0026#34;$OWNER\u0026#34; \\ --ether \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; You should see:\n10000.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.\nCheck the tipper:\ncontainer : /work : main\ncast balance \\ \u0026#34;$TIPPER\u0026#34; \\ --ether \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; It should also have:\n10000.000000000000000000 6. Send 1 ETH From the Owner to the Tipper container : /work : main\ncast send \\ \u0026#34;$TIPPER\u0026#34; \\ --value 1ether \\ --private-key \u0026#34;$OWNER_KEY\u0026#34; \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; cast uses the private key to sign a transaction saying:\nOwner ─── 1 ETH ───▶ Tipper Anvil receives the transaction through its RPC server and mines it into a block.\nThe command prints a transaction receipt containing information such as:\nblockNumber from to gasUsed status transactionHash 7. Check the Block Number Again container : /work : main\ncast block-number \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; You should now see:\n1 The transaction caused Anvil to mine a new block:\nBlock 0 Genesis │ ▼ Block 1 1 ETH transfer 8. Check the Recipient\u0026rsquo;s Balance container : /work : main\ncast balance \\ \u0026#34;$TIPPER\u0026#34; \\ --ether \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; You should now see:\n10001.000000000000000000 The tipper received 1 ETH.\n9. Check the Sender\u0026rsquo;s Balance container : /work : main\ncast balance \\ \u0026#34;$OWNER\u0026#34; \\ --ether \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; You will see slightly less than:\n9999 ETH rather than exactly 9999 ETH, because the owner paid:\n1 ETH + transaction gas The gas is also paid using Anvil\u0026rsquo;s fake ETH.\n10. Look at the Latest Block container : /work : main\ncast block latest \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; This shows information about the block Anvil just created, including:\nnumber timestamp gasLimit gasUsed baseFeePerGas transactions You can also ask for a specific block:\ncontainer : /work : main\ncast block 1 \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; 11. View a Balance in Wei Try:\ncontainer : /work : main\ncast balance \\ \u0026#34;$TIPPER\u0026#34; \\ --rpc-url \u0026#34;$RPC_URL\u0026#34; Without --ether, the balance is displayed in wei:\n10001000000000000000000 because:\n1 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.\nWei and Gwei Wei is the real unit. \u0026ldquo;ETH\u0026rdquo; and \u0026ldquo;gwei\u0026rdquo; are just names for round multiples of it:\n1 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.\nGwei exists purely for readability, and only ever for gas prices. The same mainnet gas price in each unit:\nwei 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.\nSolidity has suffixes for all three, which are nothing more than compile-time multipliers, and cast converts in both directions:\ncontainer : /work : main\ncast 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.\n12. What Just Happened? You interacted with Anvil in essentially the same way software interacts with a real Ethereum node:\ncast │ │ 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:\ncontainer : /work : main\n# What block are we on? cast block-number --rpc-url \u0026#34;$RPC_URL\u0026#34; # How much ETH does an address have? cast balance \u0026#34;$OWNER\u0026#34; --ether --rpc-url \u0026#34;$RPC_URL\u0026#34; # Send a transaction cast send \u0026#34;$TIPPER\u0026#34; --value 1ether \\ --private-key \u0026#34;$OWNER_KEY\u0026#34; --rpc-url \u0026#34;$RPC_URL\u0026#34; # Inspect the latest block cast block latest --rpc-url \u0026#34;$RPC_URL\u0026#34; These commands demonstrate the basic Ethereum model:\naccounts ↓ 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.\n13. Important Anvil Behavior Anvil is an ephemeral local blockchain. If you stop it and start it again:\ncontainer : /work : anvil\nanvil your old local blockchain is gone. That means previous:\ntransactions account balances blocks contract deployments no longer exist.\nThe 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.\nStopping 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.\nThis 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.\n14. 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.\nReading 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.\nOpen a third shell into the container, so you do not clobber the Anvil setup, and point it at mainnet:\nhost → container : /work : mainnet\npodman exec -it foundry bash export ETH_RPC_URL=https://ethereum-rpc.publicnode.com The container reaches the internet through the host\u0026rsquo;s network by default, so no extra Podman configuration is needed to talk to a real node.\nNote 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.\nThat 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\u0026rsquo;s contracts do not exist.\nSo check where you actually are before going further:\ncontainer : /work : mainnet\ncast 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.\nPublic 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.\nWhat 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.\ncast 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.\nThe Chain Has a Beginning Same command as Section 4, pointed somewhere older:\ncontainer : /work : mainnet\ncast block 1 Block 1 of Ethereum mainnet was mined on 30 July 2015. cast age puts a date on any block number:\ncontainer : /work : mainnet\ncast 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.\nWhat Gas Actually Costs container : /work : mainnet\ncast gas-price cast base-fee Both come back in wei, which is not a useful unit for reading:\ncontainer : /work : mainnet\ncast 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\u0026rsquo;s floats with demand, and watching it move over a few minutes is the cheapest possible introduction to Ethereum\u0026rsquo;s fee market.\nLook Up an Account cast resolves ENS names, so you rarely need to paste hex:\ncontainer : /work : mainnet\ncast 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\u0026rsquo;s address is derived from its deployer and that deployer\u0026rsquo;s nonce, so it can be computed before the contract exists.\nThere is a reverse command as well, going from an address back to a name:\nThe Best of Both Anvil can start from a copy of the real chain:\ncontainer : /work : anvil\nanvil --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.\n15. Anatomy of a Real Contract Section 14 poked at several mainnet contracts in passing. This section takes one apart properly.\nWETH — 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.\nContinuing in the mainnet shell from the previous section, ETH_RPC_URL is already set, so only the address is new:\ncontainer : /work : mainnet\nexport WETH=0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 cast chain-id That must still print 1. If it does not, re-export ETH_RPC_URL as described in Section 14.\nIs There Anything There? container : /work : mainnet\ncast code \u0026#34;$WETH\u0026#34; | head -c 120 A long hex string, beginning 0x6060604052.... That is deployed EVM bytecode — the compiled contract, stored in the account itself.\nSo an Ethereum account is one of two things:\nEthereum account │ ├── externally owned (a person) balance, nonce │ └── contract balance, nonce, bytecode, storage A contract account holds ETH just like a person does:\ncontainer : /work : mainnet\ncast balance \u0026#34;$WETH\u0026#34; --ether That figure is the entire point of WETH. Every WETH token in existence is backed by one ETH sitting in that balance.\nAsking It What It Is WETH implements the ERC-20 interface, so it can describe itself:\ncontainer : /work : mainnet\ncast call \u0026#34;$WETH\u0026#34; \u0026#34;name()(string)\u0026#34; cast call \u0026#34;$WETH\u0026#34; \u0026#34;symbol()(string)\u0026#34; cast call \u0026#34;$WETH\u0026#34; \u0026#34;decimals()(uint8)\u0026#34; \u0026#34;Wrapped Ether\u0026#34; \u0026#34;WETH\u0026#34; 18 The interesting part is the argument:\n\u0026#34;name()(string)\u0026#34; │ │ │ └── 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\u0026rsquo;s signature — and Section 14 showed how to recover signatures you do not know.\ndecimals() returning 18 means WETH uses the same scale as ETH, which is deliberate: one wei of ETH becomes one unit of WETH.\n1 WETH = 1 000 000 000 000 000 000 units = 10¹⁸ Do not assume that of other tokens. USDC returns 6.\nHow Much Exists container : /work : mainnet\ncast call \u0026#34;$WETH\u0026#34; \u0026#34;totalSupply()(uint256)\u0026#34; A very large integer, in the token\u0026rsquo;s smallest unit. Pipe it through from-wei, which reads from standard input when given no argument:\ncontainer : /work : mainnet\ncast call \u0026#34;$WETH\u0026#34; \u0026#34;totalSupply()(uint256)\u0026#34; | 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.\nTwo Kinds of Balance Ask what WETH balance an address holds:\ncontainer : /work : mainnet\nexport ADDRESS=0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 cast call \u0026#34;$WETH\u0026#34; \u0026#34;balanceOf(address)(uint256)\u0026#34; \u0026#34;$ADDRESS\u0026#34; | cast from-wei Now compare that with the same address\u0026rsquo;s ETH:\ncontainer : /work : mainnet\ncast balance \u0026#34;$ADDRESS\u0026#34; --ether These are not the same kind of thing at all, and conflating them is a common beginner error:\nETH balance WETH balance │ │ │ tracked by Ethereum itself, │ an entry in the WETH contract\u0026#39;s │ in the account\u0026#39;s own state │ own storage — just a number in │ │ a mapping ▼ ▼ cast balance $ADDRESS cast call $WETH \u0026#34;balanceOf(address)\u0026#34; $ADDRESS An ETH balance is part of the protocol. A token balance is an ordinary variable inside somebody\u0026rsquo;s contract, and it means whatever that contract\u0026rsquo;s code says it means. Every ERC-20 token in existence is a mapping(address =\u0026gt; uint256) and an agreement to respect it.\nWhat a Call Actually Sends Ethereum never sees the text balanceOf(address). It sees four bytes:\ncontainer : /work : mainnet\ncast sig \u0026#34;balanceOf(address)\u0026#34; 0x70a08231 That is the first four bytes of keccak256(\u0026quot;balanceOf(address)\u0026quot;), and it is what the contract\u0026rsquo;s dispatcher compares against. Build the complete calldata and the structure is visible:\ncontainer : /work : mainnet\ncast calldata \u0026#34;balanceOf(address)\u0026#34; \u0026#34;$ADDRESS\u0026#34; 0x70a08231000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa96045 └──────┘└──────────────────────────────────────────────────────────────┘ selector the address, left-padded to a full 32-byte word Four bytes of \u0026ldquo;which function\u0026rdquo;, 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.\nUnderneath cast call cast is a convenience layer over JSON-RPC, and you can drop below it:\ncontainer : /work : mainnet\ncast rpc eth_getCode \u0026#34;$WETH\u0026#34; latest cast rpc eth_getBalance \u0026#34;$WETH\u0026#34; 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:\ncast │ 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:\ncontainer : /work : mainnet\ncast storage \u0026#34;$WETH\u0026#34; 0 cast storage \u0026#34;$WETH\u0026#34; 1 cast storage \u0026#34;$WETH\u0026#34; 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.\nSlots 0 and 1 use Solidity\u0026rsquo;s encoding for short strings: the characters sit in the high bytes, and the lowest byte holds twice the length.\n0x5772617070656420457468657200 … 1a └────── \u0026#34;Wrapped Ether\u0026#34; ─────┘ └┘ 0x1a = 26 = 13 × 2 Confirm it:\ncontainer : /work : mainnet\ncast to-ascii 0x57726170706564204574686572 So the same data can be reached two ways, and the difference matters:\ncast call cast storage │ │ │ runs the contract\u0026#39;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.\nWhat It Emits Every WETH transfer emits the standard ERC-20 event:\nevent Transfer( address indexed from, address indexed to, uint256 value ); Its identifying topic is the hash of its signature:\ncontainer : /work : mainnet\ncast keccak \u0026#34;Transfer(address,address,uint256)\u0026#34; 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:\ncontainer : /work : mainnet\nLATEST=$(cast block-number) START=$((LATEST - 20)) cast logs \\ --from-block \u0026#34;$START\u0026#34; \\ --to-block \u0026#34;$LATEST\u0026#34; \\ --address \u0026#34;$WETH\u0026#34; \\ \u0026#34;Transfer(address,address,uint256)\u0026#34; Keep the range small. Public endpoints reject wide log queries, and WETH is busy enough that twenty blocks is plenty.\nEach result has from and to in its topics, because they are indexed, and the amount in data, because it is not.\nThe 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.\nTry 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\u0026rsquo;s deposit() with one of the fake pre-funded accounts, watch balanceOf rise, and call withdraw() to turn it back into ETH.\nThat 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.\n16. Container Lifecycle and Cleanup Three verbs, and the difference between them matters:\npodman 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:\nhost : ~ : main\npodman stop foundry Removing is the destructive one. What it takes with it:\nGONE KEPT ────────────────────────── ────────────────────────── Foundry install (~/.foundry) everything in ~/tip-jar-work apt packages (curl, git) which is TipJar.sol, the tests, the container\u0026#39;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.\nNot Reinstalling Every Time If you expect to delete and recreate the container, snapshot it once Foundry is installed:\nhost : ~ : main\npodman commit foundry foundry-tipjar That writes the container\u0026rsquo;s current filesystem out as a reusable image. New containers start from it with Foundry already present:\nhost : ~ : main\npodman 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:\nFROM ubuntu:24.04 RUN apt-get update \u0026amp;\u0026amp; \\ apt-get install -y curl git ca-certificates \u0026amp;\u0026amp; \\ rm -rf /var/lib/apt/lists/* RUN curl -L https://foundry.paradigm.xyz | bash \u0026amp;\u0026amp; \\ /root/.foundry/bin/foundryup ENV PATH=\u0026#34;/root/.foundry/bin:${PATH}\u0026#34; WORKDIR /work Build and use it the same way:\nhost : ~ : main\npodman 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 \u0026ldquo;command not found\u0026rdquo; problem mentioned in Section 1.\nRemoving Everything To leave no trace beyond your project files:\nhost : ~ : main\npodman 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.\n17. Additional Resources The Tools Foundry Book — the official documentation cast command reference — every subcommand, generated from cast --help anvil reference — flags for forking, block time, and account impersonation foundry-rs/foundry — the source. The CLI arguments are defined in crates/cast/src/opts.rs if the docs are ever ambiguous Podman documentation — for the container side How Ethereum Works Ethereum developer docs — accounts, transactions, gas, and the state trie, explained properly evm.codes — an interactive opcode reference with gas costs. Look up 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\u0026rsquo;s startup screen EIP-1559 — the base fee mechanism behind cast base-fee Looking at the Real Chain Etherscan — where to find the transaction hashes Section 14 asks for, and to read verified source for any contract Sepolia Etherscan — the same for the testnet Google Cloud Sepolia faucet — free testnet ETH, for when you want to send a real transaction Openchain signature database — what cast 4byte queries when turning a selector back into a function name The Rest of the Series Writing a Smart Contract with Foundry — build, deploy, and drive a tip jar from the shell Adding a Tip Jar to a Webpage — a browser frontend for the contract, using ethers.js and MetaMask Invariant Testing with Foundry — properties that must hold after any sequence of calls Security Note Using Anvil\u0026rsquo;s default private keys directly in terminal commands is fine for local development, because the keys are public and the ETH is fake.\nFor real networks, never expose real private keys in:\nshell history source code Git repositories frontend JavaScript committed .env files documentation Use an encrypted keystore, hardware wallet, or another secure signing method instead.\nThe container helps a little here, but do not overestimate it. It keeps the Foundry install and this tutorial\u0026rsquo;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\u0026rsquo;s shell history, and the container still has unrestricted outbound network access.\nSummary Anvil provides the local blockchain:\nAnvil └── http://127.0.0.1:8545 cast is a command-line client that talks to it, and to any other Ethereum node:\ncast ──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.\nThe next step is to put your own contract on that chain:\nEthereum 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 ","permalink":"https://cedarpiano.fyi/posts/ethereum-command-line/","summary":"\u003cp\u003eThis tutorial explores an Ethereum blockchain entirely from the shell —\naccounts, transactions, blocks, gas, and units — using a local test chain and\nthen the real network. There is no Solidity here, and no frontend.\u003c/p\u003e\n\u003cp\u003eWriting and deploying a contract is the subject of the follow-up,\n\u003ca href=\"/posts/smart-contract-foundry/\"\u003eWriting a Smart Contract with Foundry\u003c/a\u003e.\u003c/p\u003e\n\u003ch2 id=\"ethereum\"\u003eEthereum\u003c/h2\u003e\n\u003cp\u003eA blockchain is a shared, append-only ledger that nobody owns. What makes one\n\u003cem\u003eprogrammable\u003c/em\u003e is the ability to put code on it — a \u003cstrong\u003esmart contract\u003c/strong\u003e, which is\na program with its own address, its own storage, and its own balance. Once\ndeployed, it runs exactly as written for anyone who calls it, and no single\nparty can quietly change it or stop it.\u003c/p\u003e","title":"Ethereum from the Command Line"}]