How to build a "Tip Jar" DeFi app
Learn how to build a TipJar app that creators can use to receive tips from their supporters.
In this guide, you will learn how to build a TipJar app that creators can use to receive tips from their supporters.
The TipJar app will have the following functionality:
- Users can deposit ETH to tip the creator who owns the app.
- Users can see how much ETH is in their wallet.
- Users can see how much ETH is in the TipJar.
Here is what the app will look like when you're finished.
The app will be built using Next.js, ConnectKit, Wagmi and Viem.
This guide is divided into the following sections:
- Prework: Gather the tools you’ll need to build the app
- Step 1: Set up the starter template
- Step 2: Build a static version of the app
- Step 3: Add interactivity
- Step 4: Add on-chain transaction functionality
- Wrapping Up
Prework
In this guide, you will use:
- CodeSandbox: CodeSandbox makes it easy to develop and deploy applications in your browser without installing anything locally.
- Alchemy: Alchemy APIs allow you to fetch blockchain data and execute transactions.
- WalletConnect: WalletConnect allows your application to connect to hundreds of wallet apps.
- TipJar Contract: This contract will accept tips from supporters and allow its owner to withdraw those tips.
In this section, you will set up these tools.
Alchemy Setup
-
Create an Alchemy account: Sign up here
-
Log in to your Alchemy Dashboard.
-
Navigate to the Apps page and click Create App. When prompted, pick Ethereum as the Chain and Sepolia as the Network.
-
Once the app is created, click into it and note down the API key. You’ll need this for the next steps in this guide.
WalletConnect Cloud Setup
-
Create a WalletConnect account: Sign up here
-
In your WalletConnect dashboard, select New Project and give the project a name.
-
Once the project is created, note down the Project ID. You will need this for the next steps in the guide.
CodeSandbox Setup
Create a CodeSandbox Account: Sign up here
You will see a prompt to create a team. For this tutorial, it doesn't matter whether you make a team or use a personal account.
TipJar Contract
The TipJar contract has two key functions:
- A
deposit
function that allows users to deposit a tip into the contract. - A
withdraw
function that only the creator, who owns the contract, can call to withdraw all of the tips.
To call these functions, users have to make on-chain transactions.
The app you build in this guide will use the Sepolia testnet, so you will need Sepolia ETH to fund transactions. To get Sepolia ETH:
- Sign in to your Alchemy account.
- Open the Sepolia ETH faucet at sepoliafaucet.com.
- Enter the address of the wallet you want to use for development (use a different wallet from your personal one) and click "Send me ETH".
- Congratulations, you now have 0.5 Sepolia ETH to use for development!
You can build the app using a TipJar contract that has already been deployed on Sepolia testnet: Check it out here and note down the address.
You will need that address later in this tutorial.
Alternatively, you can deploy a new Tipjar contract using the instructions below and then note the address of that contract.
Remix IDE - Link
Contract Github Gist - Link
Step 1: Set up the starter template
In this tutorial, you will be given a Next.js template to use as a starting point when building your app. Next.js is a React framework for building web applications.
In this section, we will explore how the starter code works. Then you will fork the template on CodeSandbox and run it.
There are two critical packages used in the starter template:
- Wagmi is a collection of React Hooks that make it easy to fetch on-chain data, sign messages and interact with contracts.
- ConnectKit is a React component library for adding Connect Wallet functionality to your app.
The starter template has four key files in it. Here is an explanation of how they work:
_app.tsx
_app.tsx
This is a special file you can use to inject additional data into your website's pages or create a shared layout.
In this template’s _app.tsx
file, all rendered pages are wrapped with WagmiConfig
and ConnectKitProvider
to enable the use of Wagmi and ConnectKit. Also, Wagmi is configured for the Sepolia testnet.
_app.tsx
can be found in the pages
folder of the template. In Next.js, each file in the pages
folder represents a different page or route on your website, except for _app.tsx
. For example, a file named about.tsx
would correspond to your /about
page.
Read the code to gain a deeper understanding of how _app.tsx
works.
import '../styles/globals.css'
import type { AppProps } from 'next/app'
import { sepolia } from "wagmi/chains";
import { WagmiConfig, createConfig } from "wagmi";
import { ConnectKitProvider, ConnectKitButton, getDefaultConfig } from "connectkit";
const alchemyId = process.env.ALCHEMY_API_KEY;
const walletConnectProjectId = process.env.WALLETCONNECT_PROJECT_ID;
const chains = [sepolia];
const config = createConfig(
getDefaultConfig({
alchemyId,
walletConnectProjectId: walletConnectProjectId || "default",
chains,
appName: "TipJar",
}),
);
function MyApp({ Component, pageProps }: AppProps) {
return (
<WagmiConfig config={config}>
<ConnectKitProvider>
<Component {...pageProps} />
</ConnectKitProvider>
</WagmiConfig>
)
}
export default MyApp
index.tsx
index.tsx
This file contains the main logic of your app. There are three main React components in the index.tsx
starter file:
ConnectBlock
: This contains theConnectKitButton
imported from the ConnectKit library. Users connect their wallets to the app using this button.TitleBlock
: A component that returns a title and subtitle.MainBlock
: Wraps all the other components.
In React, a component is a reusable piece of code that represents a part of a user interface.
index.tsx
is located in the template’s pages
folder. It is the root of your directory and will be the first page to load when the site loads.
Read the code to gain a deeper understanding of how index.tsx
works.
import type { NextPage } from "next";
import Head from "next/head";
import styles from "../styles/Home.module.css";
import { ConnectKitButton } from "connectkit";
function MainBlock() {
return (
<main className={styles.main}>
<ConnectBlock />
<TitleBlock />
</main>
);
}
function ConnectBlock() {
return (
<div className={styles.connectDiv}>
<ConnectKitButton />
</div>
);
}
function TitleBlock() {
return (
<div className={styles.titleDiv}>
<h1 className={styles.title}>TipJar</h1>
<p className={styles.description}>Toss a coin to the witcher ✨</p>
</div>
);
}
const Home: NextPage = () => {
return (
<div className={styles.container}>
<Head>
<title>TipJar</title>
<meta name="description" content="Tipping app" />
<link rel="icon" href="/favicon.ico" />
</Head>
<MainBlock />
</div>
);
};
export default Home;
Home.module.css
Home.module.css
This file provides all the styling for index.jsx
. Feel free to modify the CSS in Home.module.css
to match your style preferences.
next.config.js
next.config.js
This is a special file used to configure NextJS settings. This fallback statement instructs Next to ignore certain modules when building your app. This is necessary to prevent a common bug that pops up when using ConnectKit or RainbowKit with Wagmi.
module.exports = {
webpack: (config) => {
config.resolve.fallback = { fs: false, net: false, tls: false }
return config
},
}
Action: Fork the starter template on CodeSandbox and run it ⚡
(1) Open the starter template on CodeSandbox: Use this link
(2) Click the Fork button located in the upper right corner to create a copy of the starter template that you own.
In your CodeSandbox project you will see 3 main sections:
- The Files section in the left-hand panel with a list of folders and files you can open.
- The Code Editor where you’ll see the source code of your selected file in the middle-panel.
- The Browser section in the right-hand panel where you’ll see how the code you’ve written will be displayed.
(3) After your fork is made, open the Menu using the square button in the top left corner of the page and open Project Settings.
(4) Create environment variables called ALCHEMY_API_KEY
and WALLETCONNECT_PROJECT_ID
that contain the Alchemy key and WalletConnect ID you noted down earlier. Then restart the project to implement those changes.
(5) Test the template app by connecting your wallet.
Step 2: Design a static version of the app
Here is a basic sketch of what your final product should look like. Imagine that you got it from a designer on your team.
You should break down this UI into its components. You can start by making a component for each task the UI has to perform, for example, a component to display wallet balances. Then you can organize the app’s design into layers by wrapping groups of components in parent components.
This is just one way to do it; you could split up the components using any preferred method.
This example is composed of 8 components:
MainBlock
: contains and sets the layout style for the entire appConnectBlock
: contains and styles theConnectButton
TitleBlock
: contains and styles the title and subtitleTipBlock
: containsBalanceBlock
andTipModal
BalanceBlock
: contains theBalanceCard
components that display the user's and contract's ETH balanceTipModal
: executes the tip transaction
Once the components have been identified, arrange them hierarchically. Components that appear within another component should be listed as children in the hierarchy.
MainBlock
ConnectBlock
ConnectButton
TitleBlock
TipBlock
BalanceBlock
BalanceCard
TipModal
Some of these components are already provided in the starter template. You will have to implement the rest of them.
Step 3: Build a static version of the app
Now that you have your component hierarchy, build a prototype that renders the UI with example data, but isn't interactive. It's usually easier to build a static version of your app first and add interactivity later.
Action: Add new components to index.js
⚡
index.js
⚡(1) Create the TipBlock
and its subcomponents by copy-pasting these changes into index.jsx
file:
// -- import packages same as before
function MainBlock() {
return (
<main className={styles.main}>
// -- `ConnectBlock` and `TitleBlock` same as before
<TipBlock />
</main>
);
}
// -- `ConnectBlock` and `TitleBlock` same as before
function TipBlock() {
const address = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045";
const isConnected = false;
const userBalance = "0";
const userIsError = false;
const userIsLoading = false;
return (
<div className={styles.tipDiv}>
<BalanceBlock
userIsConnected={isConnected}
userBalance={userBalance}
userIsError={userIsError}
userIsLoading={userIsLoading}
/>
<TipModal isConnected={isConnected} userBalance={userBalance} />
</div>
);
}
interface BalanceBlockProps {
userIsConnected: boolean;
userBalance: string;
userIsError: boolean;
userIsLoading: boolean;
}
function BalanceBlock({
userIsConnected,
userBalance,
userIsError,
userIsLoading,
}: BalanceBlockProps) {
const contractBalance = "0";
const contractIsError = false;
const contractIsLoading = false;
return (
<div className={styles.balanceDiv}>
<BalanceCard
title={"Your Balance"}
balance={userBalance}
isConnected={userIsConnected}
isError={userIsError}
isLoading={userIsLoading}
/>
<BalanceCard
title={"Tipjar Balance"}
balance={contractBalance}
isConnected={true}
isError={contractIsError}
isLoading={contractIsLoading}
/>
</div>
);
}
interface BalanceCardProps {
title: string;
balance: string;
isConnected: boolean;
isError: boolean;
isLoading: boolean;
}
function BalanceCard({
title,
balance,
isConnected,
isError,
isLoading,
}: BalanceCardProps) {
return (
<div className={styles.balanceCard}>
<h2 className={styles.balanceHeader}>{title}</h2>
<p>
{!isConnected
? "0 ETH"
: isLoading
? "Fetching balance…"
: isError
? "Error fetching balance"
: `${balance} ETH`}
</p>
</div>
);
}
interface TipModalProps {
isConnected: boolean;
userBalance: string;
}
function TipModal({ isConnected, userBalance }: TipModalProps) {
const inputValue = "";
const isNotNumber = isNaN(Number(inputValue));
function handleClick() {
console.log("clicked");
}
return (
<div className={styles.modal}>
<h4 className={styles.modalHeader}> Enter tip amount in ETH </h4>
<input type="text" value={inputValue}></input>
<button
onClick={handleClick}
className={styles.buttonmodal}
disabled={
inputValue === "" ||
isNotNumber ||
!isConnected ||
Number(inputValue) > Number(userBalance)
}
>
Send Tip
</button>
</div>
);
}
// const Home: NextPage = () => {
// -- same as before
(2) Save the changes you made using CNTRL + S (Windows) or CMD + S (Mac). The page should look like this now.
Recommendations
Here are some tips for writing components on your own:
- You can either build "top-down" by starting with higher-level components (like
MainBlock
), or "bottom-up" by working on lower-level components first (likeBalanceCard
). It's easier to go top-down in simpler projects, but on larger projects, it's easier to go bottom-up. - Use props to pass data down the hierarchy from parent components to their children. For example, in this project
TipBlock
passes down theuserBalance
toBalanceBlock
. Avoid repeating the same data in multiple components. If more than one component uses a data point, retrieve it once in their shared parent and pass it down using props. If there isn't a shared parent, create one. If you're unfamiliar with props, check out the React Quick Start guide.
Step 4: Add interactivity
To make the UI interactive, you need to allow users to modify the underlying data model. To achieve this, you will use state.
Think of state as the smallest set of changing data that your application needs to remember.
Your Tipjar app utilizes the following data points:
- The user's wallet connection status
- The user's wallet address
- The user's ETH balance
- The Tipjar contract's balance
- The user's input tip amount
These data points can be described as state due to the following reasons:
- They change over time
- When they change, your UI components should re-render to reflect the changes
- They cannot be calculated within the app using existing state or props.
you don't want to repeat the same data in multiple places. Each piece of state should be owned by one component and passed down to any child components that need it using props.
The user’s wallet address, balance and connection status are needed by
BalanceBlock
to display the user’s balanceTipModal
to enable tipping when the user is connected
You can access that data using the useAccount and [useBalance](https://wagmi.sh/react/hooks/useBalance)
Wagmi hooks. Their common parent is TipBlock
, so that component will retrieve the state and pass it down.
Action: Replace the static data in TipBlock
with state ⚡
TipBlock
with state ⚡(1) Delete the TipBlock component and replace it by copy-pasting the new TipBlock code below. This new version of TipBlock adds state data for the user’s address, balance and connection status.
Note that the ‘watch’ parameter of useBalance
is set to true so that the balance is automatically updated every block.
// -- import packages
import { useAccount, useBalance } from "wagmi";
// -- build components
function TipBlock() {
const { address, isConnected } = useAccount();
const {
data: userData,
isError: userIsError,
isLoading: userIsLoading,
} = useBalance({
address: address,
watch: true,
});
const userBalance= parseFloat(userData?.formatted || "0")?.toFixed(4);
return (
<div className={styles.tipDiv}>
<BalanceBlock
userIsConnected={isConnected}
userBalance={userBalance}
userIsError={userIsError}
userIsLoading={userIsLoading}
/>
<TipModal isConnected={isConnected} userBalance={userBalance} />
</div>
);
}
// -- build components
(2) Save the changes. Now when you connect your wallet, you should be able to see your Sepolia ETH balance in the UI.
If you reload the page, you will encounter an error that looks like this. This Hydration error is a common problem when using Wagmi with NextJS.
(3) To fix the Hydration error, create a useIsMounted
hook to ensure that certain content is only rendered after hydration. Apply that new hook to TipBlock
. Copy-paste the following code to make that change.
// -- import packages
import { useState, useEffect } from 'react';
export const useIsMounted = () => {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
return mounted;
};
// -- build components
function TipBlock() {
const isMounted = useIsMounted();
// -- retrieve the balance and account information
if (!isMounted) return null;
return (
// -- return the JSX
);
}
// -- build components
(4) Save the changes. Now if you reload the page, you won't see any errors.
(5) Add the state for the Tipjar contract’s ETH balance by copy-pasting these changes to BalanceBlock.
As before, you use the useIsMounted
hook to prevent hydration errors.
// -- same as before
function BalanceBlock({
userIsConnected,
userBalance,
userIsError,
userIsLoading,
}: BalanceBlockProps) {
const isMounted = useIsMounted();
const {
data: contractData,
isError: contractIsError,
isLoading: contractIsLoading,
} = useBalance({
address: "0x728DDDe0aB16B59df95a4D43d9692a44b7AEe944",
watch: true,
});
const contractBalance = parseFloat(contractData?.formatted || "0")?.toFixed(4);
if (!isMounted) return null;
return (
// -- same as before
);
}
// -- same as before
Now the UI should display the balance of the TipJar contract.
(6) Add state for the user-entered tip by deleting TipModal and copy-pasting the new TipModal code below. To store the current value of the tip amount in state and change it when the user types in a new amount, you will use the useState hook.
// -- same as before
function TipModal({ isConnected, userBalance }: TipModalProps) {
const [inputValue, setInputValue] = useState("");
const handleInputChange = (event) => {
// Update the input value state when the input changes
setInputValue(event.target.value);
};
const isNotNumber = isNaN(Number(inputValue));
function handleClick() {
console.log("clicked");
}
return (
<div className={styles.modal}>
<h4 className={styles.modalHeader}> Enter tip amount in ETH </h4>
<input
type="text"
value={inputValue}
onChange={handleInputChange}
></input>
<button
onClick={handleClick}
className={styles.buttonmodal}
disabled={
inputValue === "" ||
isNotNumber ||
!isConnected ||
Number(inputValue) > Number(userBalance)
}
>
Send Tip
</button>
{Number(inputValue) > Number(userBalance) && (
<div>You dont have enough ETH...</div>
)}
</div>
);
}
// -- same as before
(4) Save your work. Then try entering a tip amount into the UI. When you enter an amount the button should become clickable. If you try to enter a tip amount higher than your balance, you'll see a warning message.
💡 If you get stuck at this stage, compare your work to this template to see where you went wrong: Link
If you fork this template, you will need to make the ALCHEMY_API_KEY
and WALLETCONNECT_PROJECT_ID
environment variables same as you did before.
Step 5: Add transaction functionality
Your app currently displays data from the blockchain, but it needs to be able to make transactions as well. To accomplish this, you can use the useContractWrite and usePrepareContractWrite hooks from Wagmi.
These hooks allow you to call a "write" function on a smart contract. Write functions change the state of the blockchain and require a transaction to be executed. usePrepareContractWrite
validates whether a transaction is viable before the user interacts with the button. This saves time between the user clicking and the wallet popping up. useContractWrite
sends the transaction.
For this project, the app needs to call the deposit
function on the Tipjar contract so that the user can deposit their tip.
Action: Update TipModal
to enable transactions ⚡
TipModal
to enable transactions ⚡(1) The first step is to obtain the ABI (Application Binary Interface) of the smart contract. Your app will use this information to correctly encode function calls to send to the contract and to decode the data that comes back.
To get the ABI, open the page for the contract on Sepolia Etherscan. If you're using the pre-deployed example, search for 0x728DDDe0aB16B59df95a4D43d9692a44b7AEe944. If you're using a newly deployed Tipjar, search for its address. Click on the Contract tab, scroll down to the Contract ABI, and copy it.
(2) Then make a new file in the pages
folder called abi.ts
and copy the ABI into it. To make a new file, right click on the pages
folder in the file explorer and click the New File option.
export const tipjarConfig = {
// note: If you deployed your own tipjar contract use its address instead
address: '0x728DDDe0aB16B59df95a4D43d9692a44b7AEe944',
abi: -- PASTE THE ABI HERE
} as const
(3) Import abi.ts
into your index.tsx
file
// -- import packages as before
import { tipjarConfig } from './abi'
// -- build components as before
(4) Import these packages
import {
useContractWrite,
usePrepareContractWrite
} from "wagmi";
import { parseEther } from 'viem'
(4) Delete the TipModal component and replace it with the new TipModal code below. This update:
- Configures
usePrepareContractWrite
with the contract address and name of the contract function you want to call (thedeposit
function in this case). - Replaces the
handleClick()
function used by the tip button with thewrite
function generated byuseContractWrite
hook. - Modifies the tip button so it is disabled when a contract call is in progress.
- Resets the input field after a successful transaction using the
useEffect
hook. This hook runs every time theisSuccess
variable changes.
// -- same as before
function TipModal({ isConnected, userBalance }: TipModalProps) {
const [inputValue, setInputValue] = useState("");
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
// Update the input value state when the input changes
setInputValue(event.target.value);
};
const isNotNumber = isNaN(Number(inputValue));
// If you have deployed a contract, use your own contract address!
const { config } = usePrepareContractWrite({
address: "0x728DDDe0aB16B59df95a4D43d9692a44b7AEe944",
abi: tipjarConfig.abi,
functionName: "deposit",
value: parseEther(inputValue),
});
const { data, isLoading, isSuccess, write } = useContractWrite(config);
return (
<div className={styles.tipmodal}>
<div className={styles.modal}>
<h4 className={styles.modalHeader}> Enter tip amount in ETH </h4>
<input
type="text"
value={inputValue}
onChange={handleInputChange}
></input>
<button
onClick={() => write?.()}
className={styles.buttonmodal}
disabled={
!write ||
isLoading ||
inputValue === "" ||
isNotNumber ||
!isConnected ||
Number(inputValue) > Number(userBalance)
}
>
Send Tip
</button>
{Number(inputValue) > Number(userBalance) && (
<div>You dont have enough ETH...</div>
)}
{isLoading && <div>Check Wallet</div>}
</div>
{isSuccess && <div>Transaction: {JSON.stringify(data)}</div>}
</div>
);
}
// -- same as before
(5) Run the project and try depositing a tip to see the complete flow!
💡 If you get stuck at this stage, compare your work to this template to see where you went wrong: Link
If you fork this template, you will need to make the ALCHEMY_API_KEY
and WALLETCONNECT_PROJECT_ID
environment variables same as you did before.
Wrapping Up
Congratulations! You have successfully built a frontend for a DeFi app that:
- Allows users to connect their wallets.
- Retrieves and displays on-chain data.
- Enables users to execute transactions with a smart contract.
Great job! 🥳🥳🥳
Bonus: Deploy your app
If you want to turn this code into a live website that anyone can visit, follow these steps:
(1) Sign up for Github.
(2) Sign up for Vercel.
(3) In your CodeSandbox, navigate to the Git tool in the left-hand panel. Create a Github repository from your CodeSandbox project.
(4) In Vercel, create a new Project and import the GitHub repository you just made.
(5) Your site is now live on the internet 🚀
Updated about 1 year ago