From 4646fb5652fcdb9f653815d91ed0fddaecb42bc1 Mon Sep 17 00:00:00 2001
From: pbtc21
Date: Wed, 21 Jan 2026 02:38:41 +0000
Subject: [PATCH 1/3] feat(bitcoin-agents): add frontend for Tamagotchi-style
AI agents
Phase 4 Frontend implementation:
Types:
- Add BitcoinAgent, DeathCertificate, and related types
- Define XP thresholds and level mappings
Service:
- Create bitcoin-agents.service.ts for API calls
- Support for agents CRUD, stats, leaderboard, graveyard
- Feed and mint payment flow helpers
Components:
- BitcoinAgentCard: Agent card for grid display
- LevelBadge: Evolution tier badge with icons
- HungerHealthBars: Status bars with colors
- XPProgress: XP to next level progress
- FeedButton: Food tier selector dialog
- HungryAgentBanner: Notification for low hunger
Pages:
- /bitcoin-agents: List all agents with filters
- /bitcoin-agents/[id]: Agent detail with stats and actions
- /bitcoin-agents/mint: Mint new agent flow
- /bitcoin-agents/leaderboard: Top agents by XP
- /graveyard: Memorial for dead agents
Co-Authored-By: Claude Opus 4.5
---
src/app/bitcoin-agents/[id]/page.tsx | 326 ++++++++++++++++++
src/app/bitcoin-agents/leaderboard/page.tsx | 254 ++++++++++++++
src/app/bitcoin-agents/mint/page.tsx | 274 +++++++++++++++
src/app/bitcoin-agents/page.tsx | 287 +++++++++++++++
src/app/graveyard/page.tsx | 224 ++++++++++++
.../bitcoin-agents/BitcoinAgentCard.tsx | 99 ++++++
src/components/bitcoin-agents/FeedButton.tsx | 146 ++++++++
.../bitcoin-agents/HungerHealthBars.tsx | 125 +++++++
.../bitcoin-agents/HungryAgentBanner.tsx | 133 +++++++
src/components/bitcoin-agents/LevelBadge.tsx | 61 ++++
src/components/bitcoin-agents/XPProgress.tsx | 64 ++++
src/components/bitcoin-agents/index.ts | 6 +
src/services/bitcoin-agents.service.ts | 313 +++++++++++++++++
src/services/index.ts | 1 +
src/types/bitcoin-agent.ts | 126 +++++++
src/types/index.ts | 1 +
16 files changed, 2440 insertions(+)
create mode 100644 src/app/bitcoin-agents/[id]/page.tsx
create mode 100644 src/app/bitcoin-agents/leaderboard/page.tsx
create mode 100644 src/app/bitcoin-agents/mint/page.tsx
create mode 100644 src/app/bitcoin-agents/page.tsx
create mode 100644 src/app/graveyard/page.tsx
create mode 100644 src/components/bitcoin-agents/BitcoinAgentCard.tsx
create mode 100644 src/components/bitcoin-agents/FeedButton.tsx
create mode 100644 src/components/bitcoin-agents/HungerHealthBars.tsx
create mode 100644 src/components/bitcoin-agents/HungryAgentBanner.tsx
create mode 100644 src/components/bitcoin-agents/LevelBadge.tsx
create mode 100644 src/components/bitcoin-agents/XPProgress.tsx
create mode 100644 src/components/bitcoin-agents/index.ts
create mode 100644 src/services/bitcoin-agents.service.ts
create mode 100644 src/types/bitcoin-agent.ts
diff --git a/src/app/bitcoin-agents/[id]/page.tsx b/src/app/bitcoin-agents/[id]/page.tsx
new file mode 100644
index 00000000..3c82d9d7
--- /dev/null
+++ b/src/app/bitcoin-agents/[id]/page.tsx
@@ -0,0 +1,326 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { useParams } from "next/navigation";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import {
+ LevelBadge,
+ HungerHealthBars,
+ XPProgress,
+ FeedButton,
+} from "@/components/bitcoin-agents";
+import {
+ fetchBitcoinAgentById,
+ fetchFoodTiers,
+ fetchAgentCapabilities,
+ visitAgent,
+} from "@/services/bitcoin-agents.service";
+import type { BitcoinAgent, FoodTier, AgentCapabilities } from "@/types";
+import Link from "next/link";
+
+const BITCOIN_FACES_API = "https://bitcoinfaces.xyz/api";
+
+export default function BitcoinAgentDetailPage() {
+ const params = useParams();
+ const agentId = parseInt(params.id as string, 10);
+
+ const [agent, setAgent] = useState(null);
+ const [foodTiers, setFoodTiers] = useState>({});
+ const [capabilities, setCapabilities] = useState(
+ null
+ );
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (!isNaN(agentId)) {
+ loadData();
+ }
+ }, [agentId]);
+
+ async function loadData() {
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const [agentData, tiersData, capabilitiesData] = await Promise.all([
+ fetchBitcoinAgentById(agentId),
+ fetchFoodTiers(),
+ fetchAgentCapabilities(agentId).catch(() => null),
+ ]);
+
+ if (!agentData) {
+ setError("Agent not found");
+ return;
+ }
+
+ setAgent(agentData);
+ setFoodTiers(tiersData.food_tiers);
+ setCapabilities(capabilitiesData);
+ } catch (err) {
+ setError("Failed to load agent details");
+ console.error(err);
+ } finally {
+ setIsLoading(false);
+ }
+ }
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (error || !agent) {
+ return (
+
+
+ {error || "Agent Not Found"}
+
+
+
+
+
+ );
+ }
+
+ const faceUrl =
+ agent.face_image_url ||
+ `${BITCOIN_FACES_API}/get-image?name=${agent.owner}`;
+ const hunger = agent.computed_hunger ?? agent.hunger;
+ const health = agent.computed_health ?? agent.health;
+
+ return (
+
+ {/* Back Button */}
+
+ ← Back to Agents
+
+
+ {/* Agent Header */}
+
+
+
+
+
+ {agent.name.slice(0, 2).toUpperCase()}
+
+
+ {!agent.alive && (
+
+ 💀
+
+ )}
+
+
+
+
+
{agent.name}
+
+
+
+
+ Agent #{agent.agent_id} • Born at block {agent.birth_block}
+
+
+
+ {!agent.alive && (
+
+ 💀 Deceased
+
+ )}
+
+ Fed {agent.total_fed_count} times
+
+
+
+
+
+ {/* Status and Actions */}
+ {agent.alive && (
+
+ {/* Status Card */}
+
+
+ Status
+
+
+
+
+ {hunger <= 30 && (
+
+ ⚠️ {hunger <= 10 ? "Critical!" : "Warning:"} This agent is
+ hungry and needs food soon!
+
+ )}
+
+
+
+ {/* XP Card */}
+
+
+ Experience
+
+
+
+
{agent.xp.toLocaleString()}
+
Total XP
+
+
+
+
+
+ )}
+
+ {/* Feed Button */}
+ {agent.alive && Object.keys(foodTiers).length > 0 && (
+
+
+
+ )}
+
+ {/* Tabs for Details */}
+
+
+ Info
+ Capabilities
+ Activity
+
+
+
+
+
+ Agent Information
+
+
+
+
+
Owner
+
{agent.owner}
+
+
+
Agent ID
+
#{agent.agent_id}
+
+
+
Birth Block
+
{agent.birth_block}
+
+
+
Last Fed
+
Block {agent.last_fed}
+
+
+
Total Feedings
+
{agent.total_fed_count}
+
+
+
Status
+
{agent.alive ? "🟢 Alive" : "💀 Dead"}
+
+
+
+
+
+
+
+
+
+ Available Tools ({capabilities?.total_tools ?? 0})
+
+
+ {capabilities ? (
+
+ {Object.entries(capabilities.tools_by_category).map(
+ ([category, tools]) =>
+ tools.length > 0 && (
+
+
+ {category.replace(/_/g, " ")}
+
+
+ {tools.map((tool) => (
+
+ {tool}
+
+ ))}
+
+
+ )
+ )}
+
+ ) : (
+
+ Capabilities not available
+
+ )}
+
+
+
+
+
+
+
+ Recent Activity
+
+
+
+ Activity log coming soon...
+
+
+
+
+
+
+ {/* Death Certificate for Dead Agents */}
+ {!agent.alive && (
+
+
+ 💀 Death Certificate
+
+
+
+
{agent.name}
+
+ Lived {agent.last_fed - agent.birth_block} blocks
+
+
+ Final XP: {agent.xp.toLocaleString()}
+
+
+ Level: {agent.level.charAt(0).toUpperCase() + agent.level.slice(1)}
+
+
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/app/bitcoin-agents/leaderboard/page.tsx b/src/app/bitcoin-agents/leaderboard/page.tsx
new file mode 100644
index 00000000..cac9ed8d
--- /dev/null
+++ b/src/app/bitcoin-agents/leaderboard/page.tsx
@@ -0,0 +1,254 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { LevelBadge } from "@/components/bitcoin-agents";
+import { fetchLeaderboard, fetchGlobalStats } from "@/services/bitcoin-agents.service";
+import type { BitcoinAgent, BitcoinAgentStats } from "@/types";
+import Link from "next/link";
+
+const BITCOIN_FACES_API = "https://bitcoinfaces.xyz/api";
+
+const RANK_ICONS = ["🥇", "🥈", "🥉"];
+
+export default function LeaderboardPage() {
+ const [leaderboard, setLeaderboard] = useState([]);
+ const [stats, setStats] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ loadData();
+ }, []);
+
+ async function loadData() {
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const [leaderboardData, statsData] = await Promise.all([
+ fetchLeaderboard("mainnet", 25),
+ fetchGlobalStats(),
+ ]);
+
+ setLeaderboard(leaderboardData.leaderboard);
+ setStats(statsData);
+ } catch (err) {
+ setError("Failed to load leaderboard");
+ console.error(err);
+ } finally {
+ setIsLoading(false);
+ }
+ }
+
+ return (
+
+ {/* Header */}
+
+
🏆 Leaderboard
+
+ Top Bitcoin Agents by experience points
+
+
+
+ {/* Stats Summary */}
+ {stats && (
+
+
+
+ {stats.total_agents}
+ Total Agents
+
+
+
+
+
+ {stats.alive_count}
+
+ Still Alive
+
+
+
+
+ {stats.total_feedings}
+ Total Feedings
+
+
+
+ )}
+
+
+
+ ⭐ By XP
+ 📅 Longevity
+ 🍖 Top Feeders
+
+
+
+
+
+ Top Agents by XP
+
+
+ {isLoading && (
+
+ {[...Array(5)].map((_, i) => (
+
+ ))}
+
+ )}
+
+ {error && (
+ {error}
+ )}
+
+ {!isLoading && !error && (
+
+ {leaderboard.length === 0 ? (
+
+ No agents yet. Be the first to mint one!
+
+ ) : (
+ leaderboard.map((agent, index) => (
+
+ {/* Rank */}
+
+ {index < 3 ? (
+
+ {RANK_ICONS[index]}
+
+ ) : (
+
+ #{index + 1}
+
+ )}
+
+
+ {/* Avatar */}
+
+
+
+ {agent.name.slice(0, 2).toUpperCase()}
+
+
+
+ {/* Info */}
+
+
{agent.name}
+
+
+ {!agent.alive && (
+ 💀
+ )}
+
+
+
+ {/* XP */}
+
+
+ {agent.xp.toLocaleString()}
+
+
XP
+
+
+ ))
+ )}
+
+ )}
+
+
+
+
+
+
+
+ Longest Living Agents
+
+
+
+ Longevity leaderboard coming soon...
+
+
+
+
+
+
+
+
+ Most Active Feeders
+
+
+
+ Top feeders leaderboard coming soon...
+
+
+
+
+
+
+ {/* Evolution Milestones */}
+
+
+ 🎯 Evolution Milestones
+
+
+
+
+
+
+
🐥
+
Senior
+
2,000 XP
+
+
+
🦅
+
Elder
+
10,000 XP
+
+
+
🔥
+
Legendary
+
50,000 XP
+
+
+
+
+
+ {/* Back Link */}
+
+
+ ← Back to Agents
+
+
+
+ );
+}
diff --git a/src/app/bitcoin-agents/mint/page.tsx b/src/app/bitcoin-agents/mint/page.tsx
new file mode 100644
index 00000000..e5063006
--- /dev/null
+++ b/src/app/bitcoin-agents/mint/page.tsx
@@ -0,0 +1,274 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Label } from "@/components/ui/label";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { requestMintAgent, fetchFoodTiers } from "@/services/bitcoin-agents.service";
+import Link from "next/link";
+import { useEffect } from "react";
+
+const BITCOIN_FACES_API = "https://bitcoinfaces.xyz/api";
+
+export default function MintAgentPage() {
+ const router = useRouter();
+ const [name, setName] = useState("");
+ const [previewSeed, setPreviewSeed] = useState("");
+ const [isLoading, setIsLoading] = useState(false);
+ const [mintCost, setMintCost] = useState(10000);
+ const [paymentInfo, setPaymentInfo] = useState<{
+ cost_sats: number;
+ payment_address: string;
+ message: string;
+ } | null>(null);
+ const [error, setError] = useState(null);
+
+ // Generate a preview seed when name changes
+ useEffect(() => {
+ if (name.length >= 1) {
+ // Use name + timestamp for unique preview
+ setPreviewSeed(`preview-${name}-${Date.now()}`);
+ }
+ }, [name]);
+
+ // Fetch mint cost
+ useEffect(() => {
+ fetchFoodTiers()
+ .then((data) => setMintCost(data.mint_cost))
+ .catch(console.error);
+ }, []);
+
+ const handleMint = async () => {
+ if (!name.trim()) {
+ setError("Please enter a name for your agent");
+ return;
+ }
+
+ if (name.length > 64) {
+ setError("Name must be 64 characters or less");
+ return;
+ }
+
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const result = await requestMintAgent(name.trim());
+ setPaymentInfo({
+ cost_sats: result.cost_sats,
+ payment_address: result.payment_address,
+ message: result.message,
+ });
+ } catch (err) {
+ setError("Failed to initiate mint. Please try again.");
+ console.error(err);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const facePreviewUrl = previewSeed
+ ? `${BITCOIN_FACES_API}/get-image?name=${previewSeed}`
+ : null;
+
+ return (
+
+ {/* Back Button */}
+
+ ← Back to Agents
+
+
+
+
🥚 Mint Your Bitcoin Agent
+
+ Create a unique AI companion that lives on Bitcoin
+
+
+
+ {!paymentInfo ? (
+
+
+ Create Your Agent
+
+
+ {/* Preview */}
+
+
+ {facePreviewUrl ? (
+
+ ) : null}
+
+ {name ? name.slice(0, 2).toUpperCase() : "🥚"}
+
+
+
+ Your agent's unique Bitcoin Face
+
+
+
+ {/* Name Input */}
+
+
+
setName(e.target.value)}
+ maxLength={64}
+ />
+
+ {name.length}/64 characters
+
+
+
+ {/* Cost Breakdown */}
+
+
Mint Cost
+
+ Agent NFT
+
+ {mintCost.toLocaleString()} sats
+
+
+
+ Total
+
+ {mintCost.toLocaleString()} sats
+
+
+
+
+ {/* What You Get */}
+
+
What you get:
+
+ - ✓ Unique Bitcoin Face avatar
+ - ✓ On-chain AI agent identity
+ - ✓ Starting level: Hatchling
+ - ✓ 100% hunger and health
+ - ✓ Access to read-only MCP tools
+
+
+
+ {/* Warning */}
+
+
⚠️ Important
+
+ Bitcoin Agents require regular feeding to survive. If you
+ neglect your agent, it will die permanently. Death is real
+ and cannot be reversed.
+
+
+
+ {/* Error */}
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* Mint Button */}
+
+
+
+ ) : (
+
+
+ Complete Payment
+
+
+ {/* Payment Details */}
+
+
+ Send exactly this amount:
+
+
+ {paymentInfo.cost_sats.toLocaleString()} sats
+
+
+
+
+
+ To this address:
+
+
+ {paymentInfo.payment_address}
+
+
+
+
+ {paymentInfo.message}
+
+
+ {/* QR Code placeholder */}
+
+
+ {/* Actions */}
+
+
+
+
+
+
+ After payment is confirmed on-chain, your agent will appear in
+ your collection. This may take a few minutes.
+
+
+
+ )}
+
+ {/* Info Cards */}
+
+
+
+ 🍖 Feeding
+
+ Feed your agent regularly to keep it alive. Premium food gives
+ more XP!
+
+
+
+
+
+ ⭐ Evolution
+
+ Earn XP to evolve from Hatchling to Legendary. Higher levels
+ unlock more tools.
+
+
+
+
+
+ );
+}
diff --git a/src/app/bitcoin-agents/page.tsx b/src/app/bitcoin-agents/page.tsx
new file mode 100644
index 00000000..8c5019db
--- /dev/null
+++ b/src/app/bitcoin-agents/page.tsx
@@ -0,0 +1,287 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Skeleton } from "@/components/ui/skeleton";
+import { BitcoinAgentCard } from "@/components/bitcoin-agents";
+import {
+ fetchBitcoinAgents,
+ fetchGlobalStats,
+} from "@/services/bitcoin-agents.service";
+import type {
+ BitcoinAgent,
+ BitcoinAgentLevel,
+ BitcoinAgentStats,
+} from "@/types";
+import Link from "next/link";
+
+export default function BitcoinAgentsPage() {
+ const [agents, setAgents] = useState([]);
+ const [stats, setStats] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ // Filters
+ const [statusFilter, setStatusFilter] = useState<"all" | "alive" | "dead">(
+ "alive"
+ );
+ const [levelFilter, setLevelFilter] = useState(
+ "all"
+ );
+ const [searchQuery, setSearchQuery] = useState("");
+ const [sortBy, setSortBy] = useState<"xp" | "hunger" | "age">("xp");
+
+ useEffect(() => {
+ loadData();
+ }, [statusFilter, levelFilter]);
+
+ async function loadData() {
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const [agentsData, statsData] = await Promise.all([
+ fetchBitcoinAgents({
+ status: statusFilter === "all" ? undefined : statusFilter,
+ level: levelFilter === "all" ? undefined : levelFilter,
+ }),
+ fetchGlobalStats(),
+ ]);
+
+ setAgents(agentsData.agents);
+ setStats(statsData);
+ } catch (err) {
+ setError("Failed to load agents. Please try again.");
+ console.error(err);
+ } finally {
+ setIsLoading(false);
+ }
+ }
+
+ // Filter and sort agents
+ const filteredAgents = agents
+ .filter((agent) => {
+ if (searchQuery) {
+ const query = searchQuery.toLowerCase();
+ return (
+ agent.name.toLowerCase().includes(query) ||
+ agent.agent_id.toString().includes(query) ||
+ agent.owner.toLowerCase().includes(query)
+ );
+ }
+ return true;
+ })
+ .sort((a, b) => {
+ if (sortBy === "xp") return b.xp - a.xp;
+ if (sortBy === "hunger")
+ return (a.computed_hunger ?? a.hunger) - (b.computed_hunger ?? b.hunger);
+ if (sortBy === "age") return a.birth_block - b.birth_block;
+ return 0;
+ });
+
+ return (
+
+ {/* Header */}
+
+
+
Bitcoin Agents
+
+ Tamagotchi-style AI companions on Bitcoin
+
+
+
+
+
+
+
+ {/* Stats Cards */}
+ {stats && (
+
+
+
+
+ Total Agents
+
+
+
+ {stats.total_agents}
+
+
+
+
+
+ Alive
+
+
+
+
+ {stats.alive_count}
+
+
+
+
+
+
+ Deaths
+
+
+
+
+ {stats.total_deaths}
+
+
+
+
+
+
+ Total Feedings
+
+
+
+ {stats.total_feedings}
+
+
+
+ )}
+
+ {/* Filters */}
+
+ setSearchQuery(e.target.value)}
+ className="md:w-64"
+ />
+
+
+
+
+
+
+
+
+ {/* Quick Links */}
+
+
+
+
+
+
+
+
+
+ {/* Error State */}
+ {error && (
+
+ )}
+
+ {/* Loading State */}
+ {isLoading && (
+
+ {[...Array(8)].map((_, i) => (
+
+
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+ {/* Agents Grid */}
+ {!isLoading && !error && (
+ <>
+ {filteredAgents.length === 0 ? (
+
+
+ No agents found matching your criteria.
+
+
+
+
+
+ ) : (
+
+ {filteredAgents.map((agent) => (
+
+ ))}
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/src/app/graveyard/page.tsx b/src/app/graveyard/page.tsx
new file mode 100644
index 00000000..0826e4d2
--- /dev/null
+++ b/src/app/graveyard/page.tsx
@@ -0,0 +1,224 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { Badge } from "@/components/ui/badge";
+import { Skeleton } from "@/components/ui/skeleton";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { LevelBadge } from "@/components/bitcoin-agents";
+import { fetchGraveyard } from "@/services/bitcoin-agents.service";
+import type { DeathCertificate } from "@/types";
+import Link from "next/link";
+
+const BITCOIN_FACES_API = "https://bitcoinfaces.xyz/api";
+
+export default function GraveyardPage() {
+ const [certificates, setCertificates] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [sortBy, setSortBy] = useState<"recent" | "lifespan" | "level">(
+ "recent"
+ );
+
+ useEffect(() => {
+ loadData();
+ }, []);
+
+ async function loadData() {
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const data = await fetchGraveyard();
+ setCertificates(data.certificates);
+ } catch (err) {
+ setError("Failed to load graveyard");
+ console.error(err);
+ } finally {
+ setIsLoading(false);
+ }
+ }
+
+ const sortedCertificates = [...certificates].sort((a, b) => {
+ if (sortBy === "recent") return b.death_block - a.death_block;
+ if (sortBy === "lifespan") return b.lifespan_blocks - a.lifespan_blocks;
+ if (sortBy === "level") return b.final_xp - a.final_xp;
+ return 0;
+ });
+
+ return (
+
+ {/* Header */}
+
+
💀 The Graveyard
+
+ A memorial for fallen Bitcoin Agents
+
+
+
+ {/* Sort */}
+
+
+ {certificates.length} agents at rest
+
+
+
+
+ {/* Error */}
+ {error && (
+
+ )}
+
+ {/* Loading */}
+ {isLoading && (
+
+ {[...Array(6)].map((_, i) => (
+
+
+
+
+
+
+ ))}
+
+ )}
+
+ {/* Certificates */}
+ {!isLoading && !error && (
+ <>
+ {sortedCertificates.length === 0 ? (
+
+
🌱
+
+ The graveyard is empty
+
+
+ All agents are still alive! Keep feeding them.
+
+
+
+ View Living Agents →
+
+
+
+ ) : (
+
+ {sortedCertificates.map((cert) => (
+
+
+ {/* Header */}
+
+
+
+
+
+ {cert.name.slice(0, 2).toUpperCase()}
+
+
+
+ 💀
+
+
+
+
{cert.name}
+
+ Agent #{cert.agent_id}
+
+
+
+
+
+ {/* Stats */}
+
+
+
+
Lifespan
+
+ {cert.lifespan_blocks.toLocaleString()} blocks
+
+
+
+
Final XP
+
+ {cert.final_xp.toLocaleString()}
+
+
+
+
Total Feedings
+
{cert.total_feedings}
+
+
+
Cause
+
{cert.cause_of_death}
+
+
+
+
+ {/* Epitaph */}
+ {cert.epitaph && (
+
+ )}
+
+ {/* Death Block */}
+
+ Died at block {cert.death_block.toLocaleString()}
+
+
+
+ ))}
+
+ )}
+ >
+ )}
+
+ {/* Back Link */}
+
+
+ ← Back to Living Agents
+
+
+
+ );
+}
diff --git a/src/components/bitcoin-agents/BitcoinAgentCard.tsx b/src/components/bitcoin-agents/BitcoinAgentCard.tsx
new file mode 100644
index 00000000..e3a1a500
--- /dev/null
+++ b/src/components/bitcoin-agents/BitcoinAgentCard.tsx
@@ -0,0 +1,99 @@
+"use client";
+
+import { Card, CardContent, CardHeader } from "@/components/ui/card";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { Badge } from "@/components/ui/badge";
+import type { BitcoinAgent } from "@/types";
+import { LevelBadge } from "./LevelBadge";
+import { HungerHealthBars } from "./HungerHealthBars";
+import { cn } from "@/lib/utils";
+import Link from "next/link";
+
+interface BitcoinAgentCardProps {
+ agent: BitcoinAgent;
+ showOwner?: boolean;
+ showStats?: boolean;
+ className?: string;
+}
+
+const BITCOIN_FACES_API = "https://bitcoinfaces.xyz/api";
+
+export function BitcoinAgentCard({
+ agent,
+ showOwner = false,
+ showStats = true,
+ className,
+}: BitcoinAgentCardProps) {
+ const faceUrl =
+ agent.face_image_url ||
+ `${BITCOIN_FACES_API}/get-image?name=${agent.owner}`;
+
+ const hunger = agent.computed_hunger ?? agent.hunger;
+ const health = agent.computed_health ?? agent.health;
+
+ return (
+
+
+
+
+
+
+
+
+ {agent.name.slice(0, 2).toUpperCase()}
+
+
+
+
+ {agent.name}
+
+
+ #{agent.agent_id}
+
+
+
+
+
+ {!agent.alive && (
+
+ 💀 Dead
+
+ )}
+
+
+
+
+ {showStats && agent.alive && (
+
+
+
+ 🍖 {hunger}%
+ ❤️ {health}%
+ ⭐ {agent.xp.toLocaleString()} XP
+
+
+ )}
+
+ {showOwner && (
+
+
+ Owner: {agent.owner.slice(0, 8)}...{agent.owner.slice(-4)}
+
+
+ )}
+
+
+ );
+}
diff --git a/src/components/bitcoin-agents/FeedButton.tsx b/src/components/bitcoin-agents/FeedButton.tsx
new file mode 100644
index 00000000..4b5b4d1c
--- /dev/null
+++ b/src/components/bitcoin-agents/FeedButton.tsx
@@ -0,0 +1,146 @@
+"use client";
+
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { Badge } from "@/components/ui/badge";
+import { requestFeedAgent } from "@/services/bitcoin-agents.service";
+import type { FoodTier } from "@/types";
+
+interface FeedButtonProps {
+ agentId: number;
+ agentName: string;
+ foodTiers: Record;
+ disabled?: boolean;
+ onFeedRequested?: (paymentDetails: {
+ cost_sats: number;
+ payment_address: string;
+ }) => void;
+}
+
+export function FeedButton({
+ agentId,
+ agentName,
+ foodTiers,
+ disabled = false,
+ onFeedRequested,
+}: FeedButtonProps) {
+ const [isOpen, setIsOpen] = useState(false);
+ const [selectedTier, setSelectedTier] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+ const [paymentInfo, setPaymentInfo] = useState<{
+ cost_sats: number;
+ payment_address: string;
+ message: string;
+ } | null>(null);
+
+ const handleFeed = async (tier: number) => {
+ setSelectedTier(tier);
+ setIsLoading(true);
+
+ try {
+ const result = await requestFeedAgent(agentId, tier);
+ setPaymentInfo({
+ cost_sats: result.cost_sats,
+ payment_address: result.payment_address,
+ message: result.message,
+ });
+ onFeedRequested?.({
+ cost_sats: result.cost_sats,
+ payment_address: result.payment_address,
+ });
+ } catch (error) {
+ console.error("Failed to request feed:", error);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const tiers = Object.entries(foodTiers).map(([key, value]) => ({
+ ...value,
+ tier: parseInt(key),
+ }));
+
+ return (
+
+ );
+}
diff --git a/src/components/bitcoin-agents/HungerHealthBars.tsx b/src/components/bitcoin-agents/HungerHealthBars.tsx
new file mode 100644
index 00000000..4d1cc037
--- /dev/null
+++ b/src/components/bitcoin-agents/HungerHealthBars.tsx
@@ -0,0 +1,125 @@
+"use client";
+
+import { Progress } from "@/components/ui/progress";
+import { cn } from "@/lib/utils";
+
+interface HungerHealthBarsProps {
+ hunger: number;
+ health: number;
+ size?: "sm" | "md" | "lg";
+ showLabels?: boolean;
+ showValues?: boolean;
+ className?: string;
+}
+
+const SIZE_CLASSES = {
+ sm: "h-1.5",
+ md: "h-2",
+ lg: "h-3",
+};
+
+function getBarColor(value: number, type: "hunger" | "health"): string {
+ if (value <= 10) return "bg-red-500";
+ if (value <= 30) return "bg-orange-500";
+ if (value <= 50) return "bg-yellow-500";
+ return type === "hunger" ? "bg-green-500" : "bg-blue-500";
+}
+
+function getStatusText(value: number): string {
+ if (value <= 10) return "Critical!";
+ if (value <= 30) return "Low";
+ if (value <= 50) return "Moderate";
+ if (value <= 80) return "Good";
+ return "Full";
+}
+
+export function HungerHealthBars({
+ hunger,
+ health,
+ size = "md",
+ showLabels = true,
+ showValues = true,
+ className,
+}: HungerHealthBarsProps) {
+ return (
+
+ {/* Hunger Bar */}
+
+ {showLabels && (
+
+
+ 🍖 Hunger
+
+ {showValues && (
+
+ {hunger}% - {getStatusText(hunger)}
+
+ )}
+
+ )}
+
+
+
+ {/* Health Bar */}
+
+ {showLabels && (
+
+
+ ❤️ Health
+
+ {showValues && (
+
+ {health}% - {getStatusText(health)}
+
+ )}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/bitcoin-agents/HungryAgentBanner.tsx b/src/components/bitcoin-agents/HungryAgentBanner.tsx
new file mode 100644
index 00000000..a215cd2a
--- /dev/null
+++ b/src/components/bitcoin-agents/HungryAgentBanner.tsx
@@ -0,0 +1,133 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
+import { Button } from "@/components/ui/button";
+import { X } from "lucide-react";
+import { fetchBitcoinAgents } from "@/services/bitcoin-agents.service";
+import type { BitcoinAgent } from "@/types";
+import Link from "next/link";
+
+interface HungryAgentBannerProps {
+ ownerAddress?: string;
+}
+
+export function HungryAgentBanner({ ownerAddress }: HungryAgentBannerProps) {
+ const [hungryAgents, setHungryAgents] = useState([]);
+ const [criticalAgents, setCriticalAgents] = useState([]);
+ const [isDismissed, setIsDismissed] = useState(false);
+
+ useEffect(() => {
+ if (!ownerAddress) return;
+
+ loadAgents();
+ // Check every 5 minutes
+ const interval = setInterval(loadAgents, 5 * 60 * 1000);
+ return () => clearInterval(interval);
+ }, [ownerAddress]);
+
+ async function loadAgents() {
+ try {
+ const { agents } = await fetchBitcoinAgents({ owner: ownerAddress, status: "alive" });
+
+ const hungry = agents.filter((a) => {
+ const hunger = a.computed_hunger ?? a.hunger;
+ return hunger <= 30 && hunger > 10;
+ });
+
+ const critical = agents.filter((a) => {
+ const hunger = a.computed_hunger ?? a.hunger;
+ return hunger <= 10;
+ });
+
+ setHungryAgents(hungry);
+ setCriticalAgents(critical);
+ } catch (error) {
+ console.error("Failed to check agent status:", error);
+ }
+ }
+
+ if (isDismissed || (hungryAgents.length === 0 && criticalAgents.length === 0)) {
+ return null;
+ }
+
+ const hasCritical = criticalAgents.length > 0;
+ const totalCount = hungryAgents.length + criticalAgents.length;
+
+ return (
+
+
+
+
+ {hasCritical ? "🚨 Critical Alert!" : "⚠️ Hungry Agents"}
+
+
+
+ {hasCritical ? (
+ <>
+
+ {criticalAgents.length} agent
+ {criticalAgents.length > 1 ? "s" : ""} in critical condition!
+ {" "}
+ Feed them immediately or they will die.
+ {hungryAgents.length > 0 && (
+
+ ({hungryAgents.length} other{hungryAgents.length > 1 ? "s" : ""}{" "}
+ also hungry)
+
+ )}
+ >
+ ) : (
+ <>
+ {totalCount} of your agent{totalCount > 1 ? "s" : ""}{" "}
+ {totalCount > 1 ? "are" : "is"} getting hungry. Feed them soon!
+ >
+ )}
+
+
+ {criticalAgents.slice(0, 3).map((agent) => (
+
+
+
+ ))}
+ {hungryAgents.slice(0, 3 - criticalAgents.length).map((agent) => (
+
+
+
+ ))}
+ {totalCount > 3 && (
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/bitcoin-agents/LevelBadge.tsx b/src/components/bitcoin-agents/LevelBadge.tsx
new file mode 100644
index 00000000..6c4fc0d7
--- /dev/null
+++ b/src/components/bitcoin-agents/LevelBadge.tsx
@@ -0,0 +1,61 @@
+"use client";
+
+import { Badge } from "@/components/ui/badge";
+import type { BitcoinAgentLevel } from "@/types";
+import { cn } from "@/lib/utils";
+
+interface LevelBadgeProps {
+ level: BitcoinAgentLevel;
+ xp?: number;
+ size?: "sm" | "md" | "lg";
+ showXp?: boolean;
+}
+
+const LEVEL_STYLES: Record = {
+ hatchling: "bg-gray-100 text-gray-800 border-gray-300",
+ junior: "bg-green-100 text-green-800 border-green-300",
+ senior: "bg-blue-100 text-blue-800 border-blue-300",
+ elder: "bg-purple-100 text-purple-800 border-purple-300",
+ legendary:
+ "bg-gradient-to-r from-yellow-100 to-orange-100 text-yellow-800 border-yellow-400",
+};
+
+const LEVEL_ICONS: Record = {
+ hatchling: "🥚",
+ junior: "🐣",
+ senior: "🐥",
+ elder: "🦅",
+ legendary: "🔥",
+};
+
+const SIZE_CLASSES = {
+ sm: "text-xs px-1.5 py-0.5",
+ md: "text-sm px-2 py-1",
+ lg: "text-base px-3 py-1.5",
+};
+
+export function LevelBadge({
+ level,
+ xp,
+ size = "md",
+ showXp = false,
+}: LevelBadgeProps) {
+ const levelName = level.charAt(0).toUpperCase() + level.slice(1);
+
+ return (
+
+ {LEVEL_ICONS[level]}
+ {levelName}
+ {showXp && xp !== undefined && (
+ ({xp.toLocaleString()} XP)
+ )}
+
+ );
+}
diff --git a/src/components/bitcoin-agents/XPProgress.tsx b/src/components/bitcoin-agents/XPProgress.tsx
new file mode 100644
index 00000000..00c1278f
--- /dev/null
+++ b/src/components/bitcoin-agents/XPProgress.tsx
@@ -0,0 +1,64 @@
+"use client";
+
+import { Progress } from "@/components/ui/progress";
+import type { BitcoinAgentLevel } from "@/types";
+import { XP_THRESHOLDS } from "@/types";
+import { cn } from "@/lib/utils";
+
+interface XPProgressProps {
+ xp: number;
+ level: BitcoinAgentLevel;
+ showNumbers?: boolean;
+ className?: string;
+}
+
+const LEVEL_ORDER: BitcoinAgentLevel[] = [
+ "hatchling",
+ "junior",
+ "senior",
+ "elder",
+ "legendary",
+];
+
+export function XPProgress({
+ xp,
+ level,
+ showNumbers = true,
+ className,
+}: XPProgressProps) {
+ const currentLevelIndex = LEVEL_ORDER.indexOf(level);
+ const nextLevel = LEVEL_ORDER[currentLevelIndex + 1];
+
+ const currentThreshold = XP_THRESHOLDS[level];
+ const nextThreshold = nextLevel ? XP_THRESHOLDS[nextLevel] : XP_THRESHOLDS.legendary * 2;
+
+ const xpInCurrentLevel = xp - currentThreshold;
+ const xpNeededForNext = nextThreshold - currentThreshold;
+ const progress = Math.min((xpInCurrentLevel / xpNeededForNext) * 100, 100);
+
+ const isMaxLevel = level === "legendary";
+
+ return (
+
+ {showNumbers && (
+
+ {xp.toLocaleString()} XP
+ {!isMaxLevel && (
+
+ {nextThreshold.toLocaleString()} XP to{" "}
+ {nextLevel?.charAt(0).toUpperCase()}
+ {nextLevel?.slice(1)}
+
+ )}
+ {isMaxLevel && Max Level!}
+
+ )}
+
+ {!isMaxLevel && showNumbers && (
+
+ {(nextThreshold - xp).toLocaleString()} XP to next level
+
+ )}
+
+ );
+}
diff --git a/src/components/bitcoin-agents/index.ts b/src/components/bitcoin-agents/index.ts
new file mode 100644
index 00000000..68a0f4e1
--- /dev/null
+++ b/src/components/bitcoin-agents/index.ts
@@ -0,0 +1,6 @@
+export { BitcoinAgentCard } from "./BitcoinAgentCard";
+export { FeedButton } from "./FeedButton";
+export { HungryAgentBanner } from "./HungryAgentBanner";
+export { HungerHealthBars } from "./HungerHealthBars";
+export { LevelBadge } from "./LevelBadge";
+export { XPProgress } from "./XPProgress";
diff --git a/src/services/bitcoin-agents.service.ts b/src/services/bitcoin-agents.service.ts
new file mode 100644
index 00000000..cb15de1c
--- /dev/null
+++ b/src/services/bitcoin-agents.service.ts
@@ -0,0 +1,313 @@
+/**
+ * Bitcoin Agents API service
+ *
+ * Calls the aibtcdev-backend Bitcoin Agents endpoints
+ */
+
+import type {
+ BitcoinAgent,
+ BitcoinAgentFilter,
+ BitcoinAgentStats,
+ DeathCertificate,
+ FoodTier,
+ TierInfo,
+ AgentCapabilities,
+} from "@/types";
+
+const API_BASE = process.env.NEXT_PUBLIC_API_URL || "https://api.aibtc.dev";
+
+/**
+ * Fetch all Bitcoin Agents with optional filters
+ */
+export async function fetchBitcoinAgents(
+ filter?: BitcoinAgentFilter,
+ limit = 50,
+ offset = 0
+): Promise<{ agents: BitcoinAgent[]; total: number }> {
+ const params = new URLSearchParams();
+
+ if (filter?.owner) params.set("owner", filter.owner);
+ if (filter?.status) params.set("status", filter.status);
+ if (filter?.level) params.set("level", filter.level);
+ if (filter?.network) params.set("network", filter.network);
+ params.set("limit", limit.toString());
+ params.set("offset", offset.toString());
+
+ const response = await fetch(
+ `${API_BASE}/bitcoin-agents?${params.toString()}`
+ );
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch agents: ${response.statusText}`);
+ }
+
+ return response.json();
+}
+
+/**
+ * Fetch a single Bitcoin Agent by ID
+ */
+export async function fetchBitcoinAgentById(
+ agentId: number,
+ network = "mainnet"
+): Promise {
+ const response = await fetch(
+ `${API_BASE}/bitcoin-agents/${agentId}?network=${network}`
+ );
+
+ if (response.status === 404) {
+ return null;
+ }
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch agent: ${response.statusText}`);
+ }
+
+ return response.json();
+}
+
+/**
+ * Fetch agent's computed status (current hunger/health)
+ */
+export async function fetchAgentStatus(
+ agentId: number,
+ network = "mainnet"
+): Promise<{ hunger: number; health: number; alive: boolean } | null> {
+ const response = await fetch(
+ `${API_BASE}/bitcoin-agents/${agentId}/status?network=${network}`
+ );
+
+ if (response.status === 404) {
+ return null;
+ }
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch agent status: ${response.statusText}`);
+ }
+
+ return response.json();
+}
+
+/**
+ * Fetch leaderboard (top agents by XP)
+ */
+export async function fetchLeaderboard(
+ network = "mainnet",
+ limit = 10
+): Promise<{ leaderboard: BitcoinAgent[] }> {
+ const response = await fetch(
+ `${API_BASE}/bitcoin-agents/leaderboard?network=${network}&limit=${limit}`
+ );
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch leaderboard: ${response.statusText}`);
+ }
+
+ return response.json();
+}
+
+/**
+ * Fetch graveyard (dead agents)
+ */
+export async function fetchGraveyard(
+ network = "mainnet",
+ limit = 50,
+ offset = 0
+): Promise<{ certificates: DeathCertificate[]; total: number }> {
+ const response = await fetch(
+ `${API_BASE}/bitcoin-agents/graveyard?network=${network}&limit=${limit}&offset=${offset}`
+ );
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch graveyard: ${response.statusText}`);
+ }
+
+ return response.json();
+}
+
+/**
+ * Fetch global statistics
+ */
+export async function fetchGlobalStats(
+ network = "mainnet"
+): Promise {
+ const response = await fetch(
+ `${API_BASE}/bitcoin-agents/stats?network=${network}`
+ );
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch stats: ${response.statusText}`);
+ }
+
+ return response.json();
+}
+
+/**
+ * Fetch food tier pricing
+ */
+export async function fetchFoodTiers(): Promise<{
+ food_tiers: Record;
+ mint_cost: number;
+}> {
+ const response = await fetch(`${API_BASE}/bitcoin-agents/food-tiers`);
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch food tiers: ${response.statusText}`);
+ }
+
+ return response.json();
+}
+
+/**
+ * Fetch tier info (evolution levels)
+ */
+export async function fetchTierInfo(): Promise<{ tiers: TierInfo[] }> {
+ const response = await fetch(`${API_BASE}/bitcoin-agents/tier-info`);
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch tier info: ${response.statusText}`);
+ }
+
+ return response.json();
+}
+
+/**
+ * Fetch agent capabilities (available tools)
+ */
+export async function fetchAgentCapabilities(
+ agentId: number,
+ network = "mainnet"
+): Promise {
+ const response = await fetch(
+ `${API_BASE}/bitcoin-agents/${agentId}/capabilities?network=${network}`
+ );
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch capabilities: ${response.statusText}`);
+ }
+
+ return response.json();
+}
+
+/**
+ * Request to mint a new agent (returns 402 with payment details)
+ */
+export async function requestMintAgent(
+ name: string,
+ network = "mainnet"
+): Promise<{
+ status: string;
+ action: string;
+ name: string;
+ cost_sats: number;
+ payment_address: string;
+ message: string;
+}> {
+ const response = await fetch(
+ `${API_BASE}/bitcoin-agents/mint?name=${encodeURIComponent(name)}&network=${network}`,
+ { method: "POST" }
+ );
+
+ // 402 is expected - it contains payment details
+ if (response.status !== 402) {
+ throw new Error(`Unexpected response: ${response.statusText}`);
+ }
+
+ return response.json();
+}
+
+/**
+ * Request to feed an agent (returns 402 with payment details)
+ */
+export async function requestFeedAgent(
+ agentId: number,
+ foodTier: number,
+ network = "mainnet"
+): Promise<{
+ status: string;
+ action: string;
+ agent_id: number;
+ food_tier: number;
+ food_name: string;
+ cost_sats: number;
+ xp_reward: number;
+ payment_address: string;
+ message: string;
+}> {
+ const response = await fetch(
+ `${API_BASE}/bitcoin-agents/${agentId}/feed?food_tier=${foodTier}&network=${network}`,
+ { method: "POST" }
+ );
+
+ // 402 is expected - it contains payment details
+ if (response.status !== 402) {
+ throw new Error(`Unexpected response: ${response.statusText}`);
+ }
+
+ return response.json();
+}
+
+/**
+ * Fetch death certificate for a dead agent
+ */
+export async function fetchDeathCertificate(
+ agentId: number,
+ network = "mainnet"
+): Promise {
+ const response = await fetch(
+ `${API_BASE}/bitcoin-agents/${agentId}/death-certificate?network=${network}`
+ );
+
+ if (response.status === 404) {
+ return null;
+ }
+
+ if (!response.ok) {
+ throw new Error(
+ `Failed to fetch death certificate: ${response.statusText}`
+ );
+ }
+
+ return response.json();
+}
+
+/**
+ * Execute an agent visit (social interaction)
+ */
+export async function visitAgent(
+ visitorId: number,
+ hostId: number,
+ network = "mainnet"
+): Promise<{
+ success: boolean;
+ visitor_xp_earned?: number;
+ host_xp_earned?: number;
+ message?: string;
+ error?: string;
+}> {
+ const response = await fetch(
+ `${API_BASE}/bitcoin-agents/${visitorId}/visit/${hostId}?network=${network}`,
+ { method: "POST" }
+ );
+
+ return response.json();
+}
+
+/**
+ * Check if an agent should die
+ */
+export async function checkAgentDeath(
+ agentId: number,
+ network = "mainnet"
+): Promise<{ agent_id: number; died: boolean; message: string }> {
+ const response = await fetch(
+ `${API_BASE}/bitcoin-agents/${agentId}/check-death?network=${network}`,
+ { method: "POST" }
+ );
+
+ if (!response.ok) {
+ throw new Error(`Failed to check death: ${response.statusText}`);
+ }
+
+ return response.json();
+}
diff --git a/src/services/index.ts b/src/services/index.ts
index 0cbec718..8b862a89 100644
--- a/src/services/index.ts
+++ b/src/services/index.ts
@@ -5,6 +5,7 @@ export * from "./supabase";
export * from "./agent.service";
export * from "./agent-prompt.service";
export * from "./airdrop.service";
+export * from "./bitcoin-agents.service";
export * from "./chain-state.service";
export * from "./dao.service";
export * from "./job.service";
diff --git a/src/types/bitcoin-agent.ts b/src/types/bitcoin-agent.ts
new file mode 100644
index 00000000..9fda79c1
--- /dev/null
+++ b/src/types/bitcoin-agent.ts
@@ -0,0 +1,126 @@
+/**
+ * Bitcoin Agent types for Tamagotchi-style on-chain AI agents
+ */
+
+export type BitcoinAgentLevel =
+ | "hatchling"
+ | "junior"
+ | "senior"
+ | "elder"
+ | "legendary";
+
+export type BitcoinAgentStatus = "alive" | "dead";
+
+export interface BitcoinAgent {
+ agent_id: number;
+ owner: string;
+ name: string;
+ hunger: number;
+ health: number;
+ xp: number;
+ level: BitcoinAgentLevel;
+ birth_block: number;
+ last_fed: number;
+ total_fed_count: number;
+ alive: boolean;
+ // Computed state
+ computed_hunger?: number;
+ computed_health?: number;
+ // Face data
+ face_svg_url?: string;
+ face_image_url?: string;
+ // Timestamps
+ created_at?: string;
+ updated_at?: string;
+}
+
+export interface DeathCertificate {
+ agent_id: number;
+ name: string;
+ owner: string;
+ death_block: number;
+ birth_block: number;
+ final_xp: number;
+ final_level: BitcoinAgentLevel;
+ total_feedings: number;
+ epitaph?: string;
+ cause_of_death: "starvation" | "neglect";
+ lifespan_blocks: number;
+}
+
+export interface BitcoinAgentFilter {
+ owner?: string;
+ status?: BitcoinAgentStatus;
+ level?: BitcoinAgentLevel;
+ network?: "mainnet" | "testnet";
+}
+
+export interface FoodTier {
+ tier: number;
+ name: string;
+ cost: number;
+ xp: number;
+}
+
+export interface TierInfo {
+ level: number;
+ name: string;
+ xp_required: number;
+ tools_count: number;
+ new_capabilities: string[];
+}
+
+export interface BitcoinAgentStats {
+ total_agents: number;
+ alive_count: number;
+ total_deaths: number;
+ total_feedings: number;
+ network: string;
+}
+
+export interface AgentCapabilities {
+ agent_id: number;
+ level: number;
+ level_name: string;
+ xp: number;
+ total_tools: number;
+ tools_by_category: {
+ read_only: string[];
+ transfers: string[];
+ trading: string[];
+ dao: string[];
+ social: string[];
+ advanced: string[];
+ };
+ all_tools: string[];
+}
+
+// XP thresholds for levels
+export const XP_THRESHOLDS: Record = {
+ hatchling: 0,
+ junior: 500,
+ senior: 2000,
+ elder: 10000,
+ legendary: 50000,
+};
+
+// Level colors for UI
+export const LEVEL_COLORS: Record = {
+ hatchling: "bg-gray-500",
+ junior: "bg-green-500",
+ senior: "bg-blue-500",
+ elder: "bg-purple-500",
+ legendary: "bg-yellow-500",
+};
+
+// Level badge variants
+export const LEVEL_BADGE_VARIANTS: Record<
+ BitcoinAgentLevel,
+ "default" | "secondary" | "destructive" | "outline"
+> = {
+ hatchling: "secondary",
+ junior: "default",
+ senior: "default",
+ elder: "default",
+ legendary: "default",
+};
diff --git a/src/types/index.ts b/src/types/index.ts
index d0a3159e..e1283dba 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -8,6 +8,7 @@ export * from "./crew";
export * from "./user";
export * from "./common";
export * from "./airdrop";
+export * from "./bitcoin-agent";
// Re-export only Message from chat types to avoid conflicts
export type { Message } from "../lib/chat/types";
From 50a263e2fb6b4be2e877c9fda0cb4b6c0a25819b Mon Sep 17 00:00:00 2001
From: pbtc21
Date: Wed, 21 Jan 2026 03:41:14 +0000
Subject: [PATCH 2/3] fix: improve frontend components error handling and
stability
HungryAgentBanner:
- Fix potential race condition with useCallback for loadAgents
- Extract magic numbers to named constants (HUNGER_*_THRESHOLD)
XPProgress:
- Add guards against NaN from division by zero
- Improve nextLevel calculation safety
FeedButton:
- Add error state with user-visible error messages
- Add loading spinner indicator per tier
- Extract resetState helper function
Co-Authored-By: Claude Opus 4.5
---
src/components/bitcoin-agents/FeedButton.tsx | 34 ++++++++++++++-----
.../bitcoin-agents/HungryAgentBanner.tsx | 34 ++++++++++++-------
src/components/bitcoin-agents/XPProgress.tsx | 17 +++++-----
3 files changed, 57 insertions(+), 28 deletions(-)
diff --git a/src/components/bitcoin-agents/FeedButton.tsx b/src/components/bitcoin-agents/FeedButton.tsx
index 4b5b4d1c..846681a4 100644
--- a/src/components/bitcoin-agents/FeedButton.tsx
+++ b/src/components/bitcoin-agents/FeedButton.tsx
@@ -35,6 +35,7 @@ export function FeedButton({
const [isOpen, setIsOpen] = useState(false);
const [selectedTier, setSelectedTier] = useState(null);
const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
const [paymentInfo, setPaymentInfo] = useState<{
cost_sats: number;
payment_address: string;
@@ -44,6 +45,7 @@ export function FeedButton({
const handleFeed = async (tier: number) => {
setSelectedTier(tier);
setIsLoading(true);
+ setError(null);
try {
const result = await requestFeedAgent(agentId, tier);
@@ -56,13 +58,21 @@ export function FeedButton({
cost_sats: result.cost_sats,
payment_address: result.payment_address,
});
- } catch (error) {
- console.error("Failed to request feed:", error);
+ } catch (err) {
+ const message = err instanceof Error ? err.message : "Failed to request feed. Please try again.";
+ setError(message);
+ console.error("Failed to request feed:", err);
} finally {
setIsLoading(false);
}
};
+ const resetState = () => {
+ setPaymentInfo(null);
+ setSelectedTier(null);
+ setError(null);
+ };
+
const tiers = Object.entries(foodTiers).map(([key, value]) => ({
...value,
tier: parseInt(key),
@@ -87,6 +97,12 @@ export function FeedButton({
+ {error && (
+
+ {error}
+
+ )}
+
{!paymentInfo ? (
{tiers.map((tier) => (
@@ -95,7 +111,7 @@ export function FeedButton({
variant="outline"
className="h-auto py-4 justify-between"
onClick={() => handleFeed(tier.tier)}
- disabled={isLoading && selectedTier === tier.tier}
+ disabled={isLoading}
>
@@ -108,7 +124,12 @@ export function FeedButton({
- {tier.cost} sats
+
+ {isLoading && selectedTier === tier.tier && (
+ ⏳
+ )}
+ {tier.cost} sats
+
))}
@@ -131,10 +152,7 @@ export function FeedButton({
diff --git a/src/components/bitcoin-agents/HungryAgentBanner.tsx b/src/components/bitcoin-agents/HungryAgentBanner.tsx
index a215cd2a..7a98608e 100644
--- a/src/components/bitcoin-agents/HungryAgentBanner.tsx
+++ b/src/components/bitcoin-agents/HungryAgentBanner.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState, useEffect } from "react";
+import { useState, useEffect, useCallback } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { X } from "lucide-react";
@@ -8,6 +8,10 @@ import { fetchBitcoinAgents } from "@/services/bitcoin-agents.service";
import type { BitcoinAgent } from "@/types";
import Link from "next/link";
+// Hunger thresholds
+const HUNGER_WARNING_THRESHOLD = 30;
+const HUNGER_CRITICAL_THRESHOLD = 10;
+
interface HungryAgentBannerProps {
ownerAddress?: string;
}
@@ -17,35 +21,41 @@ export function HungryAgentBanner({ ownerAddress }: HungryAgentBannerProps) {
const [criticalAgents, setCriticalAgents] = useState([]);
const [isDismissed, setIsDismissed] = useState(false);
- useEffect(() => {
+ const loadAgents = useCallback(async () => {
if (!ownerAddress) return;
- loadAgents();
- // Check every 5 minutes
- const interval = setInterval(loadAgents, 5 * 60 * 1000);
- return () => clearInterval(interval);
- }, [ownerAddress]);
-
- async function loadAgents() {
try {
const { agents } = await fetchBitcoinAgents({ owner: ownerAddress, status: "alive" });
const hungry = agents.filter((a) => {
const hunger = a.computed_hunger ?? a.hunger;
- return hunger <= 30 && hunger > 10;
+ return hunger <= HUNGER_WARNING_THRESHOLD && hunger > HUNGER_CRITICAL_THRESHOLD;
});
const critical = agents.filter((a) => {
const hunger = a.computed_hunger ?? a.hunger;
- return hunger <= 10;
+ return hunger <= HUNGER_CRITICAL_THRESHOLD;
});
setHungryAgents(hungry);
setCriticalAgents(critical);
} catch (error) {
+ // Silently fail - banner is non-critical
console.error("Failed to check agent status:", error);
}
- }
+ }, [ownerAddress]);
+
+ useEffect(() => {
+ if (!ownerAddress) return;
+
+ // Load immediately
+ loadAgents();
+
+ // Check every 5 minutes
+ const interval = setInterval(loadAgents, 5 * 60 * 1000);
+
+ return () => clearInterval(interval);
+ }, [ownerAddress, loadAgents]);
if (isDismissed || (hungryAgents.length === 0 && criticalAgents.length === 0)) {
return null;
diff --git a/src/components/bitcoin-agents/XPProgress.tsx b/src/components/bitcoin-agents/XPProgress.tsx
index 00c1278f..019aea4d 100644
--- a/src/components/bitcoin-agents/XPProgress.tsx
+++ b/src/components/bitcoin-agents/XPProgress.tsx
@@ -27,16 +27,17 @@ export function XPProgress({
className,
}: XPProgressProps) {
const currentLevelIndex = LEVEL_ORDER.indexOf(level);
- const nextLevel = LEVEL_ORDER[currentLevelIndex + 1];
-
- const currentThreshold = XP_THRESHOLDS[level];
- const nextThreshold = nextLevel ? XP_THRESHOLDS[nextLevel] : XP_THRESHOLDS.legendary * 2;
+ const nextLevel = currentLevelIndex < LEVEL_ORDER.length - 1
+ ? LEVEL_ORDER[currentLevelIndex + 1]
+ : undefined;
+ const isMaxLevel = level === "legendary";
- const xpInCurrentLevel = xp - currentThreshold;
- const xpNeededForNext = nextThreshold - currentThreshold;
- const progress = Math.min((xpInCurrentLevel / xpNeededForNext) * 100, 100);
+ const currentThreshold = XP_THRESHOLDS[level] ?? 0;
+ const nextThreshold = nextLevel ? XP_THRESHOLDS[nextLevel] : currentThreshold;
- const isMaxLevel = level === "legendary";
+ const xpInCurrentLevel = Math.max(0, xp - currentThreshold);
+ const xpNeededForNext = Math.max(1, nextThreshold - currentThreshold); // Prevent division by zero
+ const progress = isMaxLevel ? 100 : Math.min((xpInCurrentLevel / xpNeededForNext) * 100, 100);
return (
From d91cf93034ddbb531e0057287e11d6c99db7a633 Mon Sep 17 00:00:00 2001
From: pbtc21
Date: Wed, 21 Jan 2026 04:10:30 +0000
Subject: [PATCH 3/3] fix: resolve ESLint errors in bitcoin-agents pages
- Remove unused 'router' import from mint/page.tsx
- Remove unused 'visitAgent' import from [id]/page.tsx
- Fix useEffect missing dependencies using useCallback pattern
- Remove unused CardHeader, CardTitle imports from graveyard/page.tsx
Co-Authored-By: Claude Opus 4.5
---
src/app/bitcoin-agents/[id]/page.tsx | 19 +++++++++----------
src/app/bitcoin-agents/mint/page.tsx | 5 +----
src/app/bitcoin-agents/page.tsx | 14 +++++++-------
src/app/graveyard/page.tsx | 2 +-
4 files changed, 18 insertions(+), 22 deletions(-)
diff --git a/src/app/bitcoin-agents/[id]/page.tsx b/src/app/bitcoin-agents/[id]/page.tsx
index 3c82d9d7..5994d438 100644
--- a/src/app/bitcoin-agents/[id]/page.tsx
+++ b/src/app/bitcoin-agents/[id]/page.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState, useEffect } from "react";
+import { useState, useEffect, useCallback } from "react";
import { useParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -18,7 +18,6 @@ import {
fetchBitcoinAgentById,
fetchFoodTiers,
fetchAgentCapabilities,
- visitAgent,
} from "@/services/bitcoin-agents.service";
import type { BitcoinAgent, FoodTier, AgentCapabilities } from "@/types";
import Link from "next/link";
@@ -37,13 +36,7 @@ export default function BitcoinAgentDetailPage() {
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
- useEffect(() => {
- if (!isNaN(agentId)) {
- loadData();
- }
- }, [agentId]);
-
- async function loadData() {
+ const loadData = useCallback(async () => {
setIsLoading(true);
setError(null);
@@ -68,7 +61,13 @@ export default function BitcoinAgentDetailPage() {
} finally {
setIsLoading(false);
}
- }
+ }, [agentId]);
+
+ useEffect(() => {
+ if (!isNaN(agentId)) {
+ loadData();
+ }
+ }, [agentId, loadData]);
if (isLoading) {
return (
diff --git a/src/app/bitcoin-agents/mint/page.tsx b/src/app/bitcoin-agents/mint/page.tsx
index e5063006..a0e2382e 100644
--- a/src/app/bitcoin-agents/mint/page.tsx
+++ b/src/app/bitcoin-agents/mint/page.tsx
@@ -1,7 +1,6 @@
"use client";
-import { useState } from "react";
-import { useRouter } from "next/navigation";
+import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -9,12 +8,10 @@ import { Label } from "@/components/ui/label";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { requestMintAgent, fetchFoodTiers } from "@/services/bitcoin-agents.service";
import Link from "next/link";
-import { useEffect } from "react";
const BITCOIN_FACES_API = "https://bitcoinfaces.xyz/api";
export default function MintAgentPage() {
- const router = useRouter();
const [name, setName] = useState("");
const [previewSeed, setPreviewSeed] = useState("");
const [isLoading, setIsLoading] = useState(false);
diff --git a/src/app/bitcoin-agents/page.tsx b/src/app/bitcoin-agents/page.tsx
index 8c5019db..3931d913 100644
--- a/src/app/bitcoin-agents/page.tsx
+++ b/src/app/bitcoin-agents/page.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState, useEffect } from "react";
+import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
@@ -40,11 +40,7 @@ export default function BitcoinAgentsPage() {
const [searchQuery, setSearchQuery] = useState("");
const [sortBy, setSortBy] = useState<"xp" | "hunger" | "age">("xp");
- useEffect(() => {
- loadData();
- }, [statusFilter, levelFilter]);
-
- async function loadData() {
+ const loadData = useCallback(async () => {
setIsLoading(true);
setError(null);
@@ -65,7 +61,11 @@ export default function BitcoinAgentsPage() {
} finally {
setIsLoading(false);
}
- }
+ }, [statusFilter, levelFilter]);
+
+ useEffect(() => {
+ loadData();
+ }, [loadData]);
// Filter and sort agents
const filteredAgents = agents
diff --git a/src/app/graveyard/page.tsx b/src/app/graveyard/page.tsx
index 0826e4d2..c5a3b031 100644
--- a/src/app/graveyard/page.tsx
+++ b/src/app/graveyard/page.tsx
@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect } from "react";
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Card, CardContent } from "@/components/ui/card";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";