Building a Habit Tracker dApp on Rootstock: A Beginner’s Guide to Smart Contracts and Blockchain


Hey there, blockchain enthusiasts! Ever tried to build a new habit, say, hitting the gym every day or reading a book a week, only to fall off the wagon after a few days? You’re not alone. Habit formation is tough, but what if there were a way to make it stick with a little extra motivation? Enter the Habit Tracker with Stakes dApp, a decentralized application that not only tracks your progress but also puts your crypto where your goals are. Miss a day? You lose your stake. Crush your goal? You get rewarded. It’s like having a personal accountability partner on the blockchain.
In this guide, we’ll build this dApp from scratch on Rootstock (RSK), a Bitcoin sidechain that combines Ethereum’s smart contract flexibility with Bitcoin’s rock-solid security. With Rootstock’s low fees and merge-mined protection, it’s the perfect platform for a project that incentivizes real behavior change. Whether you’re new to blockchain or looking to level up your skills, this project will teach you how to create a smart contract with staking mechanics, build a sleek React frontend, and deploy it all to the Rootstock Testnet.
So, grab a coffee, fire up your code editor, and let’s turn those good intentions into unstoppable habits!
What’s a Habit Tracker with Stakes?
Imagine this: you decide to read for 30 minutes every day for a month. To keep yourself accountable, you stake some cryptocurrency, say, 0.01 RBTC. Each day you check in, proving you’ve done the work. If you hit your goal (let’s say, 80% of the days), you get your stake back plus a bonus from a community reward pool. Missed too many days? Your stake goes to the pool, funding future rewards. It’s a win-win: you build better habits, and the community grows stronger.
On Rootstock, this dApp shines because:
Bitcoin-Backed Security: Your stakes are protected by Bitcoin’s massive hash power.
Low Fees: Daily check-ins won’t cost you a fortune (fees are ~0.0001 RBTC).
EVM Compatibility: You can use familiar tools like Solidity and Hardhat.
It’s not just about habits, it’s about building trust and transparency into self-improvement.
Why Build This on Rootstock?
Rootstock (RSK) is a Bitcoin sidechain that lets you create smart contracts with the best of both worlds: Ethereum’s flexibility and Bitcoin’s security. Here’s why it’s perfect for our Habit Tracker:
Merge-Mined Security: Rootstock is secured by Bitcoin’s hash rate (over 500 exahashes/second), making it one of the safest platforms.
Low Transaction Fees: With fees around 0.0001 RBTC, users can check in daily without breaking the bank.
EVM Compatibility: Use Solidity and Ethereum tools, but with Bitcoin’s backing.
Growing Ecosystem: Rootstock’s DeFi ecosystem, worth $6.6 billion (February 2025), is ripe for innovative dApps.
Whether you’re building for wellness, productivity, or just for fun, Rootstock gives you the tools to make it secure and scalable.
What You’ll Learn
By the end of this guide, you’ll know how to:
Write a Solidity smart contract with staking, time-based logic, and reward distribution.
Build a React frontend that interacts with the blockchain using ethers.js.
Deploy your dApp to the Rootstock Testnet.
We’ll keep it beginner-friendly but packed with depth. Let’s dive in!
The Smart Contract: Your Habit’s Backbone
The smart contract is the heart of our dApp, handling everything from staking to check-ins to rewards. We’ll write it in Solidity, optimize it for gas, and ensure it’s secure.
Contract Features
Before we jump into the frontend, let’s peek under the hood at the smart contract that powers our time capsules. It’s written in Solidity and packed with features like time-locking, privacy controls, and text attachments. But don’t worry, it’s simpler than it sounds.
Quickly, before looking into the contract, let’s set up our project
mkdir habit-tracker-dapp
cd habit-tracker-dapp
mkdir smart-contract
cd smart-contract
npx hardhat init
And yes(enter) all the way through, i.e., selecting a JavaScript project, and yes to other options.
Then, let’s install the required dependencies for this project, @openzeppelin/contracts
and dotenv
. Run the commands
npm i @openzeppelin/contracts
npm i --save-dev dotenv
The Contract’s Superpowers
Habit Creation: Users create habits with a duration (7-365 days) and daily stake.
Daily Check-Ins: Users check in daily to confirm they’ve completed their habit.
Success Calculation: If users meet 80% of their check-ins, they get their stake back plus a bonus.
Reward Pool: Missed stakes fund a community pool for future bonuses.
Streak Bonuses: Consecutive check-ins earn extra rewards.
Admin Withdrawal: An admin can withdraw excess pool funds (for sustainability).
Here’s the full smart contract. Create a new file. contracts/HabitTracker.sol
:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
contract HabitTracker {
address public admin;
struct Habit {
address user;
bool active;
bool isCompleted;
string title;
uint256 startTime;
uint256 durationDays;
uint256 dailyStake;
uint256 checkIns;
uint256 streak;
uint256 lastCheckIn;
uint256 completedAt;
}
mapping(address => Habit[]) public habitsByUser;
uint256 public totalPool;
uint256 public constant STREAK_BONUS = 0.001 ether; // Bonus for streaks > 3 days
uint256 public constant MIN_DURATION = 7;
uint256 public constant MAX_DURATION = 365;
uint256 public constant SUCCESS_THRESHOLD = 80; // 80% check-ins required
event HabitCreated(
address indexed user,
string title,
uint256 habitId,
uint256 durationDays,
uint256 dailyStake
);
event CheckedIn(
address indexed user,
uint256 habitId,
uint256 day,
uint256 streak
);
event HabitCompleted(
address indexed user,
uint256 habitId,
bool success,
uint256 reward
);
event PoolWithdrawn(address indexed admin, uint256 amount);
modifier onlyAdmin() {
require(msg.sender == admin, "Only admin can call this function");
_;
}
constructor() {
admin = msg.sender;
}
function createHabit(
uint256 _durationDays,
uint256 _dailyStake,
string memory _title
) external payable {
require(
_durationDays >= MIN_DURATION && _durationDays <= MAX_DURATION,
"Duration must be 7-365 days"
);
require(bytes(_title).length > 0, "Title cannot be empty");
require(_dailyStake > 0, "Stake must be greater than 0");
uint256 totalStake = _dailyStake * _durationDays;
require(msg.value == totalStake, "Incorrect stake amount");
uint256 habitId = habitsByUser[msg.sender].length;
habitsByUser[msg.sender].push(
Habit({
user: msg.sender,
title: _title,
startTime: block.timestamp,
durationDays: _durationDays,
dailyStake: _dailyStake,
checkIns: 0,
streak: 0,
active: true,
isCompleted: false,
lastCheckIn: 0,
completedAt: 0
})
);
emit HabitCreated(
msg.sender,
_title,
habitId,
_durationDays,
_dailyStake
);
}
function checkIn(uint256 _habitId) external {
Habit storage habit = habitsByUser[msg.sender][_habitId];
require(habit.active, "Habit not active");
uint256 day = (block.timestamp - habit.startTime) / 1 days;
require(day < habit.durationDays, "Habit period ended");
require(
block.timestamp > habit.lastCheckIn + 1 days - 2 hours,
"Already checked in today"
); // 2-hour buffer
habit.checkIns += 1;
if (block.timestamp <= habit.lastCheckIn + 1 days + 2 hours) {
habit.streak += 1;
} else {
habit.streak = 1;
}
habit.lastCheckIn = block.timestamp;
emit CheckedIn(msg.sender, _habitId, day, habit.streak);
}
function finalizeHabit(uint256 _habitId) external {
Habit storage habit = habitsByUser[msg.sender][_habitId];
require(habit.active, "Habit not active");
require(!habit.isCompleted, "Habit already completed");
require(
block.timestamp >= habit.startTime + habit.durationDays * 1 days,
"Habit period not ended"
);
habit.active = false;
habit.isCompleted = true;
habit.completedAt = block.timestamp;
uint256 requiredCheckIns = (habit.durationDays * SUCCESS_THRESHOLD) /
100;
uint256 totalStake = habit.dailyStake * habit.durationDays;
uint256 reward = 0;
if (habit.checkIns >= requiredCheckIns) {
reward = totalStake;
if (habit.streak >= 3 && totalPool >= STREAK_BONUS) {
reward += STREAK_BONUS;
totalPool -= STREAK_BONUS;
}
payable(msg.sender).transfer(reward);
emit HabitCompleted(msg.sender, _habitId, true, reward);
} else {
totalPool += totalStake;
emit HabitCompleted(msg.sender, _habitId, false, 0);
}
}
function withdrawPool(uint256 _amount) external onlyAdmin {
require(_amount <= totalPool, "Insufficient pool balance");
totalPool -= _amount;
payable(admin).transfer(_amount);
emit PoolWithdrawn(admin, _amount);
}
function getHabit(
address _user,
uint256 _habitId
) external view returns (Habit memory) {
return habitsByUser[_user][_habitId];
}
function getAllHabits(
address _user
) external view returns (Habit[] memory) {
return habitsByUser[_user];
}
}
Here’s what's happening above here:
State Variables:
admin
: Contract deployer can withdraw excess pool funds.habitsByUser
: Maps user addresses to their array of habits.totalPool
: Tracks accumulated stakes from failed habits.Constants like
STREAK_BONUS
,MIN_DURATION
, andSUCCESS_THRESHOLD
ensure consistency.
Habit Creation:
Validates duration and stake, ensuring the correct amount of tRBTC is sent.
Stores habit details in a
Habit
struct.
Daily Check-Ins:
Prevents double check-ins within a 22-26 hour window (with a 2-hour buffer).
Tracks streaks for consecutive days.
Finalization:
Calculates success based on 80% check-ins.
Award streak bonuses from the pool if applicable.
Transfers stakes to the user or pool accordingly.
Admin Withdrawal: Allows the admin to manage the pool sustainably.
Gas Optimization:
Minimizes storage writes (e.g., updating only necessary fields).
Uses constants to avoid repeated calculations.
Deploying to Rootstock
Now our contract is ready, let’s deploy it to Rootstock’s Testnet.
Step 1: Configure Hardhat for Rootstock
In your hardhat.config.js
, add the Rootstock Testnet configuration:
require("@nomicfoundation/hardhat-toolbox");
const dotenv = require("dotenv");
dotenv.config();
/** @type import('hardhat/config').HardhatUserConfig */
module.exports = {
solidity: "0.8.28",
networks: {
// for testnet
rootstock: {
url: process.env.ROOTSTOCK_TESTNET_RPC_URL,
accounts: [process.env.WALLET_KEY],
},
},
etherscan: {
// Use "123" as a placeholder, because Blockscout doesn't need a real API key, and Hardhat will complain if this property isn't set.
apiKey: {
rootstock: "123",
},
customChains: [
{
network: "rootstock",
chainId: 31,
urls: {
apiURL: "https://rootstock-testnet.blockscout.com/api/",
browserURL: "https://rootstock-testnet.blockscout.com/",
},
},
],
},
sourcify: {
enabled: false,
},
};
And in your .env
file in the root directory of your hardhat project
WALLET_KEY=<your-private-key>
ROOTSTOCK_TESTNET_RPC_URL=<your-rootstock-testnet-rpc-url>
You can get your testnet RPC URL from Alchemy Dashboard, ensure you’ve selected testnet.
Step 3: Write the ignition deployment modules
Create a new file in ignition/modules/HabitTracker.js
:
const { buildModule } = require("@nomicfoundation/hardhat-ignition/modules");
module.exports = buildModule("HabitTrackerModule", (m) => {
const HabitTracker = m.contract("HabitTracker");
return { HabitTracker };
});
Step 4: Deploy the Contract
Run the deployment script:
npx hardhat ignition deploy ./ignition/modules/HabitTracker.js --network rootstock --verify
This will deploy your contract to the Rootstock Testnet.
Building the Frontend
Let’s bring the contract to life with a React frontend using the thirdweb SDK to connect to the Rootstock Testnet.
What You’ll Need
Node.js: Installed on your machine.
Contract Details: Address of your deployed
HabitTracker
contract.
Step 1: Set Up the Project
Create a new React app:
Select React and TypeScript in the options to create your React app
npx create-vite@latest client cd client npm install
Then follow this guide to set up Tailwind CSS, ShadcnUI with Vite for your React app https://ui.shadcn.com/docs/installation/vite
Step 2: Adding UI components and installing dependencies
First, let’s add some UI components from Shadcn.
npx shadcn@latest add button card tabs input label sonner badge progress
And then we’re going to add and set up thirdweb
npm i thirdweb
In your main.tsx, we’re going to add the ThirdwebProvider component to wrap the App, and the Toaster component will be added here also.
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.tsx";
import { ThirdwebProvider } from "thirdweb/react";
import { Toaster } from "./components/ui/sonner.tsx";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ThirdwebProvider>
<App />
<Toaster />
</ThirdwebProvider>
</StrictMode>
);
In your App.tsx,
To get your thirdweb client ID, head to your thirdweb dashboard, create a new project titled ‘habit-tracker-dapp‘, and copy the client ID and add it below here.
import { ConnectButton, useActiveAccount } from "thirdweb/react";
import { createThirdwebClient } from "thirdweb";
import { defineChain } from "thirdweb/chains";
import { HabitDashboard } from "@/components/HabitDashboard";
import { Plus, Target, TrendingUp, Award } from "lucide-react";
const client = createThirdwebClient({
clientId: "your-thirdweb-client-id", // add your thirdweb client id here
});
export const rootstockTestnet = defineChain({
id: 31,
name: "Rootstock Testnet",
rpc: "https://public-node.testnet.rsk.co",
nativeCurrency: {
name: "tRBTC",
symbol: "tRBTC",
decimals: 18,
},
blockExplorers: [
{
name: "RSK Testnet Explorer",
url: "https://explorer.testnet.rootstock.io/",
},
{
name: "Blockscout Testnet Explorer",
url: "https://rootstock-testnet.blockscout.com/",
},
],
testnet: true,
});
function App() {
const account = useActiveAccount();
if (!account) {
return (
<div className="min-h-screen bg-gradient-to-br from-purple-50 via-blue-50 to-indigo-100">
<div className="container mx-auto px-4 py-16">
{/* Hero Section */}
<div className="text-center mb-16">
<div className="inline-flex items-center justify-center w-20 h-20 bg-gradient-to-r from-purple-600 to-blue-600 rounded-2xl mb-8 shadow-lg">
<Target className="w-10 h-10 text-white" />
</div>
<h1 className="text-5xl md:text-6xl font-bold bg-gradient-to-r from-purple-600 via-blue-600 to-indigo-600 bg-clip-text text-transparent mb-6">
HabitTracker
</h1>
<p className="text-xl text-gray-600 max-w-2xl mx-auto mb-8">
Stake your commitment, track your progress, and earn rewards for
building life-changing habits.
</p>
{/* Connect Wallet Button */}
<div className="mb-12">
<ConnectButton
client={client}
theme={"light"}
chain={rootstockTestnet}
connectButton={{
label: "Connect Wallet to Start",
style: {
background:
"linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
color: "white",
fontSize: "18px",
padding: "16px 32px",
borderRadius: "12px",
border: "none",
cursor: "pointer",
fontWeight: "600",
boxShadow: "0 10px 25px rgba(102, 126, 234, 0.3)",
transition: "all 0.3s ease",
},
}}
/>
</div>
</div>
{/* Features Grid */}
<div className="grid md:grid-cols-3 gap-8 max-w-4xl mx-auto">
<div className="bg-white/70 backdrop-blur-sm rounded-2xl p-8 shadow-lg border border-white/20">
<div className="w-12 h-12 bg-gradient-to-r from-green-400 to-emerald-500 rounded-xl flex items-center justify-center mb-4">
<Plus className="w-6 h-6 text-white" />
</div>
<h3 className="text-xl font-semibold text-gray-800 mb-3">
Stake & Commit
</h3>
<p className="text-gray-600">
Put your money where your motivation is. Stake tRBTC to create
accountability for your habits.
</p>
</div>
<div className="bg-white/70 backdrop-blur-sm rounded-2xl p-8 shadow-lg border border-white/20">
<div className="w-12 h-12 bg-gradient-to-r from-blue-400 to-indigo-500 rounded-xl flex items-center justify-center mb-4">
<TrendingUp className="w-6 h-6 text-white" />
</div>
<h3 className="text-xl font-semibold text-gray-800 mb-3">
Track Progress
</h3>
<p className="text-gray-600">
Daily check-ins, streak tracking, and beautiful progress
visualization keep you motivated.
</p>
</div>
<div className="bg-white/70 backdrop-blur-sm rounded-2xl p-8 shadow-lg border border-white/20">
<div className="w-12 h-12 bg-gradient-to-r from-purple-400 to-pink-500 rounded-xl flex items-center justify-center mb-4">
<Award className="w-6 h-6 text-white" />
</div>
<h3 className="text-xl font-semibold text-gray-800 mb-3">
Earn Rewards
</h3>
<p className="text-gray-600">
Complete 80% of your habit and get your stake back, plus bonus
rewards for streaks!
</p>
</div>
</div>
{/* Stats */}
<div className="mt-16 text-center">
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 max-w-2xl mx-auto">
<div>
<div className="text-3xl font-bold text-purple-600">7-365</div>
<div className="text-gray-600">Days Range</div>
</div>
<div>
<div className="text-3xl font-bold text-blue-600">80%</div>
<div className="text-gray-600">Success Rate</div>
</div>
<div>
<div className="text-3xl font-bold text-indigo-600">
0.001 tRBTC
</div>
<div className="text-gray-600">Streak Bonus</div>
</div>
<div>
<div className="text-3xl font-bold text-purple-600">∞</div>
<div className="text-gray-600">Possibilities</div>
</div>
</div>
</div>
</div>
</div>
);
}
return <HabitDashboard client={client} chain={rootstockTestnet} />;
}
export default App;
Alright, so here’s what’s going on in this part. First, we set up the connection to the Rootstock testnet using Thirdweb. We defined the network details like its ID, name, RPC URL, and block explorer. Then, we created a Thirdweb client using my project’s client ID. Now, when you open the app, it checks if your wallet is connected. If it’s not, you’ll see a nice welcome screen that explains what the app is about, staking tRBTC to commit to your habits, tracking your progress daily, and earning rewards for staying consistent. There’s also a "Connect Wallet" button that lets you get started. Once you connect your wallet, you can access the dashboard, i.e., the HabitDashboard
is rendered, where you can create habits, check into habits you’ve created, and monitor your current and completed habits.
Then, create a new file for the habit dashboard components/HabitDashboard.tsx
:
import { useState } from 'react';
import { ConnectButton } from "thirdweb/react";
import { getContract } from "thirdweb";
import { CreateHabitForm } from './CreateHabitForm';
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Card, CardContent } from "@/components/ui/card";
import { Target, Plus, Clock, Trophy } from 'lucide-react';
const CONTRACT_ADDRESS = "0x41B276f514B04a6C39b692AA3531f98d3904e37D"; // replace with your deployed HabitTracker contract address
interface HabitDashboardProps {
client: any;
chain: any;
}
export const HabitDashboard = ({ client, chain }: HabitDashboardProps) => {
const [activeTab, setActiveTab] = useState("active");
const contract = getContract({
client,
chain,
address: CONTRACT_ADDRESS,
});
return (
<div className="min-h-screen bg-gradient-to-br from-purple-50 via-blue-50 to-indigo-100">
<div className="container mx-auto px-4 py-8">
{/* Header */}
<div className="flex flex-col md:flex-row md:justify-between md:items-start mb-8">
<div className="flex items-center gap-3 mb-4 md:mb-0">
<div className="w-12 h-12 bg-gradient-to-r from-purple-600 to-blue-600 rounded-xl flex items-center justify-center">
<Target className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="text-xl sm:text-3xl font-bold bg-gradient-to-r from-purple-600 to-blue-600 bg-clip-text text-transparent">
My Habits
</h1>
<p className="text-gray-600">Track your progress and build consistency</p>
</div>
</div>
<ConnectButton client={client} theme={"light"}/>
</div>
{/* Main Content */}
<div className="grid lg:grid-cols-3 gap-8">
{/* Create Habit Form */}
<div className="lg:col-span-1">
<Card className="bg-white/70 backdrop-blur-sm border-white/20 shadow-lg">
<CardContent className="p-6">
<div className="flex items-center gap-2 mb-4">
<Plus className="w-5 h-5 text-purple-600" />
<h2 className="text-xl font-semibold">Create New Habit</h2>
</div>
<CreateHabitForm contract={contract} />
</CardContent>
</Card>
</div>
{/* Habits Tabs */}
<div className="lg:col-span-2">
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-2 bg-white/50 backdrop-blur-sm">
<TabsTrigger value="active" className="flex items-center gap-2">
<Clock className="w-4 h-4" />
Active Habits
</TabsTrigger>
<TabsTrigger value="completed" className="flex items-center gap-2">
<Trophy className="w-4 h-4" />
Completed
</TabsTrigger>
</TabsList>
<TabsContent value="active" className="mt-6">
Active Habits
</TabsContent>
<TabsContent value="completed" className="mt-6">
Completed Habits
</TabsContent>
</Tabs>
</div>
</div>
</div>
</div>
);
};
The code above creates the main dashboard where users can connect their wallet, create new habits, and track their progress. It connects to a smart contract using Thirdweb and shows two tabs, one for active habits and one for completed ones. There’s a simple form on the left to add a new habit, and on the right, users can switch between seeing habits they’re still working on or ones they’ve finished.
So let’s create a new component components/CreateHabitForm.tsx
import { useState } from "react";
import { useSendAndConfirmTransaction } from "thirdweb/react";
import { prepareContractCall } from "thirdweb";
import { toWei } from "thirdweb/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent } from "@/components/ui/card";
import { Loader2, Plus } from "lucide-react";
import { toast } from "@/hooks/use-toast";
interface CreateHabitFormProps {
contract: any;
}
export const CreateHabitForm = ({ contract }: CreateHabitFormProps) => {
const [duration, setDuration] = useState("");
const [dailyStake, setDailyStake] = useState("");
const [title, setTitle] = useState("");
const [isCreating, setIsCreating] = useState(false);
const { mutate: sendAndConfirmTransaction, isPending } = useSendAndConfirmTransaction();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!duration || !dailyStake || !title) {
toast({
title: "Error",
description: "Please fill in all fields",
variant: "destructive",
});
return;
}
const durationDays = parseInt(duration);
const stakeAmount = parseFloat(dailyStake);
if (durationDays < 7 || durationDays > 365) {
toast({
title: "Error",
description: "Duration must be between 7 and 365 days",
variant: "destructive",
});
return;
}
if (stakeAmount <= 0) {
toast({
title: "Error",
description: "Daily stake must be greater than 0",
variant: "destructive",
});
return;
}
setIsCreating(true);
try {
const totalStake = stakeAmount * durationDays;
const transaction = prepareContractCall({
contract,
method:
"function createHabit(uint256 _durationDays, uint256 _dailyStake, string memory _title) external payable",
params: [BigInt(durationDays), toWei(stakeAmount.toString()), title],
value: toWei(totalStake.toString()),
});
sendAndConfirmTransaction(transaction, {
onSuccess: () => {
toast({
title: "Success!",
description: `Habit created! You've staked ${totalStake.toFixed(5)} tRBTC for ${durationDays} days.`,
});
setTitle("");
setDuration("");
setDailyStake("");
},
onError: (error) => {
toast({
title: "Transaction Failed",
description: error.message,
variant: "destructive",
});
},
});
} catch (error) {
toast({
title: "Error",
description: "Failed to create habit",
variant: "destructive",
});
} finally {
setIsCreating(false);
}
};
const totalStake =
duration && dailyStake
? (parseFloat(dailyStake) * parseInt(duration)).toFixed(4)
: "0";
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="dailyStake">Title</Label>
<Input
id="title"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g., Exercise, Praying Daily, Study 1 hour"
required
autoFocus
maxLength={50}
pattern="^[a-zA-Z0-9\s]+$"
title="Only letters, numbers, and spaces allowed"
aria-describedby="titleHelp"
aria-required="true"
aria-invalid={!title || title.length < 3}
aria-errormessage="titleError"
aria-label="Habit Title"
className="mt-1 outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isCreating || isPending}
/>
</div>
<div>
<Label htmlFor="duration">Duration (days)</Label>
<Input
id="duration"
type="number"
min="7"
max="365"
value={duration}
onChange={(e) => setDuration(e.target.value)}
placeholder="e.g., 30"
className="mt-1 outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isCreating || isPending}
/>
<p className="text-sm text-gray-500 mt-1">Between 7 and 365 days</p>
</div>
<div>
<Label htmlFor="dailyStake">Daily Stake (tRBTC)</Label>
<Input
id="dailyStake"
type="number"
step="0.00001"
min="0.00001"
value={dailyStake}
onChange={(e) => setDailyStake(e.target.value)}
placeholder="e.g., 0.01"
className="mt-1 outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isCreating || isPending}
aria-describedby="dailyStakeHelp"
/>
<p className="text-sm text-gray-500 mt-1">
Amount you're willing to risk each day
</p>
</div>
{duration && dailyStake && (
<Card className="bg-gradient-to-r from-purple-100 to-blue-100 border-purple-200">
<CardContent className="p-4">
<div className="text-center">
<p className="text-sm text-gray-600">Total Stake Required</p>
<p className="text-2xl font-bold text-purple-600">
{totalStake} tRBTC
</p>
<p className="text-xs text-gray-500 mt-1">
Complete 80% to get your stake back + bonuses!
</p>
</div>
</CardContent>
</Card>
)}
<Button
type="submit"
disabled={isCreating || isPending || !duration || !dailyStake}
className="w-full bg-gradient-to-r from-purple-600 to-blue-600 hover:from-purple-700 hover:to-blue-700"
>
{isCreating || isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Creating Habit...
</>
) : (
<>
<Plus className="w-4 h-4 mr-2" />
Create Habit
</>
)}
</Button>
</form>
);
};
This component is the form where users set up a new habit they want to commit to. The key idea here is: you pick a title for your habit, choose how many days you want to stick with it, and decide how much tRBTC you're willing to stake each day to stay accountable.
handleSubmit
Let me walk you through what happens when you hit the Create Habit button.
We’ve got a function called handleSubmit
, and it kicks in when you submit the form. Here's the step-by-step of what it does:
First, it prevents the default form behavior (you know, that page reload stuff).
Then, it checks if all the fields: duration, dailyStake, and title, are filled in. If any are missing, it pops up an error toast to let you know.
After that, it checks that the duration is between 7 and 365 days, and the daily stake is greater than 0. If those validations fail, it again shows a relevant toast.
If everything is fine, it calculates the
totalStake
by multiplying your daily stake by the number of days you’re committing to.It uses
prepareContractCall()
to prepare a smart contract transaction. ThecreateHabit
method on-chain takes:The number of days
The daily stake (converted to wei)
Your habit title
And it also sends along the total stake as the
msg.value
, the equivalent totalStake along to the contract
Finally, it uses Thirdweb’s
sendAndConfirmTransaction()
to send the transaction to the Rootstock blockchain. Based on success or failure, it shows a successful or failure toast, resets the form, or tells you what went wrong.
totalStake
This is just a small calculation, but super useful for user feedback. Right below the inputs, there’s a live-updating card that tells you how much total tRBTC you’re putting at stake. It only shows up when both the duration and daily stake are filled in.
Here’s how it works:
const totalStake = duration && dailyStake
? (parseFloat(dailyStake) * parseInt(duration)).toFixed(4)
: "0";
So as soon as the user inputs values, they immediately see what’s at risk. It’s a nice way to drive home the commitment.
✍️ Minor Stuff (but still important)
We’ve got some state hooks like
title
,duration
,dailyStake
, andisCreating
to manage the form inputs and the submission process.Each input is well-structured: it has labels, validation, helpful descriptions, and some accessibility goodies like
aria-label
,aria-invalid
, etc.The form is styled using a UI component library with cards, labels, and buttons from
@/components/ui
, plus some icons from Lucide for a smooth look.When the form is submitting or pending, we disable inputs and show a loading spinner on the button, so the user knows something is happening.
And just to keep things safe, if anything at all fails (like a blockchain error or something unexpected), we’ve wrapped the logic in a try-catch block and show a proper error toast.
So in short:
We’ve built this component to let users create habits on-chain. They define the habit, stake some crypto as a commitment, and then the smart contract keeps track of everything. This form handles validation, feedback, transaction prep, and confirmation all in one tight flow. Pretty neat, right? you bet!
PS: You might get an error like this below, and your app just shows a blank page.
To fix this, head to this component components/ui/sonner.tsx
And update this import section with the below:
// from this
import { useTheme } from "next-themes"
import { Toaster as Sonner, ToasterProps } from "sonner"
// to this
import { useTheme } from "next-themes"
import { Toaster as Sonner } from "sonner"
import type { ToasterProps } from "sonner"
And then refresh your page.
Now, let’s have a little feel of our app. If you’ve not yet run the app, run the command npm run dev
to open up the app locally
First you should see this
Now, let’s try to create some habits here
Here, it pops your connected wallet up, and then you can confirm the transaction and get the successful toast message.
Now that we can create a habit, let’s implement the tabs in the right view to show the active habits and the completed habits.
Create a new file components/ActiveHabits.tsx
import { useState, useEffect } from "react";
import {
useActiveAccount,
useReadContract,
useSendAndConfirmTransaction,
} from "thirdweb/react";
import { prepareContractCall } from "thirdweb";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { Badge } from "@/components/ui/badge";
import { CheckCircle, Clock, Flame, Target, Calendar } from "lucide-react";
import { toast } from "sonner";
interface ActiveHabitsProps {
contract: any;
}
interface Habit {
title: string;
user: string;
startTime: bigint;
durationDays: bigint;
dailyStake: bigint;
checkIns: bigint;
streak: bigint;
active: boolean;
lastCheckIn: bigint;
// Additional fields for completed habits
success: boolean;
reward: bigint;
completedAt: bigint;
}
export const ActiveHabits = ({ contract }: ActiveHabitsProps) => {
const account = useActiveAccount();
const [habits, setHabits] = useState<Habit[]>([]);
const [checkedInToday, setCheckedInToday] = useState<{
[key: number]: boolean;
}>({});
const [currentlyCheckingIn, setCurrentlyCheckingIn] = useState<number | null>(
null
);
const { mutate: sendAndConfirmTransaction } = useSendAndConfirmTransaction();
const { data: allHabits } = useReadContract({
contract,
method:
"function getAllHabits(address _user) view returns ((address user, bool active, bool isCompleted, string title, uint256 startTime, uint256 durationDays, uint256 dailyStake, uint256 checkIns, uint256 streak, uint256 lastCheckIn, uint256 completedAt)[] memory)",
params: [account?.address || ""],
});
useEffect(() => {
setHabits((allHabits as unknown as Habit[]) || []);
console.log("Fetched Habits:", allHabits);
}, [account, allHabits]);
const handleCheckIn = async (habitId: number) => {
try {
setCurrentlyCheckingIn(habitId);
const transaction = prepareContractCall({
contract,
method: "function checkIn(uint256 _habitId) external",
params: [BigInt(habitId)],
});
sendAndConfirmTransaction(transaction, {
onSuccess: () => {
toast.success("Success", {
description: "Daily check-in completed! Keep up the great work!",
});
setCheckedInToday((prev) => ({ ...prev, [habitId]: true }));
setCurrentlyCheckingIn(null);
},
onError: (error) => {
toast("Check-in Failed", {
description: error.message,
className: "bg-red-50 text-red-800 border-red-200",
});
setCurrentlyCheckingIn(null);
},
});
} catch (error) {
toast.error("Error", {
description: "Failed to check in",
className: "bg-red-50 text-red-800 border-red-200",
});
setCurrentlyCheckingIn(null);
}
};
const formatDate = (timestamp: bigint) => {
return new Date(Number(timestamp) * 1000).toLocaleDateString();
};
const getDaysRemaining = (startTime: bigint, duration: bigint) => {
const startMs = Number(startTime) * 1000;
const endMs = startMs + Number(duration) * 24 * 60 * 60 * 1000;
const remainingMs = endMs - Date.now();
return Math.max(0, Math.ceil(remainingMs / (24 * 60 * 60 * 1000)));
};
const getProgress = (checkIns: bigint, duration: bigint) => {
return (Number(checkIns) / Number(duration)) * 100;
};
const canCheckInToday = (lastCheckIn: bigint) => {
const lastCheckInMs = Number(lastCheckIn) * 1000;
const oneDayMs = 24 * 60 * 60 * 1000;
return Date.now() - lastCheckInMs >= oneDayMs - 2 * 60 * 60 * 1000; // 2-hour buffer
};
if (habits.length === 0) {
return (
<Card className="bg-white/70 backdrop-blur-sm border-white/20 shadow-lg">
<CardContent className="p-8 text-center">
<Target className="w-12 h-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-gray-700 mb-2">
No Active Habits
</h3>
<p className="text-gray-500">
Create your first habit to get started on your journey!
</p>
</CardContent>
</Card>
);
}
return (
<div className="space-y-6">
{habits.map((habit, index) => {
if (!habit.active) return null; // Skip inactive habits
const progress = getProgress(habit.checkIns, habit.durationDays);
const daysRemaining = getDaysRemaining(
habit.startTime,
habit.durationDays
);
const canCheckIn =
canCheckInToday(habit.lastCheckIn) && !checkedInToday[index];
const successThreshold = 80;
const isOnTrack =
progress >=
successThreshold * (1 - daysRemaining / Number(habit.durationDays));
return (
<Card
key={index}
className="bg-white/70 backdrop-blur-sm border-white/20 shadow-lg hover:shadow-xl transition-all duration-300"
>
<CardHeader>
<div className="flex justify-between items-start">
<div>
<CardTitle className="flex items-center gap-2">
<div className="w-3 h-3 bg-gradient-to-r from-green-400 to-emerald-500 rounded-full"></div>
{habit.title}
</CardTitle>
<div className="flex items-center gap-4 mt-2 text-sm text-gray-600">
<div className="flex items-center gap-1">
<Calendar className="w-4 h-4" />
{Number(habit.durationDays)} days
</div>
<div className="flex items-center gap-1">
<Clock className="w-4 h-4" />
{daysRemaining} left
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Badge
variant={isOnTrack ? "default" : "destructive"}
className="flex items-center gap-1"
>
<Target className="w-3 h-3" />
{isOnTrack ? "On Track" : "Behind"}
</Badge>
{Number(habit.streak) >= 3 && (
<Badge className="bg-gradient-to-r from-orange-400 to-red-500 flex items-center gap-1">
<Flame className="w-3 h-3" />
{Number(habit.streak)} streak
</Badge>
)}
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Progress Bar */}
<div>
<div className="flex justify-between text-sm mb-2">
<span>Progress</span>
<span>
{Number(habit.checkIns)}/{Number(habit.durationDays)} days (
{progress.toFixed(1)}%)
</span>
</div>
<Progress value={progress} className="h-3" />
<div className="flex justify-between text-xs text-gray-500 mt-1">
<span>{progress.toFixed(1)}%</span>
<span className="text-purple-600">80% needed</span>
<span>100%</span>
</div>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
<div className="text-center p-3 bg-gradient-to-r from-blue-50 to-indigo-50 rounded-lg border border-blue-100">
<div className="text-2xl font-bold text-blue-600">
{Number(habit.checkIns)}
</div>
<div className="text-xs text-gray-600">Check-ins</div>
</div>
<div className="text-center p-3 bg-gradient-to-r from-orange-50 to-red-50 rounded-lg border border-orange-100">
<div className="text-2xl font-bold text-orange-600">
{Number(habit.streak)}
</div>
<div className="text-xs text-gray-600">Current Streak</div>
</div>
<div className="text-center p-3 bg-gradient-to-r from-green-50 to-emerald-50 rounded-lg border border-green-100 col-span-2 sm:col-span-1">
<div className="text-2xl font-bold text-green-600">
{(Number(habit.dailyStake) / 1e18).toFixed(5)}
</div>
<div className="text-xs text-gray-600">
Daily Stake (tRBTC)
</div>
</div>
</div>
{/* Check-in Button */}
<Button
onClick={() => handleCheckIn(index)}
disabled={
!canCheckIn ||
(currentlyCheckingIn !== null && currentlyCheckingIn == index)
}
className={`w-full ${
canCheckIn
? "bg-gradient-to-r from-green-500 to-emerald-600 hover:from-green-600 hover:to-emerald-700"
: "bg-gray-300"
}`}
>
{checkedInToday[index] ? (
<>
<CheckCircle className="w-4 h-4 mr-2" />
Checked in today!
</>
) : checkedInToday[index] === undefined &&
currentlyCheckingIn !== null &&
currentlyCheckingIn == index ? (
<>
<Clock className="w-4 h-4 mr-2" />
Checking in...
</>
) : canCheckIn ? (
<>
<CheckCircle className="w-4 h-4 mr-2" />
Check In Now
</>
) : (
<>
<Clock className="w-4 h-4 mr-2" />
Already checked in
</>
)}
</Button>
</CardContent>
</Card>
);
})}
</div>
);
};
Let’s understand what’s happening here:
1. Fetching the User’s Habits
const { data: allHabits, refetch: refetchHabits } = useReadContract({
contract,
method: "function getAllHabits(address _user) view returns ((address user, bool active, bool isCompleted, string title, uint256 startTime, uint256 durationDays, uint256 dailyStake, uint256 checkIns, uint256 streak, uint256 lastCheckIn, uint256 completedAt)[] memory)",
params: [account?.address || ""],
});
This hook reads from the smart contract and retrieves all habits for the connected user.
It calls the
getAllHabits
method on-chain, returning an array of habit objects.
2. Updating Component State with Fetched Habits
useEffect(() => {
setHabits((allHabits as unknown as Habit[]) || []);
}, [account, allHabits]);
Updates local component state whenever the connected account or fetched habits change.
Ensures the UI always reflects the current list of habits for the user.
3. Check-In Logic
const handleCheckIn = async (habitId: number) => {
try {
setCurrentlyCheckingIn(habitId);
const transaction = prepareContractCall({
contract,
method: "function checkIn(uint256 _habitId) external",
params: [BigInt(habitId)],
});
sendAndConfirmTransaction(transaction, {
onSuccess: () => {
toast.success("Success", {
description: "Daily check-in completed! Keep up the great work!",
});
setCheckedInToday((prev) => ({ ...prev, [habitId]: true }));
setCurrentlyCheckingIn(null);
},
onError: (error) => {
toast("Check-in Failed", {
description: error.message,
className: "bg-red-50 text-red-800 border-red-200",
});
setCurrentlyCheckingIn(null);
},
});
} catch (error) {
toast.error("Error", {
description: "Failed to check in",
className: "bg-red-50 text-red-800 border-red-200",
});
setCurrentlyCheckingIn(null);
}
};
Prepares a transaction calling the
checkIn(uint256 _habitId)
function on the smart contract.Uses
sendAndConfirmTransaction
to broadcast and confirm the check-in.Updates local state to reflect a successful check-in and displays a toast notification for user feedback.
4. Helpers:
formatDate()
— Converts a UNIX timestamp to a human-readable date.getDaysRemaining()
— Calculates how many days are left for the habit to complete.getProgress()
— Determines the percentage of progress based on check-ins.canCheckInToday()
— Checks if a user is eligible to check in (i.e., hasn’t already checked in today).
✍️ Minor Stuff (but still important)
The rest of the code handles UI rendering using Tailwind-styled components and various icons to present:
A card layout for each active habit.
Visual progress bars and stats like current streak, check-ins, and daily stake.
Check-in button states:
Disabled: if already checked in.
Loading: if in progress.
Green and clickable if check-in is possible.
Badges indicate if the user is on track or behind.
A fallback card that encourages users to create a habit if one doesn't exist.
Now, let’s also implement the CompletedHabits tab, create a new file components/CompletedHabits.tsx
import { useState, useEffect } from "react";
import { useActiveAccount, useReadContract } from "thirdweb/react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Trophy, Calendar, CheckCircle, XCircle } from "lucide-react";
interface CompletedHabitsProps {
contract: any;
}
interface Habit {
title: string;
user: string;
startTime: bigint;
durationDays: bigint;
dailyStake: bigint;
checkIns: bigint;
streak: bigint;
active: boolean;
lastCheckIn: bigint;
isCompleted: boolean;
completedAt: bigint;
}
export const STREAK_REWARD = BigInt(1000000000000000); // 0.001 RBTC in wei --normally we'd use viem or ethers to parse this
export const STREAK_BONUS_THRESHOLD = BigInt(3); // Example threshold for streak bonus
export const CompletedHabits = ({ contract }: CompletedHabitsProps) => {
const account = useActiveAccount();
interface HabitWithExtras extends Habit {
reward: bigint;
success: boolean;
}
const [completedHabits, setCompletedHabits] = useState<HabitWithExtras[]>([]);
const { data: allHabits, refetch: refetchHabits } = useReadContract({
contract,
method:
"function getAllHabits(address _user) view returns ((address user, bool active, bool isCompleted, string title, uint256 startTime, uint256 durationDays, uint256 dailyStake, uint256 checkIns, uint256 streak, uint256 lastCheckIn, uint256 completedAt)[] memory)",
params: [account?.address || ""],
});
useEffect(() => {
refetchHabits();
setCompletedHabits(
(allHabits || [])
.filter((habit: Habit) => habit.isCompleted)
.map((habit: Habit) => {
const success = habit.checkIns >= Number(habit.durationDays) * 0.8;
const reward = success
? BigInt(habit.dailyStake) * BigInt(habit.durationDays) +
habit.streak >
STREAK_BONUS_THRESHOLD
? STREAK_REWARD
: BigInt(0)
: BigInt(0);
return {
...habit,
reward,
success,
};
})
);
}, [account]);
const formatDate = (timestamp: bigint) => {
return new Date(Number(timestamp)).toLocaleDateString();
};
const getSuccessRate = (checkIns: bigint, duration: bigint) => {
return (Number(checkIns) / Number(duration)) * 100;
};
if (completedHabits.length === 0) {
return (
<Card className="bg-white/70 backdrop-blur-sm border-white/20 shadow-lg">
<CardContent className="p-8 text-center">
<Trophy className="w-12 h-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-gray-700 mb-2">
No Completed Habits
</h3>
<p className="text-gray-500">
Complete your first habit to see your achievements here!
</p>
</CardContent>
</Card>
);
}
return (
<div className="space-y-6">
{/* Summary Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<Card className="bg-gradient-to-r from-green-50 to-emerald-50 border-green-200">
<CardContent className="p-4 text-center">
<div className="text-2xl font-bold text-green-600">
{completedHabits.filter((h) => h.success).length}
</div>
<div className="text-sm text-gray-600">Successful Habits</div>
</CardContent>
</Card>
<Card className="bg-gradient-to-r from-red-50 to-pink-50 border-red-200">
<CardContent className="p-4 text-center">
<div className="text-2xl font-bold text-red-600">
{completedHabits.filter((h) => !h.success).length}
</div>
<div className="text-sm text-gray-600">Failed Habits</div>
</CardContent>
</Card>
<Card className="bg-gradient-to-r from-purple-50 to-blue-50 border-purple-200">
<CardContent className="p-4 text-center">
<div className="text-2xl font-bold text-purple-600">
{completedHabits.length > 0
? (
(completedHabits.filter((h) => h.success).length /
completedHabits.length) *
100
).toFixed(0)
: 0}
%
</div>
<div className="text-sm text-gray-600">Success Rate</div>
</CardContent>
</Card>
</div>
{/* Completed Habits List */}
{completedHabits.map((habit, idx) => {
const successRate = getSuccessRate(habit.checkIns, habit.durationDays);
return (
<Card
key={`${habit.user}_habit_${idx + 1}`}
className="bg-white/70 backdrop-blur-sm border-white/20 shadow-lg"
>
<CardHeader>
<div className="flex justify-between items-start">
<div>
<CardTitle className="flex items-center gap-2">
{habit.success ? (
<CheckCircle className="w-5 h-5 text-green-500" />
) : (
<XCircle className="w-5 h-5 text-red-500" />
)}
{habit.title}
</CardTitle>
<div className="flex items-center gap-4 mt-2 text-sm text-gray-600">
<div className="flex items-center gap-1">
<Calendar className="w-4 h-4" />
Completed {formatDate(habit.completedAt)}
</div>
</div>
</div>
<Badge variant={habit.success ? "default" : "destructive"}>
{habit.success ? "Success" : "Failed"}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Stats Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-center p-3 bg-blue-50 rounded-lg">
<div className="text-lg font-bold text-blue-600">
{Number(habit.checkIns)}
</div>
<div className="text-xs text-gray-600">Check-ins</div>
</div>
<div className="text-center p-3 bg-orange-50 rounded-lg">
<div className="text-lg font-bold text-orange-600">
{Number(habit.streak)}
</div>
<div className="text-xs text-gray-600">Best Streak</div>
</div>
<div className="text-center p-3 bg-green-50 rounded-lg">
<div className="text-lg font-bold text-green-600">
{successRate.toFixed(1)}%
</div>
<div className="text-xs text-gray-600">Success Rate</div>
</div>
<div className="text-center p-3 bg-purple-50 rounded-lg">
<div className="text-lg font-bold text-purple-600">
{(Number(habit.reward) / 1e18).toFixed(3)}
</div>
<div className="text-xs text-gray-600">Reward (tRBTC)</div>
</div>
</div>
{/* Results */}
{habit.success ? (
<div className="p-4 bg-gradient-to-r from-green-50 to-emerald-50 rounded-lg border border-green-200">
<div className="flex items-center gap-2 text-green-700">
<Trophy className="w-5 h-5" />
<span className="font-semibold">Congratulations!</span>
</div>
<p className="text-sm text-green-600 mt-1">
You successfully completed this habit and earned{" "}
{(Number(habit.reward) / 1e18).toFixed(3)} tRBTC!
{Number(habit.streak) >= 3 && " Including streak bonus!"}
</p>
</div>
) : (
<div className="p-4 bg-gradient-to-r from-red-50 to-pink-50 rounded-lg border border-red-200">
<div className="flex items-center gap-2 text-red-700">
<XCircle className="w-5 h-5" />
<span className="font-semibold">Habit Not Completed</span>
</div>
<p className="text-sm text-red-600 mt-1">
You needed 80% check-ins but only achieved{" "}
{successRate.toFixed(1)}%. Your stake was added to the
reward pool.
</p>
</div>
)}
</CardContent>
</Card>
);
})}
</div>
);
};
🔍 What This Component Does (In Plain English 👀):
This component, CompletedHabits
, is a dashboard for showing habits a user has completed, whether successfully or not, and displaying statistics and rewards based on their performance.
It fetches a user’s completed habits from a smart contract and displays:
✅ Which were completed successfully
❌ Which were not
🏆 How many times the user succeeded or failed
📊 Success rate and rewards earned
1. useReadContract()
Reads all habits related to the connected user from the smart contract.
It uses the method
getAllHabits(address _user)
to fetch:user
,title
,startTime
,durationDays
,dailyStake
,checkIns
,streak
,completedAt
, etc.
Only completed habits (
isCompleted === true
) are used in this component.
2. useEffect()
— 💡 Where most logic lives
Runs when the component mounts or when the connected user changes:
useEffect(() => {
refetchHabits();
setCompletedHabits(
(allHabits || [])
.filter((habit) => habit.isCompleted)
.map((habit) => {
const success = habit.checkIns >= Number(habit.durationDays) * 0.8;
const reward = success
? BigInt(habit.dailyStake) * BigInt(habit.durationDays) +
habit.streak > STREAK_BONUS_THRESHOLD
? STREAK_REWARD
: BigInt(0)
: BigInt(0);
return { ...habit, reward, success };
})
);
}, [account]);
This logic:
Filters habits to get only the completed ones.
Calculates:
✅ If the user was successful (80% check-ins)
💰 The reward (daily stake * duration + bonus if streak > threshold)
🧠 Note: It calculates rewards using raw
bigint
math and assumes tRBTC token values in wei (like Ether in Ethereum).
3. UI Rendering (JSX Return Block)
If there are no completed habits, it shows a motivational card:
No Completed Habits
Complete your first habit to see your achievements here!
If there are completed habits:
Displays a summary dashboard:
Number of successful habits
Number of failed habits
Overall success rate (%)
Then it maps through
completedHabits
and for each one:Shows habit title, date completed, streak, check-ins, reward earned
Shows either a Success Card or Failure Card with stats and messages
🧩 Minor Functions & Logic
formatDate(timestamp: bigint)
return new Date(Number(timestamp)).toLocaleDateString();
- Converts a blockchain timestamp (in
bigint
) to a readable date like6/9/2025
.
getSuccessRate(checkIns, duration)
return (Number(checkIns) / Number(duration)) * 100;
- Returns the percentage of check-ins made relative to total habit duration.
STREAK_REWARD
and STREAK_BONUS_THRESHOLD
- Constants used to determine an extra reward if the user maintained a good habit streak.
STREAK_REWARD = 0.001 RBTC in wei
STREAK_BONUS_THRESHOLD = 3 days of streak
Badge
, Card
, CardHeader
, CardContent
, etc.
These are UI components (from Shadcn UI).
They help display habit info in styled cards with icons like ✅, ❌, 🏆, 📅, etc.
🧠 Summary of Logic Flow
useReadContract
fetches all user habits.useEffect
filters and processes only completed habits.Each completed habit is analyzed:
Did the user succeed? (80% check-ins)
How much is the reward? (dailyStake × days + bonus if long streak)
A styled card is rendered for each:
- Shows title, stats, reward, and status (Success or Failure)
A summary block shows total stats: ✅, ❌, % success rate.
Now let’s add the components to the habit dashboard to see the beauty. In your components/HabitDashboard.tsx
import { useState } from "react";
import { ConnectButton } from "thirdweb/react";
import { getContract } from "thirdweb";
import { CreateHabitForm } from "./CreateHabitForm";
import { ActiveHabits } from "./ActiveHabits"; // importing the active habits component
import { CompletedHabits } from "./CompletedHabits"; // importing the completed habits component
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Card, CardContent } from "@/components/ui/card";
import { Target, Plus, Clock, Trophy } from "lucide-react";
const CONTRACT_ADDRESS = "0x41B276f514B04a6C39b692AA3531f98d3904e37D";
interface HabitDashboardProps {
client: any;
chain: any;
}
export const HabitDashboard = ({ client, chain }: HabitDashboardProps) => {
const [activeTab, setActiveTab] = useState("active");
const contract = getContract({
client,
chain,
address: CONTRACT_ADDRESS,
});
return (
<div className="min-h-screen bg-gradient-to-br from-purple-50 via-blue-50 to-indigo-100">
<div className="container mx-auto px-4 py-8">
{/* Header */}
<div className="flex flex-col md:flex-row md:justify-between md:items-start mb-8">
<div className="flex items-center gap-3 mb-4 md:mb-0">
<div className="w-12 h-12 bg-gradient-to-r from-purple-600 to-blue-600 rounded-xl flex items-center justify-center">
<Target className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="text-xl sm:text-3xl font-bold bg-gradient-to-r from-purple-600 to-blue-600 bg-clip-text text-transparent">
My Habits
</h1>
<p className="text-gray-600">
Track your progress and build consistency
</p>
</div>
</div>
<ConnectButton client={client} theme={"light"} />
</div>
{/* Main Content */}
<div className="grid lg:grid-cols-3 gap-8">
{/* Create Habit Form */}
<div className="lg:col-span-1">
<Card className="bg-white/70 backdrop-blur-sm border-white/20 shadow-lg">
<CardContent className="p-6">
<div className="flex items-center gap-2 mb-4">
<Plus className="w-5 h-5 text-purple-600" />
<h2 className="text-xl font-semibold">Create New Habit</h2>
</div>
<CreateHabitForm contract={contract} />
</CardContent>
</Card>
</div>
{/* Habits Tabs */}
<div className="lg:col-span-2">
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="w-full"
>
<TabsList className="grid w-full grid-cols-2 bg-white/50 backdrop-blur-sm">
<TabsTrigger value="active" className="flex items-center gap-2">
<Clock className="w-4 h-4" />
Active Habits
</TabsTrigger>
<TabsTrigger
value="completed"
className="flex items-center gap-2"
>
<Trophy className="w-4 h-4" />
Completed
</TabsTrigger>
</TabsList>
<TabsContent value="active" className="mt-6">
<ActiveHabits contract={contract} /> // Here we added the ActiveHabits component
</TabsContent>
<TabsContent value="completed" className="mt-6">
<CompletedHabits contract={contract} /> // And here we added the CompletedHabits component
</TabsContent>
</Tabs>
</div>
</div>
</div>
</div>
);
};
First, you’d see this for no habits yet.
Let’s create a habit and see the beauty
Now let’s try checking into this new habit,
Well, that wraps it up. We’ve created a beautiful Habit Tracker dApp. Tell me what you think. If you come across any problems, feel free to drop them in the comment section. I’ll attend to them. The source code for this article can be found here https://github.com/michojekunle/habit-tracker-dapp. You can also interact with the deployed version here live https://habit-tracker-dapp.vercel.app/.
Wrapping Up
You’ve just built a Habit Tracker with Stakes dApp on Rootstock! This project combines smart contract development with frontend integration, giving you a hands-on way to learn blockchain development. With Rootstock’s low fees and Bitcoin-backed security, your habits are both affordable and secure.
Want to take it further? Add features like a leaderboard, custom goals, or a mobile-friendly UI. Check out the Rootstock Developer Portal for more resources, and join the Rootstock Discord to connect with other builders. Happy coding!
Subscribe to my newsletter
Read articles from Michael Ojekunle directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Michael Ojekunle
Michael Ojekunle
I love crafting beautiful web experiences and writing efficient smart contracts. I'm currently exploring Zero-Knowledge proofs, as well as Functional, Systems, Concurrent, and Scripting Programming Languages (Rust, Erlang, and Python).