21 KiB
🏗 Scaffold-ETH 2
Documentation | Website
🧪 An open-source, up-to-date toolkit for building decentralized applications (dapps) on the Ethereum blockchain. It's designed to make it easier for developers to create and deploy smart contracts and build user interfaces that interact with those contracts.
⚙️ Built using NextJS, RainbowKit, Hardhat, Wagmi, Viem, and Typescript.
- ✅ Contract Hot Reload: Your frontend auto-adapts to your smart contract as you edit it.
- 🪝 Custom hooks: Collection of React hooks wrapper around wagmi to simplify interactions with smart contracts with typescript autocompletion.
- 🧱 Components: Collection of common web3 components to quickly build your frontend.
- 🔥 Burner Wallet & Local Faucet: Quickly test your application with a burner wallet and local faucet.
- 🔐 Integration with Wallet Providers: Connect to different wallet providers and interact with the Ethereum network.
Requirements
Before you begin, you need to install the following tools:
- Node (>= v20.18.3)
- Yarn (v1 or v2+)
- Git
🚩 Challenge: 🏵 Token Vendor 🤖
🤖 Smart contracts are kind of like "always on" vending machines that anyone can access. Let's make a decentralized, digital currency. Then, let's build an unstoppable vending machine that will buy and sell the currency. We'll learn about the "approve" pattern for ERC20s and how contract to contract interactions work.
🏵 Create YourToken.sol smart contract that inherits the ERC20 token standard from OpenZeppelin. Set your token to _mint() 1000 (* 10 ** 18) tokens to the msg.sender. Then create a Vendor.sol contract that sells your token using a payable buyTokens() function.
🎛 Edit the frontend that invites the user to input an amount of tokens they want to buy. We'll display a preview of the amount of ETH it will cost with a confirm button.
🔍 It will be important to verify your token's source code in the block explorer after you deploy. Supporters will want to be sure that it has a fixed supply and you can't just mint more.
🌟 The final deliverable is an app that lets users purchase your ERC20 token, transfer it, and sell it back to the vendor. Deploy your contracts on your public chain of choice and then yarn vercel your app to a public web server. Submit the url on SpeedrunEthereum.com!
💬 Meet other builders working on this challenge and get help in the Challenge Telegram!
Checkpoint 0: 📦 Environment 📚
Start your local network (a blockchain emulator in your computer):
yarn chain
in a second terminal window, 🛰 deploy your contract (locally):
yarn deploy
in a third terminal window, start your 📱 frontend:
yarn start
📱 Open http://localhost:3000 to see the app.
👩💻 Rerun
yarn deploy --resetwhenever you want to deploy new contracts to the frontend, update your current contracts with changes, or re-deploy it to get a fresh contract address.
⚠️ We have disabled AI in Cursor and VSCode and highly suggest that you do not enable it so you can focus on the challenge, do everything by yourself, and hence better understand and remember things. If you are using another IDE, please disable AI yourself.
🔧 If you are a vibe-coder and don't care about understanding the syntax of the code used and just want to understand the general takeaways, you can re-enable AI by:
- Cursor: remove
*from.cursorignorefile - VSCode: set
chat.disableAIFeaturestofalsein.vscode/settings.jsonfile
Checkpoint 1: 🏵Your Token 💵
👩💻 Go to
packages/hardhat/contracts/YourToken.sollook at how this contract is inheriting the ERC20 token standard from OpenZeppelin. This means that theYourTokencontract obtains every method that is a part of the ERC20 standard and so it has all the default properties needed to be a used as a token on Ethereum.
In the
constructor(), mint a fixed supply of 1000 tokens (with 18 decimals) tomsg.sender(the deployer).
Implementing YourToken
- Decide on the token name/symbol (already scaffolded for you as "Gold"/"GLD").
- The OpenZeppelin ERC20 contract you are inheriting exposes a
_mintfunction you can use to create new tokens. - Update the constructor to mint exactly 1000 tokens to
msg.sender(the deployer).
🔎 Hint
ERC20 tokens typically use 18 decimals. Solidity has a convenient unit that matches that scaling:
1000 etheris1000 * 10**18
So minting 1000 tokens can be as simple as:
_mint(msg.sender, 1000 ether);
🎯 Solution
constructor() ERC20("Gold", "GLD") {
_mint(msg.sender, 1000 ether);
}
🥅 Goals
-
⚠️ Important: Your initial token supply was minted to the deployer. If the wallet you use in the frontend is a different address, you won’t see a balance there yet.
- Update
FRONTEND_ADDRESSinpackages/hardhat/deploy/01_deploy_vendor.tsand keepSEND_TOKENS_TO_VENDOR = falsesince we are not ready for that step. - Then run
yarn deploy --resetto send the tokens to your frontend wallet so you can test in the UI.
- Update
-
Can you check the
balanceOf()your frontend address in theDebug Contractstab? (YourTokencontract) -
Can you
transfer()your token to another account and check that account'sbalanceOf?
💬 Hint: Use an incognito window to create a new address and try sending to that new address. Can use the
transfer()function in theDebug Contractstab.
Testing your progress
🔍 Run:
yarn test --grep "Checkpoint1"
Checkpoint 2: ⚖️ Vendor 🤖
👩💻 Edit
packages/hardhat/contracts/Vendor.soland build a token vending machine with a payablebuyTokens()function.
Step 1: Add a price constant
Use a price variable named tokensPerEth set to 100 (meaning 100 tokens per 1 ETH):
uint256 public constant tokensPerEth = 100;
Step 2: Add custom errors
Instead of require(condition, "message"), we’ll use custom errors (they’re cheaper at runtime and easier to test).
In the error section of the contract, add these errors for the common failure cases:
error InvalidEthAmount();
error InsufficientVendorTokenBalance(uint256 available, uint256 required);
Step 3: Add an event
In the event section of the contract, add an event that the UI (and block explorers) can use to track purchases:
event BuyTokens(address indexed buyer, uint256 amountOfETH, uint256 amountOfTokens);
Implementing buyTokens()
The buyTokens() function should:
- Reject a purchase with 0 ETH, reverting with
InvalidEthAmount - Compute how many tokens the buyer should receive using
msg.valueandtokensPerEth - Make sure the Vendor has enough tokens to sell and if they don't revert with
InsufficientVendorTokenBalance - Transfer tokens to the buyer
- Emit the
BuyTokensevent
🔎 Hint
Decimals gotcha (important):
- ETH is measured in wei (18 decimals)
- ERC20 tokens in this challenge also use 18 decimals
If tokensPerEth = 100, then:
- Sending
1 ethershould yield100 ethertoken units
A simple formula that works with 18-decimals tokens:
tokensToBuy = msg.value * tokensPerEth
Also, you can check how many tokens the vendor holds with:
yourToken.balanceOf(address(this))
🎯 Solution
function buyTokens() external payable {
if (msg.value == 0) revert InvalidEthAmount();
uint256 amountOfTokens = msg.value * tokensPerEth;
uint256 vendorBalance = yourToken.balanceOf(address(this));
if (vendorBalance < amountOfTokens) revert InsufficientVendorTokenBalance(vendorBalance, amountOfTokens);
yourToken.transfer(msg.sender, amountOfTokens);
emit BuyTokens(msg.sender, msg.value, amountOfTokens);
}
Try it out (frontend + deploy)
Edit packages/hardhat/deploy/01_deploy_vendor.ts to set SEND_TOKENS_TO_VENDOR to true. This will deploy the Vendor contract and automatically seed it with the tokens INSTEAD of sending the tokens to your FRONTEND_ADDRESS. It will also set your address as the owner of the Vendor contract but we will dig into that later...
🔎 Look in
packages/nextjs/app/token-vendor/page.tsxand uncomment theVendor BalancesandBuy Tokenssections to display the Vendor ETH and Token balances as well as enable buying tokens from the frontend.
You can
yarn deploy --resetto deploy your contract until you get it right.
🥅 Goals
- Does the
Vendoraddress start with abalanceOf1000 inYourTokenon theDebug Contractstab? - Can you buy 10 tokens for 0.1 ETH?
- Can you transfer tokens to a different account?
Testing your progress
🔍 Run:
yarn test --grep "Checkpoint2"
Checkpoint 3: 👑 Ownable + Withdraw 💸
Now that your Vendor can accept ETH via buyTokens(), let’s protect the treasury.
Step 1: Inheriting Ownable (OpenZeppelin v5)
The OpenZeppelin Ownable contract adds special methods, modifiers and state variable for helping to secure certain methods. Notably the onlyOwner modifier can be used to guard a method so that only the owner can call it.
- Notice how the vendor contract already imports
Ownablefrom OpenZeppelin - See how it is inherited in the line defining the contract:
contract Vendor is Ownable ...
- Lastly see how we define ownership in the constructor with
Ownable(msg.sender), making the deployer of the contract theowner
We are using the onlyOwner modifier to protect the withdraw method so that only the owner can withdraw the contract's ETH balance.
Step 2: Add a custom error for failed transfers
First add an error you will need in the method.
error EthTransferFailed(address to, uint256 amount);
Implementing withdraw()
Your withdraw() function should:
- Be restricted to the owner (
onlyOwner) - Send all ETH in the Vendor to the owner
- Revert with
EthTransferFailedif the ETH transfer fails
🔎 Hint
Avoid transfer() (it can unexpectedly fail due to gas changes and is no longer supported in the latest Solidity versions). Prefer call:
(bool ok,) = owner().call{value: amount}("");
If ok is false, revert.
🎯 Solution
function withdraw() external onlyOwner {
uint256 amount = address(this).balance;
(bool success,) = owner().call{value: amount}("");
if (!success) revert EthTransferFailed(owner(), amount);
}
Try it out
Deploy the updated contract with yarn deploy --reset and then go test it out by depositing ETH and withdrawing. You can do this from the Debug Contracts tab.
🥅 Goals
- Is your frontend address the
ownerof theVendor? - Can your address successfully withdraw all the ETH in the
Vendor?
Testing your progress
🔍 Run:
yarn test --grep "Checkpoint3"
Checkpoint 4: 🤔 Vendor Buyback 🤯
👩🏫 The hardest part of this challenge is to build your Vendor in such a way so that it can buy the tokens back.
🧐 The reason why this is hard is the approve() pattern in ERC20s.
😕 First, the user has to call approve() on the YourToken contract, approving the Vendor contract address to take some amount of tokens.
🤨 Then, the user makes a second transaction to the Vendor contract to sellTokens(uint256 amount).
🤓 The Vendor should call yourToken.transferFrom(msg.sender, address(this), theAmount) and if the user has approved the Vendor correctly, tokens should transfer to the Vendor and ETH should be sent to the user.
🤔 But why do we need the approve method?
The crux of the issue is this: if smart contracts can move tokens out of your wallet, how do you make sure that only the smart contract you want to take tokens is the one that’s allowed to do it?
Here’s the simple mental model:
-
approve(spender, amount)= “I allow this contract to spend up to X of my tokens.”- It does not move tokens.
- It writes an allowance into the token contract:
allowance[you][spender] = amount.
-
transferFrom(from, to, amount)= “Use that permission to pull tokens.”- The
Vendorcontract calls this duringsellTokens(...). - The token contract checks the allowance and only lets the transfer happen if it’s big enough.
- The
What this unlocks: safe, pull-based token interactions where a contract can perform an action (swap, buyback, marketplace purchase, subscription, etc.) and pull exactly the tokens it needs, without having blanket access to your wallet. You can also limit risk by approving only the exact amount (or revoke later by approving 0).
Luckily, wallet UX is improving fast. With proposals like EIP-7702 now being enabled on Ethereum, a wallet can let you sign one “sell” action that executes a small bundle of steps atomically (e.g. approve + sellTokens / transferFrom) in a single transaction, instead of making you click through two separate user actions. The underlying ERC-20 allowance model still exists; you’re just authorizing a smarter, batched execution path. This only needs to be adopted by wallets and frontends for users to reap the benefits.
Step 1: Add custom errors + event
error InvalidTokenAmount();
error InsufficientVendorEthBalance(uint256 available, uint256 required);
event SellTokens(address indexed seller, uint256 amountOfTokens, uint256 amountOfETH);
Implementing sellTokens(uint256 amount)
Your sellTokens(amount) should:
- Reject
amount == 0withInvalidTokenAmount - Pull tokens from the user with
transferFrom(requires priorapprovecall by user) - Compute the ETH to return using the inverse of your pricing
- Ensure the Vendor has enough ETH liquidity and if not, revert with
InsufficientVendorEthBalance - Send ETH back to the user
- Emit a
SellTokensevent
🔎 Hint
If tokensPerEth = 100, then the inverse conversion is:
ethToReturn = amount / tokensPerEth
🎯 Solution
function sellTokens(uint256 amount) external {
if (amount == 0) revert InvalidTokenAmount();
uint256 amountOfETH = amount / tokensPerEth;
uint256 vendorEthBalance = address(this).balance;
if (vendorEthBalance < amountOfETH) revert InsufficientVendorEthBalance(vendorEthBalance, amountOfETH);
yourToken.transferFrom(msg.sender, address(this), amount);
(bool success,) = msg.sender.call{value: amountOfETH}("");
if (!success) revert EthTransferFailed(msg.sender, amountOfETH);
emit SellTokens(msg.sender, amount, amountOfETH);
}
Try it out
🔁 Redeploy (yarn deploy --reset) and try out your new function!
🔨 Use the Debug Contracts tab to call the approve and sellTokens() at first but then...
🔍 Look in the packages/nextjs/app/token-vendor/page.tsx for the extra approve/sell UI to uncomment and then go to packages/nextjs/app/events/page.tsx and uncomment the SellTokens Events section to update the Events tab on the frontend.
🥅 Goal
- Can you sell tokens back to the vendor?
- Do you receive the right amount of ETH for the tokens?
- Do you see
SellTokensevents in theEventstab now?
⚔️ Side Quests
- Should we disable the
ownerwithdraw to keep liquidity in theVendor? - Would people be more interested in your token if they knew there wasn't a way to drain the ETH backing?
Testing your progress
🔍 Run:
yarn test --grep "Checkpoint4"
Checkpoint 5: 💾 Deploy your contracts! 🛰
📡 Edit the defaultNetwork in hardhat.config.ts to match the name of one of testnets from the networks object. We recommend to use "sepolia" or "optimismSepolia"
🔐 You will need to generate a deployer address using yarn generate This creates a mnemonic and saves it locally.
👩🚀 Use yarn account to view your deployer account balances.
⛽️ You will need to send ETH to your deployer address with your wallet, or get it from a public faucet of your chosen network. You can also request ETH by sending a message with your new deployer address and preferred network in the challenge Telegram. People are usually more than willing to share.
🚀 Run yarn deploy to deploy your smart contract to a public network (selected in hardhat.config.ts)
💬 Hint: Instead of editing
hardhat.config.tsyou can just add a network flag to the deploy command like this:yarn deploy --network sepoliaoryarn deploy --network optimismSepolia
Checkpoint 6: 🚢 Ship your frontend! 🚁
✏️ Edit your frontend config in packages/nextjs/scaffold.config.ts to change the targetNetwork to chains.sepolia (or chains.optimismSepolia if you deployed to OP Sepolia)
💻 View your frontend at http://localhost:3000 and verify you see the correct network.
📡 When you are ready to ship the frontend app...
📦 Run yarn vercel to package up your frontend and deploy.
You might need to log in to Vercel first by running
yarn vercel:login. Once you log in (email, GitHub, etc), the default options should work.
If you want to redeploy to the same production URL you can run
yarn vercel --prod. If you omit the--prodflag it will deploy it to a preview/test URL.
Follow the steps to deploy to Vercel. It'll give you a public URL.
🦊 Since we have deployed to a public testnet, you will now need to connect using a wallet you own or use a burner wallet. By default 🔥
burner walletsare only available onhardhat. You can enable them on every chain by settingonlyLocalBurnerWallet: falsein your frontend config (scaffold.config.tsinpackages/nextjs/)
Configuration of Third-Party Services for Production-Grade Apps.
By default, 🏗 Scaffold-ETH 2 provides predefined API keys for popular services such as Alchemy and Etherscan. This allows you to begin developing and testing your applications more easily, avoiding the need to register for these services. This is great to complete your Speedrun Ethereum.
For production-grade applications, it's recommended to obtain your own API keys (to prevent rate limiting issues). You can configure these at:
-
🔷
ALCHEMY_API_KEYvariable inpackages/hardhat/.envandpackages/nextjs/.env.local. You can create API keys from the Alchemy dashboard. -
📃
ETHERSCAN_API_KEYvariable inpackages/hardhat/.envwith your generated API key. You can get your key here.
💬 Hint: It's recommended to store env's for nextjs in Vercel/system env config for live apps and use .env.local for local testing.
Checkpoint 7: 📜 Contract Verification
Run the yarn verify --network your_network command to verify your contracts on etherscan 🛰
👀 You may see an address for both YourToken and Vendor. You will want the Vendor address.
👉 Search this address on Sepolia Etherscan (or Optimism Sepolia Etherscan if you deployed to OP Sepolia) to get the URL you submit to 🏃♀️SpeedrunEthereum.com.
🏃 Head to your next challenge here.
💬 Problems, questions, comments on the stack? Post them to the 🏗 scaffold-eth developers chat
Documentation
Visit our docs to learn how to start building with Scaffold-ETH 2.
To know more about its features, check out our website.
Contributing to Scaffold-ETH 2
We welcome contributions to Scaffold-ETH 2!
Please see CONTRIBUTING.MD for more information and guidelines for contributing to Scaffold-ETH 2.
