⚗️ Ethereum Hands-on Lab
A complete two-session intensive (2 × 4h = 8h). You will install MetaMask, connect to the Sepolia testnet, explore real on-chain activity on Etherscan, deploy your first Solidity contracts in Remix, dissect bytecode and ABI, and finish by building a Donation Pool DApp — anyone donates ETH toward a target; once it's reached, only the named beneficiary can withdraw the funds, with no admin override possible. Wired to a browser frontend using ethers.js v6.
Welcome & Lab Agenda
This lab is a self-contained companion to the existing Part 2 modules. If you have followed Modules 6 through 12, everything here will feel familiar — but the pace is faster and the focus is strictly hands-on. By the end you will have deployed a real contract to a public testnet and interacted with it from a web page running in your browser.
What You Will Build
A Donation Pool smart contract: at deploy time, the deployer fixes a fundraising target and a beneficiary address. Anyone can donate ETH to the contract; their cumulative contribution is tracked on-chain. Once the target is reached, only the named beneficiary can trigger a transaction that sends the entire pooled amount to themselves. The deployer cannot change the beneficiary, cancel the campaign, or withdraw the funds — those guarantees are enforced by Solidity's immutable keyword and the absence of any admin function, not by trust. A simple HTML + ethers.js frontend lets anyone connect MetaMask, see live progress toward the goal, donate, and (if they're the beneficiary) withdraw.
Prerequisites
- A modern browser (Chrome, Firefox, Brave, or Edge). Safari works but MetaMask support is less convenient.
- An email or GitHub account for one of the Sepolia faucets (most public faucets ask you to sign in to fight bots).
- Basic JavaScript & HTML. You do not need any prior Solidity knowledge — we will introduce every keyword as it appears.
- No installation beyond a browser extension. Remix runs in the browser, Sepolia is public, and the frontend is a single HTML file you can open locally.
Agenda
Foundations & First Contract
- Welcome, lab goals, what you'll ship
- Ethereum in 15 minutes: EOA vs contract, Wei/Gwei/ETH, gas, transactions
- Install MetaMask, secure your seed, switch to Sepolia
- Get test ETH from a Sepolia faucet, check balance
- Etherscan tour: addresses, txs, internal txs, logs, verified source
- Remix IDE tour, compile and deploy a Counter
- Function anatomy: visibility & state mutability, with live calls
Donation Pool DApp
- Recap, distribute project brief
- Design pass: events, payable functions, immutable variables, access control
- Write & deploy
DonationPool.solin Remix - Deploy to Sepolia + verify source on Etherscan
- Build the ethers.js v6 frontend: connect, read, write, listen
- Show & tell, next-step ideas
A Word on Money
You will be using the Sepolia public test network throughout. Sepolia ETH has zero monetary value — it exists only so developers can test. Never send real (mainnet) ETH to anything you deploy in this lab. We will not touch mainnet at any point.
Ethereum in 15 Minutes
Ethereum is a public, decentralised blockchain — but unlike Bitcoin, it is also a programmable platform. Anyone can publish a piece of code on it, and from that moment the code runs exactly the same way for everyone, everywhere in the world, with no central server and no company you have to trust. That single property — open programmability — is what makes everything in this lab possible.
How it works, in one paragraph
Around the world, thousands of independent computers — called nodes — keep an identical copy of the same ledger. Roughly every 12 seconds a new block is added: a batch of transactions plus the resulting state. The nodes agree on which block is next via a consensus protocol called Proof of Stake (active since "The Merge", September 2022). In contrast with Bitcoin's Proof of Work (where miners race to find a valid hash), in PoS the protocol picks one validator per slot — randomly, weighted by how much ETH they have staked (32 ETH minimum) — to propose the block, while a committee of other validators attests to it. If a validator signs conflicting blocks or goes offline, their stake is slashed (destroyed). No single node, company, or country can rewrite history — to cheat you would have to convince two-thirds of all stakers, who collectively lock up tens of billions of dollars as collateral and would lose it if they misbehaved.
The Ethereum Virtual Machine (EVM)
Inside every node runs a tiny, deterministic virtual machine called the EVM. It is the computer that actually executes Ethereum's code. The EVM is stack-based — instructions push/pop values on an operand stack, like an old RPN calculator (the expression 2 + 3 compiles to "push 2, push 3, ADD" — no registers, no variables at the hardware level). It has a 256-bit word size, and meters every single operation in gas — so an infinite loop or a crash bug cannot take the network down (the sender simply runs out of paid-for gas). Whatever language you write your contract in — Solidity, Vyper, Huff — it always compiles down to EVM bytecode, and that bytecode is what gets stored on-chain.
What a transaction actually looks like
An Ethereum transaction is just a signed JSON object. Bitcoin transactions are lists of inputs and outputs; Ethereum transactions are method calls. Every msg.sender, msg.value, and "calldata" you'll see later traces back to one of these fields:
{
"from": "0xYourEOA…", // who signed → becomes msg.sender
"to": "0xContract…", // destination; null when deploying a new contract
"value": "0x2386f26fc10000", // 0.01 ETH, in wei, hex → becomes msg.value
"data": "0xa9059cbb000…", // calldata: function selector (4 bytes) + ABI-encoded args
"gas": "0x15F90", // max gas the sender is willing to pay for
"gasPrice": "0x77359400", // wei per gas unit
"nonce": "0x4", // per-sender counter, prevents replay
"v", "r", "s": /* the ECDSA signature components */
}
Smart contracts
A smart contract is just a program that lives at an Ethereum address. It has its own balance, its own storage, and its own code — and it runs the moment anyone (a human, another contract) sends it a transaction targeting one of its functions. The source is public, the state is public, the rules cannot be changed after deployment unless the contract was specifically designed to be upgradable. DeFi protocols (Uniswap, Aave), NFT collections, DAOs (on-chain governance organisations), and tokens following community standards like ERC-20 (fungible — e.g. stablecoins) or ERC-721 (non-fungible — e.g. NFTs) are all, fundamentally, smart contracts. And yes — a smart contract can call another smart contract during the same transaction (that's how Uniswap composes with token contracts); we'll see this on Etherscan as "internal transactions" in §5.
Now that you have the big picture, anchor these four ideas before opening any tool. The rest of the lab keeps coming back to them.
1. Two kinds of accounts
An EOA (externally owned account) is what humans control — a public address derived from a private key. A contract account is created by deploying code; it holds storage and runs logic, but cannot initiate a transaction on its own. Both have the same 20-byte address format.
2. Transactions transform state
Every state change on Ethereum starts as a signed transaction from an EOA. It either transfers ETH, deploys a contract, or calls a function. Everything else — events, internal calls, storage writes — is a side-effect inside that transaction.
3. Gas is metered work
Think of gas as computational fuel: each EVM instruction burns a fixed amount, and the transaction sender pays in ETH for the fuel their transaction consumes — exactly like a car using more fuel for a longer trip. The formula is gasUsed × gasPrice. Gas exists to bound execution (no infinite loops) and to price storage; it is independent of any business logic like donation amounts. Bitcoin charges per byte; Ethereum charges per opcode.
4. Wei is the integer unit
Solidity has no decimals. Internally, everything is in Wei: 1 ETH = 1018 Wei, 1 Gwei = 109 Wei. UI shows ETH; the EVM only knows Wei. This is the #1 source of beginner bugs.
The unit ladder you'll see in MetaMask & Remix
| Unit | Value in Wei | Used for |
|---|---|---|
wei |
1 |
Internal Solidity arithmetic. Smallest indivisible unit. |
gwei |
1_000_000_000 (109) |
Gas prices. MetaMask shows fees in gwei. |
ether |
1_000_000_000_000_000_000 (1018) |
Human-facing balance. UI conversion only. |
Mental model
Treat the EVM as a giant, slow, global SQL server where every write is paid for in ETH and every read is free if you ask from outside. Contracts are stored procedures; the ABI is the schema you hand to your client to call them.
Install MetaMask & Create a Wallet
MetaMask is a browser extension that does three jobs: it stores your private keys, it signs transactions on your behalf, and it acts as a bridge between any web page and the Ethereum network. Every DApp in this lab — Remix, Etherscan's "Write Contract" panel, the donation frontend — will talk to the chain through MetaMask.
Install the extension
Go to metamask.io/download directly in your browser. Avoid any other source — fake MetaMask extensions exist. Install for Chrome / Firefox / Brave / Edge. After install, find MetaMask by clicking the puzzle-piece (Extensions) icon in your browser's toolbar, then click the pin icon next to MetaMask — its fox icon will now sit permanently in your toolbar, one click away.
Create a new wallet
Choose "Create a new wallet". Pick a strong local password — this only unlocks the extension on this device, it is not your account password.
Save the Secret Recovery Phrase
MetaMask will show you 12 words. Write them down on paper, in order. Do not screenshot them, do not paste them in a chat, do not store them in iCloud or Google Drive. Anyone who sees these 12 words owns your wallet. For this lab a dev-only wallet is fine — but treat the habit as if it were real.
Confirm and reach the dashboard
After confirming the 12-word order, you land on the wallet dashboard. You'll see three key things: (1) the network dropdown at the top (default: Ethereum Mainnet), (2) your account name in the centre (e.g. Account 1) with the public address right below it — a string starting with 0x, and (3) your balance (0 ETH for a fresh wallet). The address is safe to share — anyone with it can send you ETH but cannot spend yours.
Seed phrase = private key = full control
The seed phrase deterministically generates every private key in the wallet. It is the master credential. Real users have lost six- and seven-figure sums by storing their seed in a screenshot that synced to a compromised cloud account. Even for a lab wallet, build the muscle memory: paper, never digital.
Switch to Sepolia & Get Test ETH
MetaMask defaults to Ethereum mainnet — the production network where ETH is real money. We need to switch to Sepolia, the long-lived public testnet. Sepolia behaves exactly like mainnet (same EVM, same tooling, same Etherscan) but its ETH is free and worthless.
Enable testnets
In MetaMask: open the menu (three dots) → Settings → Advanced → Show test networks. Toggle it on. (MetaMask's UI shifts slightly between versions — if the toggle isn't where described, search "test networks" in Settings.)
Pick Sepolia from the network dropdown
Click the network selector at the top of MetaMask (default reads Ethereum Mainnet) and choose Sepolia. Your balance will reset to 0 — that is normal, you are on a different chain.
Copy your Sepolia address
Same address as mainnet — chains share the EOA address. Click Copy address; you will paste it into the faucet next.
Request from a faucet
Try one of these — if one is rate-limited, try the next. They typically drip 0.05–0.5 Sepolia ETH per day per account.
cloud.google.com/application/web3/faucet/ethereum/sepolia— Google Cloud Web3 faucet, generous limits.www.alchemy.com/faucets/ethereum-sepolia— Alchemy public faucet (the same service as the legacysepoliafaucet.com).www.infura.io/faucet/sepolia— Infura faucet, requires Infura signup.faucet.quicknode.com/ethereum/sepolia— QuickNode faucet.faucets.chain.link/sepolia— Chainlink faucet; may require a small mainnet ETH balance to qualify (anti-sybil).
Confirm arrival
Within ~30 seconds, MetaMask should show a non-zero balance. The faucet's confirmation page also gives you a transaction hash — a 66-character hex string starting with 0x (e.g. 0x4f8c2a…). It usually appears as a clickable link labelled "View on Etherscan" or printed in green next to the words "tx hash". Keep that browser tab open; you will paste the hash into Etherscan in the next section.
Why Sepolia, not Goerli or Holesky?
Goerli was the previous beginner testnet but has been deprecated. Holesky exists for staking and validator testing. Sepolia is the official application-developer testnet — smaller state, faster sync, well-funded faucets, supported by every wallet and explorer.
Etherscan: The Public Forensics Tool
Etherscan is a website that indexes the chain and makes every account, transaction, contract, and event browsable. There is one per network. For this lab we use sepolia.etherscan.io (not etherscan.io — that is mainnet). Treat it as the universal debugger.
The four pages you'll use the most
withdraw() pushes the pooled balance to the beneficiary, that transfer shows up here as an internal tx, even though only one signed transaction was sent.emit MyEvent(...) in Solidity writes a log entry on this tab. Each log has up to 4 topics + a data blob:topic[0] is always the event's signature hash (auto-generated — it's how Etherscan knows which event was emitted).
topic[1..3] are the first three parameters you marked
indexed.Everything not indexed gets concatenated into the data field.
Example — our contract emits
Donated(address indexed donor, uint256 amount, uint256 newTotal). So for each donation:•
topic[0] = keccak256("Donated(address,uint256,uint256)")•
topic[1] = the donor's address (indexed)•
data = amount and newTotal concatenated (not indexed)Why this matters: nodes can filter by topic in O(1), but searching by an un-indexed field forces them to scan and decode every log. So our frontend can subscribe to "every
Donated where donor = 0xabc…" cheaply — but it could not subscribe to "every Donated where amount = 1 ETH" without scanning all logs. Rule of thumb: mark the parameters users will want to filter on (sender, recipient, token id) as indexed; leave bulk data un-indexed to save gas at emit time.Quick exercise — 10 minutes
- Paste the faucet's transaction hash into
sepolia.etherscan.io. Identify: From, To, Value (in ETH), Gas Limit, Gas Used, Gas Price (in Gwei), and Transaction Fee (in ETH). - Now click the From address (the faucet itself). How many transactions has it sent in the last hour? What does that tell you about the level of testnet activity?
- Find a well-known verified contract: paste the canonical Sepolia WETH address
0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14into Etherscan's search bar (avoid searching by the word "WETH" — Sepolia has many tokens with that symbol, most unverified). Open the Contract → Code tab. Notice how the source is human-readable — this is what we will do for our own Donation Pool later. - In that same WETH contract, click Read Contract. Call
totalSupply()directly from the browser — no MetaMask popup, no gas. Why is it free?
Etherscan is your debugger
When a transaction fails in Remix or the frontend, the popup gives you a hash. Always paste that hash into Etherscan first. The "Revert reason" appears on the page, and the decoded calldata shows exactly what arguments were sent. 80% of beginner debugging happens here.
Remix IDE — Compile, Deploy, Interact
Remix is the official browser-based IDE for Solidity, maintained by the Ethereum Foundation. No installation. Go to remix.ethereum.org. The left sidebar shows several vertical icons (file explorer, search, compiler, deploy, static analyser, plugins, settings, etc.) — we'll focus on the four that matter most for this lab.
The four icons that matter
.sol files here. Workspace lives in your browser storage — export to GitHub or download as a ZIP if you care about persistence.0.8.28 or the latest 0.8.x). Click Compile or enable Auto compile. The bytecode and ABI are produced here — they are also the two things you can copy out at the bottom of this panel.Always test on the VM first
The Remix VM environment seeds about 15 fake accounts with 100 fake ETH each, and transactions confirm instantly. Use it to iterate on logic for free. Only switch to Injected Provider — MetaMask when you are sure the contract works — every Sepolia deploy costs faucet ETH and takes ~12 seconds (one block).
Your First Contract — Counter
Create a new file in Remix: open the File Explorer panel (the topmost icon on the left), click the "New file" icon (a sheet of paper with a +) inside the contracts/ folder, and name it Counter.sol. Paste the snippet below. This is the smallest contract that still teaches you the whole deploy-and-call cycle. Read each line — every keyword is explained right after.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
contract Counter {
uint256 public count;
address public owner;
event Incremented(address indexed by, uint256 newValue);
constructor() {
owner = msg.sender;
}
function increment() external {
count += 1;
emit Incremented(msg.sender, count);
}
function reset() external {
require(msg.sender == owner, "not owner");
count = 0;
}
function read() external view returns (uint256) {
return count;
}
}
Line-by-line
SPDXpragma^0.8.28 means "0.8.28 or higher, but below 0.9.0". 0.8.x has built-in overflow checks — a big deal historically.uint256 public countpublic auto-generates a getter named count().Why
uint256 and not uint8? Counter-intuitive but true: even though uint256 is bigger, it is cheaper in gas for a standalone variable. Here is why:The EVM has no "small int" hardware — every storage slot, every stack item, every arithmetic operation is 256 bits wide. When you declare
uint8, Solidity still uses a full 256-bit slot, but it adds extra mask & clean opcodes around every read/write to zero out the unused 248 bits. Those extra opcodes cost gas every time.So a single
uint8 is more expensive than a single uint256. Smaller types only pay off when you can pack several of them into one 256-bit slot — for example a struct with four uint64 fields fits in one slot and costs one SSTORE instead of four. For a standalone counter, uint256 wins every time.address public ownerreset().event Incremented(...)constructor()msg.sender is the EOA that signed the deploy transaction — usually you.function increment()require(...)view returns (uint256)count(); included to show the explicit form.Deploy in two environments
- Remix VM (free): Compile → switch Environment to Remix VM (Cancun) → click orange Deploy. Your contract appears under Deployed Contracts. Expand it: you see one button per public function. Remix colour-codes them — orange/red buttons send a transaction (state-changing), blue ones just call a view function for free. Click orange
increment, then bluecount. The number grew. Each call's result (and any emitted event) appears in the Remix terminal panel at the bottom of the screen — that's also where revert reasons show up when something fails. - Sepolia (real): First check that MetaMask is on Sepolia (network badge at the top of the MetaMask popup; if not, switch back per §4 step 2). Then switch Remix's Environment to Injected Provider — MetaMask. MetaMask will pop up to confirm the connection. After confirmation, Deploy again — this time MetaMask asks you to sign the deploy transaction. Wait ~12 s (one block). Click the contract's copy icon to get the deployed address and paste it into
sepolia.etherscan.io— there it is.
See the bytecode and the ABI
Back in the Solidity Compiler panel, scroll down. Two buttons sit side by side: Bytecode and ABI. Click each — they copy a JSON blob to your clipboard.
Bytecode
A long hex string starting with 0x60806040.... This is what gets stored on-chain at your contract's address. The EVM executes it byte by byte. You will never read it by hand — but it is what every Solidity compile ultimately produces.
ABI — Application Binary Interface
A JSON array describing every public function and event: name, inputs, outputs, type. This is the contract's "instruction manual" — Etherscan reads it to build the Read/Write panels, and ethers.js reads it to encode your function calls into the calldata bytes that the EVM expects. Save this JSON, you will paste it into the frontend in Session 2.
[
{ "type": "constructor", "inputs": [], "stateMutability": "nonpayable" },
{
"type": "function", "name": "increment",
"inputs": [], "outputs": [], "stateMutability": "nonpayable"
},
{
"type": "function", "name": "count",
"inputs": [], "outputs": [{ "type": "uint256" }], "stateMutability": "view"
},
{
"type": "event", "name": "Incremented",
"inputs": [
{ "name": "by", "type": "address", "indexed": true },
{ "name": "newValue", "type": "uint256", "indexed": false }
]
}
]
Function Anatomy — Visibility, State Mutability, Money
Every Solidity function has two orthogonal labels. Visibility answers "who can call it?". State mutability answers "what can it do with state and money?". You combine one from each. Get this wrong and your contract is either uncallable, dangerously open, or wasting gas.
Visibility — who can call
| Keyword | Callable from | Use when |
|---|---|---|
external |
Other accounts/contracts only. Cannot call internally without this.. |
The default for entry points. Cheapest gas when args are arrays — external reads them directly from calldata; public always copies array args into memory first, which costs extra gas. |
public |
Anyone, internally too. | Functions used both as entry points and by other functions in the contract. For state variables, generates a free getter. |
internal |
This contract and contracts that inherit from it. | Helper logic shared with subclasses. Like protected in OOP. |
private |
This contract only. | Internal helpers you do not want subclasses to override. Note: a private variable is still publicly readable — anyone can ask any Ethereum node for your contract's raw storage slots and decode them. private is access control for code, not data privacy. Never put secrets in a smart contract. |
State mutability — what can it do
| Keyword | Reads state | Writes state | Receives ETH | Caller pays gas? |
|---|---|---|---|---|
pure |
❌ | ❌ | ❌ | Free if called off-chain (a "call", not a "transaction"). Use for stateless helpers: function add(uint a, uint b) external pure returns (uint) { return a + b; } |
view |
✅ | ❌ | ❌ | Free if called off-chain |
| (nonpayable, default) | ✅ | ✅ | ❌ | Yes — sender pays gas. Reverts if any ETH is sent. |
payable |
✅ | ✅ | ✅ | Yes — sender pays gas plus any ETH they choose to attach. |
The payable keyword is the one that unlocks money
If a function is not marked payable, any transaction that attaches ETH to it reverts automatically. This is Solidity protecting you: ETH cannot accidentally enter a function that doesn't expect it. Inside a payable function, the attached amount is available as msg.value in Wei. Our donate() in Session 2 will be payable — each donation arrives via msg.value.
Globals you'll use everywhere
| Global | What it is |
|---|---|
msg.sender |
The address that called this function. The EOA for direct calls, or a contract address when one contract calls another. |
msg.value |
Amount of ETH (in Wei) attached to the current call. Only meaningful inside payable functions. |
block.timestamp |
Unix seconds at the time of the block. Acceptable for ~12 s precision (one block); never as a source of randomness. |
address(this).balance |
The ETH balance of the current contract, in Wei. |
tx.origin |
The original EOA that started the whole call chain. Almost always the wrong thing to use for access control — use msg.sender. (One niche legitimate use: require(tx.origin == msg.sender) to block any contract from calling you — rarely a good idea, breaks composability.) |
Two transaction kinds you'll see in MetaMask
"Transactions" — write
Calls to non-view, non-pure functions. Signed by your wallet, broadcast to the network, mined into a block. MetaMask pops up asking for confirmation and showing a gas estimate. Returns a transaction hash, not the function's return value.
"Calls" — read
Calls to view or pure functions. Executed locally by your RPC node — no broadcast, no mining, no gas, no MetaMask popup. Returns the actual value. This is why fetching count() or price() is instant in our frontend.
Project Brief — Donation Pool
Now you have the building blocks. Time to compose them into something real. The contract you write today is small enough to fit on one screen but uses every feature you have just learned: a payable function, an event, a mapping, immutable variables, access control by address, custom errors, and a controlled outbound ETH transfer.
Specification
owner — purely for transparency, has no special powers after the contract is deployed) and the beneficiary (the only address that can later withdraw the funds). Both addresses are immutable — locked into the bytecode at construction, unchangeable forever.owner, beneficiary, targetAmount (in Wei) — all immutable; totalRaised — running sum of every donation; withdrawn — a one-shot boolean flag; donations — a mapping(address => uint256) tracking how much each donor has given (cumulative, useful for the DApp to display "you've donated X").payable. Anyone can call, any number of times. Reverts if msg.value == 0 (no point in zero-value donations) or if the funds have already been withdrawn. Otherwise: adds msg.value to the caller's entry in donations and to totalRaised; emits Donated. Multiple donations from the same address simply accumulate in donations[donor] — donors aren't capped to one contribution. If this donation is the one that crosses the target threshold (was below, is now at or above), it additionally emits TargetReached exactly once.msg.sender == beneficiary (anyone else gets OnlyBeneficiary), the target has been reached (totalRaised >= targetAmount, otherwise TargetNotReached), and the contract has not already been emptied (withdrawn == false, otherwise AlreadyWithdrawn). Sets withdrawn = true before the external call (CEI), then sends the entire address(this).balance to the beneficiary in a single .call. Emits Withdrawn.progressPercent() (capped at 100), isTargetReached(), remaining(). Plus the auto-generated getters from the public state variables: owner(), beneficiary(), targetAmount(), totalRaised(), withdrawn(), donations(address). The frontend reads these to render the page without submitting any transaction.Donated(address indexed donor, uint256 amount, uint256 newTotal), TargetReached(uint256 finalAmount), Withdrawn(address indexed by, uint256 amount). Indexing donor and by lets the frontend filter "show me my donations" or "watch for the withdrawal" without scanning every log.totalRaised above the target, withdrawal still sends the full balance (the beneficiary receives the overshoot too).Trustless by construction — why immutable matters here
This is a small contract, but it makes a strong promise to every donor: your donations will only ever reach the beneficiary you saw at deploy time. That promise is enforced by two things, neither of which involves trusting the deployer: (1) beneficiary and targetAmount are declared immutable — Solidity bakes their values into the contract's bytecode during construction. There is no opcode that can change them afterwards. (2) The contract contains no admin functions — no setBeneficiary, no cancel, no rescueFunds, no onlyOwner modifier on anything. The deployer's address appears in owner only as a transparency record on Etherscan. They cannot call any function that would redirect, refund, or freeze the money. The guarantee is in the code — anyone can read the bytecode and confirm it. That is what "trustless" actually means.
Scaffold — Try It Yourself First
Below is a deliberately incomplete starter. Open Remix, create DonationPool.sol, paste this, and fill in the TODO blocks. Give yourself 30–40 minutes before peeking at the reference solution in the next section.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
contract DonationPool {
// ── Immutable config (locked at construction) ────
address public immutable owner; // deployer — recorded only, NO privileges
address public immutable beneficiary; // the only address that can withdraw
uint256 public immutable targetAmount; // in wei
// ── Mutable state ────────────────────────────────
uint256 public totalRaised;
bool public withdrawn;
mapping(address => uint256) public donations;
// ── Events ───────────────────────────────────────
event Donated(address indexed donor, uint256 amount, uint256 newTotal);
event TargetReached(uint256 finalAmount);
event Withdrawn(address indexed by, uint256 amount);
// ── Errors (gas-cheap reverts) ───────────────────
error ZeroDonation();
error AlreadyWithdrawn();
error OnlyBeneficiary();
error TargetNotReached(uint256 raised, uint256 target);
error TransferFailed();
constructor(address _beneficiary, uint256 _targetWei) {
// TODO 1: assign the three immutables.
// owner ← msg.sender (the deployer; not a constructor arg).
// beneficiary ← _beneficiary.
// targetAmount ← _targetWei.
}
// TODO 2: write donate() — payable
// - revert ZeroDonation() if msg.value == 0
// - revert AlreadyWithdrawn() if `withdrawn` is true
// - remember whether totalRaised was below targetAmount BEFORE this donation
// (you'll need it to decide whether to emit TargetReached)
// - add msg.value to donations[msg.sender] and to totalRaised
// - emit Donated(msg.sender, msg.value, totalRaised)
// - if we just crossed the threshold (was below, now ≥), emit TargetReached
// TODO 3: write withdraw() — anyone calls but only the beneficiary succeeds
// - revert OnlyBeneficiary() if msg.sender != beneficiary
// - revert TargetNotReached(totalRaised, targetAmount) if totalRaised < targetAmount
// - revert AlreadyWithdrawn() if `withdrawn` is true
// - set withdrawn = true BEFORE the external call (Checks-Effects-Interactions)
// - send address(this).balance to the beneficiary via payable(...).call{value: ...}("")
// - emit Withdrawn(beneficiary, amount)
function progressPercent() external view returns (uint256) {
// TODO 4: return (totalRaised * 100) / targetAmount, capped at 100
}
function isTargetReached() external view returns (bool) {
// TODO 5
}
function remaining() external view returns (uint256) {
// TODO 6: return targetAmount - totalRaised, or 0 if already at/past target
}
}
Hints
- Sending ETH:
(bool ok, ) = recipient.call{value: amount}(""); require(ok, "transfer failed");— this is the modern, recommended pattern..transfer()and.send()are now discouraged because they hard-code a 2300-gas budget that was made unreliable by a 2019 Ethereum upgrade (Istanbul / EIP-1884) which raised the cost of common opcodes above that budget. - One-shot withdraw via a boolean flag.
bool public withdrawn;defaults tofalse. Set it totruebefore the external call, never clear it back. This single flag prevents double withdrawal and is cheaper than deleting state. - Custom errors (
error X(), thenrevert X();) are ~50% cheaper thanrequire(cond, "string"). Use them throughout — you'll see the gas savings in the Remix logs after each call. Errors with arguments (likeTargetNotReached(uint256, uint256)) let Etherscan show why the call failed and what the values were.
Verify your scaffold in the Remix VM (5 minutes)
Before peeking at §11, run these four checks on the Remix VM environment to confirm your fill-in actually works. Deploy with _beneficiary = Account 5 (or any address other than Account 1, the deployer), _targetWei = 100000000000000000 (0.1 ETH — use Remix's ether unit selector and type 0.1).
- Donate & reach target. (Fresh deploy.) Switch to Account 2, set Value to
0.05 ether, calldonate→ succeeds. Calldonations(account2)→ returns5e16(0.05 ETH in wei). CallprogressPercent()→50. Now switch to Account 3, donate0.05 ether→ the terminal shows bothDonatedandTargetReachedevents.isTargetReached()→true. - Cannot withdraw before target. (Redeploy fresh.) Donate only
0.05 etherfrom Account 2 (half the target). Switch to the beneficiary (Account 5) and callwithdraw→ must revert withTargetNotReached(50000000000000000, 100000000000000000). - Only the beneficiary can withdraw. (Redeploy fresh.) Bring the campaign to its target: donate
0.05 etherfrom Account 2, then0.05 etherfrom Account 3. Now switch to Account 4 — not the deployer, not the beneficiary — and callwithdraw→ must revert withOnlyBeneficiary. Try Account 1 (the deployer) — same revert. The deployer has no power, even though they made the contract. - One-shot withdrawal. (Continuing from T3 — target is met, nothing withdrawn yet.) Switch to the beneficiary (Account 5) and call
withdraw→ succeeds; the beneficiary's balance jumps by 0.1 ETH (minus a tiny gas fee). Now callwithdrawa second time → must revert withAlreadyWithdrawn.
If all four pass, your contract is correct. If anything misbehaves, compare with §11 — but try to spot your bug yourself first.
Reference Solution — DonationPool.sol
Compare your version with this one. There is no single "right" answer — yours might be cleaner. The notes after the code call out the decisions that matter.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
/// @title DonationPool
/// @notice Collects ETH donations toward a fixed target. Once the target is met,
/// only the beneficiary can withdraw the funds. The deployer is recorded
/// in `owner` for transparency but has NO functions they can call — they
/// cannot change the beneficiary, cancel the campaign, or withdraw funds.
contract DonationPool {
address public immutable owner;
address public immutable beneficiary;
uint256 public immutable targetAmount;
uint256 public totalRaised;
bool public withdrawn;
mapping(address => uint256) public donations;
event Donated(address indexed donor, uint256 amount, uint256 newTotal);
event TargetReached(uint256 finalAmount);
event Withdrawn(address indexed by, uint256 amount);
error ZeroDonation();
error AlreadyWithdrawn();
error OnlyBeneficiary();
error TargetNotReached(uint256 raised, uint256 target);
error TransferFailed();
constructor(address _beneficiary, uint256 _targetWei) {
owner = msg.sender;
beneficiary = _beneficiary;
targetAmount = _targetWei;
}
/// @notice Anyone can donate any non-zero amount of ETH.
function donate() external payable {
if (msg.value == 0) revert ZeroDonation();
if (withdrawn) revert AlreadyWithdrawn();
bool wasBelowTarget = totalRaised < targetAmount;
donations[msg.sender] += msg.value;
totalRaised += msg.value;
emit Donated(msg.sender, msg.value, totalRaised);
// Fire TargetReached exactly once — the moment we cross the threshold.
if (wasBelowTarget && totalRaised >= targetAmount) {
emit TargetReached(totalRaised);
}
}
/// @notice Only the beneficiary can withdraw, only after the target is met,
/// and only once. Pulls the entire contract balance (handles any
/// over-donation that pushed totalRaised past the target).
function withdraw() external {
if (msg.sender != beneficiary) revert OnlyBeneficiary();
if (totalRaised < targetAmount) revert TargetNotReached(totalRaised, targetAmount);
if (withdrawn) revert AlreadyWithdrawn();
withdrawn = true; // CEI: effect before interaction
uint256 amount = address(this).balance;
(bool ok, ) = payable(beneficiary).call{value: amount}("");
if (!ok) revert TransferFailed();
emit Withdrawn(beneficiary, amount);
}
// ── Views ────────────────────────────────────
function progressPercent() external view returns (uint256) {
if (targetAmount == 0) return 0;
uint256 p = (totalRaised * 100) / targetAmount;
return p > 100 ? 100 : p;
}
function isTargetReached() external view returns (bool) {
return totalRaised >= targetAmount;
}
function remaining() external view returns (uint256) {
return totalRaised >= targetAmount ? 0 : targetAmount - totalRaised;
}
}
Design notes
withdraw() we set withdrawn = true before the external .call. If the beneficiary turned out to be a malicious contract that re-enters withdraw() mid-execution, the second call would already see withdrawn == true and trip AlreadyWithdrawn. This neutralises the classic reentrancy drain: the attacker cannot withdraw twice. The same logic applies to donate() — although nothing is sent out there, we update state before emitting events for consistency.immutable variables are set once in the constructor, then baked into the contract's bytecode at deploy time. There is literally no opcode that can change them afterwards — not SSTORE, not anything. Reading them costs ~3 gas (vs 2100 for a regular storage slot). For this contract that's a security feature, not just optimisation: beneficiary and targetAmount cannot be modified by the deployer, by an attacker who compromises the deployer's key, or by anyone else. The values you saw at deploy are the values forever.revert MyError(args) compiles to 4 bytes of selector + ABI-encoded args. require(cond, "long string") stores the string in code. Errors save ~50–80 gas on every revert path. TargetNotReached(uint256 raised, uint256 target) in particular returns the two numbers — Etherscan decodes them and shows "raised: 50000000000000000, target: 100000000000000000" right on the failed transaction page, which beats deciphering a string..call, not .transfer.call forwards all remaining gas (you can cap it with {gas: n} if you really need to). .transfer forwards only 2300 gas, which silently breaks when a recipient contract has a non-trivial fallback. The community moved on around Solidity 0.6.setBeneficiary, no cancel, no rescueFunds, no pause, no onlyOwner modifier on anything. The deployer's address sits in owner only as a transparency record. If you added even one admin function, donors would have to trust the deployer not to abuse it — and the whole point of this contract is to remove that trust. The Ownable pattern from OpenZeppelin (the industry-standard library of audited, reusable Solidity contracts) is excellent when an owner does have legitimate post-deploy duties; here, by design, they don't. Compare with the Counter in §7 — there, owner had a real power (reset() was access-controlled). Same Solidity primitives; opposite trust model. The lesson: storing owner is not the same as granting the owner anything.withdraw call), but inside that call the contract pushes all the ETH out in a single transfer. If the beneficiary happens to be a contract whose receive function reverts (or runs out of gas), the entire withdraw will fail every time — the donations would be locked forever. With our spec (beneficiary is an EOA chosen at deploy time) this is a non-issue. For a production system where the beneficiary might be a multisig or another contract, you'd switch to a true pull pattern: a separate claim() that the beneficiary calls in chunks, so a failing transfer only burns that one call, not the whole campaign.payable(...) castwithdraw() we write payable(beneficiary).call{value: amount}(""). Since Solidity 0.6, .call works on a plain address too — the payable(...) cast is technically redundant here but kept for readability: it signals "we're about to move ETH" and matches the older, stricter pattern where .transfer / .send required address payable. You will see both styles in the wild; either compiles.Deploy to Sepolia & Verify on Etherscan
Same flow as the Counter, but with constructor arguments. Take this slowly — once deployed, you cannot edit the contract. To change anything you must redeploy at a new address.
- Compile with optimisation enabled (Solidity Compiler → Advanced configurations → Enable optimization, 200 runs). Optimisation produces smaller, cheaper bytecode and is expected by Etherscan's verifier.
- Switch Environment to Injected Provider — MetaMask. MetaMask must be on Sepolia.
- Set constructor args. Click the small arrow (
▼) next to the orange Deploy button to expand the inputs for each constructor parameter. Suggested values for the demo:_beneficiary: a second address you control (a classmate's address, or a second account you can switch to in MetaMask). Choose carefully — you cannot change it later._targetWei:100000000000000000(0.1 ETH — Remix has a "wei/gwei/ether" helper next to the input; pick ether and just type0.1to avoid counting zeros). Keep it small so you can actually meet the target with Sepolia faucet funds.
- Click Deploy. Confirm in MetaMask. Wait ~12 s (one block).
- Copy the contract address (the little copy icon next to the deployed contract in Remix) and open it in
sepolia.etherscan.io.
Verifying the source code
Verifying matches your .sol source against the deployed bytecode. After verification, anyone can read your code on Etherscan and call your functions through the Read/Write UI — invaluable for demos and audits.
Recommended path — Remix's built-in Etherscan plugin (one-click, handles ABI-encoded args automatically):
- In Remix, click the Plugin Manager icon (bottom-left, looks like a plug). Search for Etherscan - Contract Verification and click Activate.
- A new icon appears in the left sidebar. Open it. Paste your Etherscan API key (get a free one at
etherscan.io/myapikey) and pick network Sepolia. - Pick the contract (
DonationPool), paste your deployed Sepolia address, and click Verify. The plugin auto-fills the source, compiler version, optimisation settings, and ABI-encoded constructor args. - Within ~30 s the plugin reports success. Your contract page on Etherscan now has Read Contract and Write Contract tabs, with full source visible under Contract → Code.
Fallback path — manual upload on Etherscan (if the plugin fails or you prefer to understand each piece):
- On your contract's Etherscan page, click the Contract tab → Verify and Publish.
- Compiler type: Solidity (Single file). Compiler version: the exact string from the top of Remix's Solidity Compiler panel (something like
v0.8.28+commit.6a4a2d24). License: MIT. - Paste your full
.solsource. Optimisation: Yes, 200 runs — must match Remix exactly. - For the ABI-encoded constructor arguments: open Remix's Solidity Compiler panel, scroll down, click Compilation Details (small icon at the bottom). In the modal, copy the value next to ENCODED ARGS. Paste it into Etherscan's form (strip the leading
0xif it complains). - Submit. Within ~30 s, Etherscan reports a green check. Same result as the plugin path.
Smoke-test from Etherscan
Under Write Contract, click Connect to Web3 (uses MetaMask) and call donate. The form asks for a payableAmount in ETH — enter 0.05. Confirm in MetaMask. Once mined, the Events tab of your contract shows the Donated log. Stay on the same account and donate another 0.05 — the contract accepts repeated donations from the same address and just accumulates them in donations[your_address]. The Events tab now shows two logs: another Donated and a TargetReached (the campaign just hit 0.1 ETH). Now switch MetaMask to the beneficiary account, refresh Etherscan, and call withdraw. After the block confirms, the beneficiary's balance jumps by 0.1 ETH and the Events tab shows the Withdrawn log. That is your first end-to-end trustless fundraiser.
DApp Frontend — Vanilla HTML + ethers.js v6
Final piece. A single index.html with a <script> tag is enough — no build tool, no React, no npm. The whole frontend fits on one screen. Save the file locally, serve it with any tiny static server (we'll show how below), and you have a working DApp.
The stack
window.ethereum into the page.window.ethereum with a clean API.We will build the frontend in five short steps. Each step adds one concept on top of the previous — by the end you'll have the full file at the bottom of this section. Read each snippet carefully; the comments call out the one or two things that matter most.
Heads-up — the snippets are illustrative, not copy-paste-ready
Each step shows only the lines that are new. To keep them readable, variables like provider, signer, and account are declared inline (with const) inside the function where they first appear. If you naively concatenate the five snippets you'll get "provider is not defined" errors, because Step 3 references provider that Step 2 declared locally. The complete file at the bottom is the version that actually runs: it lifts those variables to module scope (let provider, signer, …) so all five pieces share them. Read each step for understanding; copy the final file to run.
Step 1 — The page skeleton
Start with a plain HTML5 document. Three buttons (Connect, Donate, and a hidden Withdraw), a number input for the donation amount, a few <code> placeholders we will fill with live data, and one <script type="module"> at the end. No framework, no bundler, no npm. The type="module" is what lets us use import directly from a CDN.
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Donation Pool</title></head>
<body>
<button id="connect">Connect MetaMask</button>
<span id="account">not connected</span>
Beneficiary: <code id="beneficiary">…</code>
Raised: <code id="raised">…</code> / <code id="target">…</code> ETH
(<code id="progress">…</code>%)
<input id="amount" type="number" step="0.001" min="0.001" placeholder="0.01">
<button id="donate">Donate ETH</button>
<button id="withdraw" hidden>Withdraw (beneficiary only)</button>
<script type="module">
// everything below goes here — we'll add it step by step
</script>
</body>
</html>
Step 2 — Talk to MetaMask
When MetaMask is installed, it injects an object called window.ethereum into every web page. That object speaks EIP-1193 — a low-level JSON-RPC interface. Ethers wraps it in a friendlier BrowserProvider.
The first thing the user must do is authorise the page. That is the eth_requestAccounts call — MetaMask pops up asking "Connect this site?". After approval, we ask the provider for a Signer, which is the abstraction that can sign transactions on behalf of the connected account.
import { BrowserProvider } from "https://cdn.jsdelivr.net/npm/ethers@6/+esm";
document.getElementById("connect").addEventListener("click", async () => {
if (!window.ethereum) { alert("Install MetaMask first"); return; }
// 1. wrap the injected EIP-1193 provider
const provider = new BrowserProvider(window.ethereum);
// 2. ask the user for permission — this is what triggers the MetaMask popup
await provider.send("eth_requestAccounts", []);
// 3. get a Signer (knows the address, can sign txs)
const signer = await provider.getSigner();
const account = await signer.getAddress();
document.getElementById("account").textContent = account;
});
Provider vs Signer — the one distinction to remember
A Provider can only read the chain — balances, contract state, blocks. It needs no permission. A Signer can also send transactions (writes) because it is tied to an authorised account. Reads use the provider; writes use the signer. We will see both right below.
Step 3 — Read contract state (calls, not transactions)
To talk to your deployed contract, ethers needs two things: the address (where it lives on Sepolia) and the ABI (what functions it has). The ABI can be a JSON object — or, much more concisely, a list of human-readable signature strings. Ethers accepts either.
Reading is free: we bind a Contract to the provider, call any view/pure function, and ethers runs an eth_call under the hood — no broadcast, no mining, no MetaMask popup. uint256 values come back as native JavaScript BigInt; formatEther turns Wei into a human-readable ETH string.
import { Contract, formatEther } from "https://cdn.jsdelivr.net/npm/ethers@6/+esm";
// ⚠ Replace this placeholder with the address Remix gave you after deploying
// DonationPool.sol on Sepolia. A real address is 42 chars: "0x" + 40 hex digits.
const CONTRACT_ADDRESS = "0xPASTE_YOUR_SEPOLIA_CONTRACT_ADDRESS_HERE";
const ABI = [
"function beneficiary() view returns (address)",
"function targetAmount() view returns (uint256)",
"function totalRaised() view returns (uint256)",
"function progressPercent() view returns (uint256)",
"function isTargetReached() view returns (bool)",
"function donations(address) view returns (uint256)",
];
// bound to the PROVIDER ⇒ read-only, free, no popup
const readonly = new Contract(CONTRACT_ADDRESS, ABI, provider);
// Parallel fetch with Promise.all — one round-trip per call.
const [beneficiary, targetWei, raisedWei, percent, reached] = await Promise.all([
readonly.beneficiary(),
readonly.targetAmount(), // uint256 → BigInt
readonly.totalRaised(),
readonly.progressPercent(),
readonly.isTargetReached(),
]);
console.log(
"beneficiary:", beneficiary,
"raised:", formatEther(raisedWei), "/", formatEther(targetWei), "ETH",
"(" + percent + "%)",
reached ? "🎉 target reached" : ""
);
Step 4 — Send a transaction (the MetaMask popup moment)
Writes are different. We bind a second Contract instance to the signer, then call donate with { value: amountWei } — that value field becomes msg.value inside Solidity. The user picks how much to donate via an input, so we convert their ETH input to Wei with parseEther. The function returns a tx object as soon as MetaMask broadcasts it. To wait for inclusion in a block, we await tx.wait() — that gives us the receipt.
A common confusion: tx.hash is available immediately (you can paste it into Etherscan right away to follow progress). The receipt only arrives ~12 seconds later, once the block is mined.
import { Contract, parseEther } from "https://cdn.jsdelivr.net/npm/ethers@6/+esm";
// bound to the SIGNER ⇒ can send transactions
const writable = new Contract(
CONTRACT_ADDRESS,
["function donate() payable", "function withdraw()"],
signer
);
// ── Donate (anyone can call) ─────────────────────────────────
document.getElementById("donate").addEventListener("click", async () => {
try {
const ethAmount = document.getElementById("amount").value;
if (!ethAmount || Number(ethAmount) <= 0) { alert("enter a positive amount"); return; }
// parseEther turns "0.05" → 50000000000000000n (BigInt, in wei).
// `{ value: ... }` is ethers' "call overrides" — what becomes msg.value.
const tx = await writable.donate({ value: parseEther(ethAmount) });
console.log("donate submitted, hash =", tx.hash);
const receipt = await tx.wait();
console.log("confirmed in block", receipt.blockNumber);
} catch (e) {
console.error(e.shortMessage || e.message);
}
});
// ── Withdraw (only the beneficiary; only after target is reached) ────
document.getElementById("withdraw").addEventListener("click", async () => {
try {
const tx = await writable.withdraw(); // no value, no args
console.log("withdraw submitted, hash =", tx.hash);
await tx.wait();
console.log("funds released to beneficiary");
} catch (e) {
// e.g. OnlyBeneficiary, TargetNotReached, AlreadyWithdrawn
console.error(e.shortMessage || e.message);
}
});
Step 5 — Listen to events live
Reading once is fine, but the page would go stale the moment someone else donated from another browser. The fix is to subscribe to the contract's events. Ethers opens a long-lived JSON-RPC subscription via MetaMask; whenever a new matching log appears on Sepolia, the callback fires — without a page refresh. We listen for two: Donated (update the progress bar) and TargetReached (reveal the Withdraw button to the beneficiary).
// Add the events to the ABI used by `readonly` so ethers can decode them:
// "event Donated(address indexed donor, uint256 amount, uint256 newTotal)"
// "event TargetReached(uint256 finalAmount)"
// "event Withdrawn(address indexed by, uint256 amount)"
readonly.on("Donated", (donor, amount, newTotal) => {
console.log(
"❤️", donor, "donated", formatEther(amount),
"ETH · running total:", formatEther(newTotal), "ETH"
);
refresh(); // re-read state, update the progress bar
});
readonly.on("TargetReached", (finalAmount) => {
console.log("🎉 target reached!", formatEther(finalAmount), "ETH");
refresh(); // the Withdraw button becomes visible to the beneficiary
});
readonly.on("Withdrawn", (by, amount) => {
console.log("✅ withdrawn", formatEther(amount), "ETH by", by);
refresh();
});
Why this is magic
Open your DApp in two browsers, side by side, with different MetaMask accounts. Donate from the right — the left page's progress bar moves a few seconds later, automatically. When the target is reached, the Withdraw button reveals itself on the beneficiary's screen only. That's the subscription doing its job. This is the foundation of every live order-book, NFT mint counter, crowdfunding bar, and DeFi UI you have ever seen.
Putting it all together — the complete index.html
Below is the full single-file DApp. It is exactly the five pieces you just read, glued together with a touch of CSS and a tiny log panel for visibility. Save it as index.html, paste your Sepolia contract address in CONTRACT_ADDRESS, and serve the folder.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Donation Pool</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 560px; margin: 2rem auto; padding: 0 1rem; }
button { padding: 0.6rem 1rem; font-size: 1rem; cursor: pointer; }
input[type=number] { padding: 0.5rem; font-size: 1rem; width: 8rem; }
.row { margin: 0.5rem 0; }
.bar { height: 14px; background: #eee; border-radius: 8px; overflow: hidden; margin: 0.4rem 0; }
.bar > div { height: 100%; background: linear-gradient(90deg, #34d399, #22d3ee); transition: width 0.4s; }
code { background: #f3f3f3; padding: 0 4px; border-radius: 3px; }
#log { background: #0a0a0a; color: #b6f5b0; padding: 0.8rem; border-radius: 6px;
font-family: ui-monospace, monospace; font-size: 0.85rem; white-space: pre-wrap;
max-height: 220px; overflow: auto; margin-top: 1rem; }
</style>
</head>
<body>
<h1>❤️ Donation Pool</h1>
<div class="row"><button id="connect">Connect MetaMask</button>
<span id="account">not connected</span></div>
<div class="row">Beneficiary: <code id="beneficiary">…</code></div>
<div class="row">Raised: <code id="raised">…</code> / <code id="target">…</code> ETH
(<code id="progress">…</code>%)</div>
<div class="bar"><div id="barFill" style="width:0%"></div></div>
<div class="row">Your donations so far: <code id="mine">…</code> ETH</div>
<div class="row">
<input id="amount" type="number" step="0.001" min="0.001" placeholder="0.01">
<button id="donate">Donate ETH</button>
</div>
<div class="row">
<button id="withdraw" hidden>Withdraw to beneficiary</button>
<span id="status"></span>
</div>
<div id="log"></div>
<script type="module">
import { BrowserProvider, Contract, formatEther, parseEther }
from "https://cdn.jsdelivr.net/npm/ethers@6/+esm";
// ── 1. PASTE YOUR DEPLOY OUTPUT HERE ─────────────────────────
// ⚠ Replace this placeholder with the contract address Remix gave you
// after deploying DonationPool.sol on Sepolia. A real address is 42
// characters: "0x" + 40 hex digits.
const CONTRACT_ADDRESS = "0xPASTE_YOUR_SEPOLIA_CONTRACT_ADDRESS_HERE";
const ABI = [
"function owner() view returns (address)",
"function beneficiary() view returns (address)",
"function targetAmount() view returns (uint256)",
"function totalRaised() view returns (uint256)",
"function withdrawn() view returns (bool)",
"function donations(address) view returns (uint256)",
"function progressPercent() view returns (uint256)",
"function isTargetReached() view returns (bool)",
"function donate() payable",
"function withdraw()",
"event Donated(address indexed donor, uint256 amount, uint256 newTotal)",
"event TargetReached(uint256 finalAmount)",
"event Withdrawn(address indexed by, uint256 amount)"
];
const log = (...a) => { document.getElementById("log").textContent += a.join(" ") + "\n"; };
let provider, signer, account, readonly, writable;
// ── 2. CONNECT ───────────────────────────────────────────────
document.getElementById("connect").addEventListener("click", async () => {
if (!window.ethereum) { alert("Install MetaMask"); return; }
provider = new BrowserProvider(window.ethereum);
await provider.send("eth_requestAccounts", []);
signer = await provider.getSigner();
account = await signer.getAddress();
document.getElementById("account").textContent = account;
readonly = new Contract(CONTRACT_ADDRESS, ABI, provider);
writable = new Contract(CONTRACT_ADDRESS, ABI, signer);
await refresh();
subscribe();
log("connected as", account);
});
// ── 3. READ STATE ────────────────────────────────────────────
async function refresh() {
const [ben, target, raised, percent, reached, withdrawn, mine] =
await Promise.all([
readonly.beneficiary(),
readonly.targetAmount(),
readonly.totalRaised(),
readonly.progressPercent(),
readonly.isTargetReached(),
readonly.withdrawn(),
readonly.donations(account)
]);
document.getElementById("beneficiary").textContent = ben;
document.getElementById("target").textContent = formatEther(target);
document.getElementById("raised").textContent = formatEther(raised);
document.getElementById("progress").textContent = percent.toString();
document.getElementById("barFill").style.width = percent + "%";
document.getElementById("mine").textContent = formatEther(mine);
// Withdraw button: only visible when target is met, not yet withdrawn,
// AND the connected account is the beneficiary.
const isBeneficiary = account.toLowerCase() === ben.toLowerCase();
document.getElementById("withdraw").hidden = !(reached && !withdrawn && isBeneficiary);
document.getElementById("status").textContent =
withdrawn ? "✅ Funds withdrawn — campaign closed." :
reached ? "🎉 Target reached — awaiting beneficiary withdrawal." :
"";
}
// ── 4. WRITE A TX ────────────────────────────────────────────
document.getElementById("donate").addEventListener("click", async () => {
try {
const ethAmount = document.getElementById("amount").value;
if (!ethAmount || Number(ethAmount) <= 0) {
log("enter a positive amount in ETH"); return;
}
log("sending donate(" + ethAmount + " ETH), confirm in MetaMask…");
const tx = await writable.donate({ value: parseEther(ethAmount) });
log("tx hash:", tx.hash);
const r = await tx.wait();
log("confirmed in block", r.blockNumber);
await refresh();
} catch (e) { log("error:", e.shortMessage || e.message); }
});
document.getElementById("withdraw").addEventListener("click", async () => {
try {
log("sending withdraw, confirm in MetaMask…");
const tx = await writable.withdraw();
log("tx hash:", tx.hash);
await tx.wait();
log("✅ funds released to beneficiary");
await refresh();
} catch (e) { log("error:", e.shortMessage || e.message); }
});
// ── 5. LISTEN TO EVENTS ──────────────────────────────────────
function subscribe() {
readonly.on("Donated", (donor, amount, newTotal) => {
log("❤️ Donated by", donor, "·", formatEther(amount), "ETH · total:", formatEther(newTotal));
refresh();
});
readonly.on("TargetReached", (finalAmount) => {
log("🎉 Target reached:", formatEther(finalAmount), "ETH");
refresh();
});
readonly.on("Withdrawn", (by, amount) => {
log("✅ Withdrawn", formatEther(amount), "ETH by", by);
refresh();
});
}
</script>
</body>
</html>
How to run it
- Save the file as
index.html. ReplaceCONTRACT_ADDRESSwith your deployed Sepolia address. - Serve the folder with any tiny static server — browsers refuse to load ES modules over
file://for security reasons. Easiest options:python3 -m http.serverin the folder, then openhttp://localhost:8000— or VS Code's Live Server extension, ornpx serve. - Click Connect MetaMask. Approve. The page populates with the campaign's live state — beneficiary, target, raised amount, progress bar.
- Type an amount (e.g.
0.05) into the input and click Donate ETH. MetaMask asks for confirmation. After ~12 s (one block) the log line "confirmed in block ..." appears and the progress bar advances. Repeat until the target is reached — you'll see theTargetReachedlog fire once. - Once the target is reached, switch MetaMask to the beneficiary account and reconnect — the Withdraw to beneficiary button becomes visible (it's hidden for everyone else). Click it; the contract's entire balance moves to the beneficiary in one transaction. To watch the live-update magic, open the same page in a second browser instance with a different MetaMask account: donate from one, watch the other's bar move automatically thanks to the event subscription. Practical ways to get a second instance: (a) a different browser (Chrome + Firefox / Brave), each with its own MetaMask; (b) Chrome user profiles (top-right avatar → Add) — each profile has its own extensions and MetaMask state; (c) inside the same MetaMask, click the account icon → Add account or hardware wallet → Add a new account to get a fresh address, then switch between them manually before each tx.
Why ethers.js v6, not v5 or Web3.js
v6 is the current stable ethers line — BrowserProvider replaces the old Web3Provider, BigInt replaces BigNumber, and the ESM build loads from a CDN with one import. Web3.js v4 works fine too; for the same DApp you would use new Web3(window.ethereum) and web3.eth.Contract — slightly more verbose but conceptually identical.
Wrap-up, Variations, and What to Learn Next
If you have reached this section with a verified contract and a working DApp, you have done in 8 hours what most online courses take a weekend to cover. Time to consolidate.
You can now…
- Install and secure a MetaMask wallet; explain why the seed phrase matters.
- Switch between mainnet and testnets; request and verify test ETH on Sepolia.
- Read addresses, transactions, internal transactions and events on Etherscan.
- Use Remix to compile, deploy (to VM and to Sepolia), and inspect bytecode & ABI.
- Reason about visibility (
external/public/internal/private) and state mutability (pure/view/payable) when designing a contract. - Write a small payable contract with immutable trust guarantees, address-based access control, and event-driven UI updates — applying CEI and custom errors.
- Verify source on Etherscan and interact with the contract from a vanilla HTML + ethers.js v6 page, including live event subscriptions.
Variations to propose to your students
If the donation example bores them, the same architecture (a small payable contract + DApp + event subscriptions) supports a dozen different briefs. Pick one — or let them pick.
Ticket Generator
Fixed-supply non-transferable tickets at a fixed price. Organiser takes 95% per sale; a configurable beneficiary takes 5%. Adds a struct Ticket and a sold-out cap.
Tip jar
Anyone tips ETH with a short message. Author withdraws anytime. The DApp lists the last 10 messages by reading event logs.
Time-locked donation
Same as today's lab, but with a deadline: if the target is not reached by block.timestamp >= endsAt, donors can call refund() to reclaim their donation. Adds a deadline + per-donor reclaim flow.
Paid polls
Voting costs 0.001 ETH per vote (anti-sybil). The poll creator can withdraw the pooled fees once voting closes. Adds an enum option and basic vote tallying.
Going deeper from here
Each of the modules below picks up exactly where this lab leaves off, with much more depth.
immutable, custom errors, CEI, the three ways to send ETH — in one searchable, lab-aligned page. Bookmark it.Lab deliverables (suggested grading rubric)
- Sepolia contract address, verified on Etherscan (40%).
- Source file with
immutablebeneficiary/target, custom errors, and the CEI pattern (20%). - Working DApp hosted locally — connect, read state, donate, listen, and (as the beneficiary) withdraw (30%).
- One-page write-up: what design choice did you make (e.g. allowing over-donation, no deadline, no refund) and what trust property does it preserve or weaken (10%).