diff --git a/components/bounty-detail/bounty-detail-client.tsx b/components/bounty-detail/bounty-detail-client.tsx index 3e93b7fa..cda5bb15 100644 --- a/components/bounty-detail/bounty-detail-client.tsx +++ b/components/bounty-detail/bounty-detail-client.tsx @@ -18,6 +18,7 @@ import { RefundStatusTracker } from "../bounty/refund-status"; import { FeeCalculator } from "../bounty/fee-calculator"; import { useEscrowPool } from "@/hooks/use-escrow"; import { authClient } from "@/lib/auth-client"; +import { useWalletAddress } from "@/hooks/use-wallet-address"; import { useDeadlinePassed } from "@/hooks/use-deadline-passed"; import type { CancellationRecord } from "@/types/escrow"; import { MilestoneFunnel } from "@/components/bounty/milestone-funnel"; @@ -87,6 +88,8 @@ export function BountyDetailClient({ bountyId }: { bountyId: string }) { }, []); const pastDeadline = useDeadlinePassed(bounty?.bountyWindow?.endDate); + // walletAddress is required for contract actions. Do NOT fallback to user.id. + const walletAddress = useWalletAddress() ?? ""; if (isPending) return ; @@ -145,9 +148,6 @@ export function BountyDetailClient({ bountyId }: { bountyId: string }) { const isCreator = (session?.user as { id?: string } | undefined)?.id === bounty.createdBy; const isFinalized = bounty.status === "COMPLETED"; - // walletAddress is required for contract actions. Do NOT fallback to user.id. - const walletAddress = - (session?.user as { walletAddress?: string })?.walletAddress || ""; // Identify if the current user is the assigned contributor // using a fallback check on submissions or assumed backend field. diff --git a/components/bounty/competition-judging.tsx b/components/bounty/competition-judging.tsx index 36e45bbb..30e9a8ba 100644 --- a/components/bounty/competition-judging.tsx +++ b/components/bounty/competition-judging.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import { Loader2, Trophy, Award, CheckCircle2, Lock } from "lucide-react"; import { toast } from "sonner"; -import { authClient } from "@/lib/auth-client"; +import { useWalletAddress } from "@/hooks/use-wallet-address"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -45,7 +45,7 @@ export function CompetitionJudging({ totalReward, currency, }: CompetitionJudgingProps) { - const { data: session } = authClient.useSession(); + const walletAddress = useWalletAddress(); const approveMutation = useApproveContestWinner(); const finalizeMutation = useFinalizeContest(); @@ -55,13 +55,6 @@ export function CompetitionJudging({ // On next query invalidation the backend status takes over. const [localApproved, setLocalApproved] = useState>(new Set()); - const walletAddress = - (session?.user as { walletAddress?: string; address?: string } | undefined) - ?.walletAddress || - (session?.user as { walletAddress?: string; address?: string } | undefined) - ?.address || - null; - const handleApprove = async (sub: Submission) => { if (!walletAddress) { toast.error("Connect your wallet to approve."); diff --git a/components/bounty/competition-submission.tsx b/components/bounty/competition-submission.tsx index 669c07b7..ef59f712 100644 --- a/components/bounty/competition-submission.tsx +++ b/components/bounty/competition-submission.tsx @@ -3,7 +3,7 @@ import { useState, useEffect } from "react"; import { Loader2, Lock, Send, Clock } from "lucide-react"; import { toast } from "sonner"; -import { authClient } from "@/lib/auth-client"; +import { useWalletAddress } from "@/hooks/use-wallet-address"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; import { Label } from "@/components/ui/label"; @@ -40,7 +40,7 @@ export function CompetitionSubmission({ deadline, hasJoined, }: CompetitionSubmissionProps) { - const { data: session } = authClient.useSession(); + const walletAddress = useWalletAddress(); const [workCid, setWorkCid] = useState(""); const submitMutation = useSubmitContestWork(); const isPastDeadline = useDeadlinePassed(deadline); @@ -55,13 +55,6 @@ export function CompetitionSubmission({ return () => clearInterval(id); }, [deadline]); - const walletAddress = - (session?.user as { walletAddress?: string; address?: string } | undefined) - ?.walletAddress || - (session?.user as { walletAddress?: string; address?: string } | undefined) - ?.address || - null; - if (!hasJoined) return null; const trimmed = workCid.trim(); diff --git a/components/bounty/fcfs-approval-panel.tsx b/components/bounty/fcfs-approval-panel.tsx index df066bc8..9574bcbe 100644 --- a/components/bounty/fcfs-approval-panel.tsx +++ b/components/bounty/fcfs-approval-panel.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { Loader2 } from "lucide-react"; import { toast } from "sonner"; import { authClient } from "@/lib/auth-client"; +import { useWalletAddress } from "@/hooks/use-wallet-address"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -29,12 +30,7 @@ export function FcfsApprovalPanel({ bounty }: { bounty: FcfsApprovalBounty }) { const [points, setPoints] = useState(10); const currentUserId = (session?.user as { id?: string } | undefined)?.id; - const walletAddress = - (session?.user as { walletAddress?: string; address?: string } | undefined) - ?.walletAddress || - (session?.user as { walletAddress?: string; address?: string } | undefined) - ?.address || - null; + const walletAddress = useWalletAddress(); const isCreator = Boolean( currentUserId && currentUserId === bounty.createdBy, diff --git a/components/bounty/fcfs-claim-button.tsx b/components/bounty/fcfs-claim-button.tsx index ba7e2232..7578e680 100644 --- a/components/bounty/fcfs-claim-button.tsx +++ b/components/bounty/fcfs-claim-button.tsx @@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react"; import { AlertTriangle, Clock3, Loader2 } from "lucide-react"; import { toast } from "sonner"; import { authClient } from "@/lib/auth-client"; +import { useWalletAddress } from "@/hooks/use-wallet-address"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -73,12 +74,7 @@ export function FcfsClaimButton({ bounty }: { bounty: FcfsBounty }) { }, []); const currentUserId = (session?.user as { id?: string } | undefined)?.id; - const walletAddress = - (session?.user as { walletAddress?: string; address?: string } | undefined) - ?.walletAddress || - (session?.user as { walletAddress?: string; address?: string } | undefined) - ?.address || - null; + const walletAddress = useWalletAddress(); const isFcfs = bounty.type === "FIXED_PRICE"; const isOpen = bounty.status === "OPEN"; diff --git a/hooks/__tests__/use-wallet-address.test.ts b/hooks/__tests__/use-wallet-address.test.ts new file mode 100644 index 00000000..3124d61a --- /dev/null +++ b/hooks/__tests__/use-wallet-address.test.ts @@ -0,0 +1,49 @@ +import { renderHook } from "@testing-library/react"; +import { useWalletAddress } from "../use-wallet-address"; +import { authClient } from "@/lib/auth-client"; + +jest.mock("@/lib/auth-client", () => ({ + authClient: { + useSession: jest.fn(), + }, +})); + +describe("useWalletAddress", () => { + it("returns null when there is no session", () => { + (authClient.useSession as jest.Mock).mockReturnValue({ data: null }); + const { result } = renderHook(() => useWalletAddress()); + expect(result.current).toBeNull(); + }); + + it("returns null when session has no user", () => { + (authClient.useSession as jest.Mock).mockReturnValue({ + data: { user: undefined }, + }); + const { result } = renderHook(() => useWalletAddress()); + expect(result.current).toBeNull(); + }); + + it("returns walletAddress when available", () => { + (authClient.useSession as jest.Mock).mockReturnValue({ + data: { user: { walletAddress: "0xABC", address: "0xDEF" } }, + }); + const { result } = renderHook(() => useWalletAddress()); + expect(result.current).toBe("0xABC"); + }); + + it("falls back to address when walletAddress is missing", () => { + (authClient.useSession as jest.Mock).mockReturnValue({ + data: { user: { address: "0xDEF" } }, + }); + const { result } = renderHook(() => useWalletAddress()); + expect(result.current).toBe("0xDEF"); + }); + + it("returns null when neither walletAddress nor address exists", () => { + (authClient.useSession as jest.Mock).mockReturnValue({ + data: { user: { name: "test" } }, + }); + const { result } = renderHook(() => useWalletAddress()); + expect(result.current).toBeNull(); + }); +}); diff --git a/hooks/use-competition-join-state.ts b/hooks/use-competition-join-state.ts index f3c5ee21..07787ed9 100644 --- a/hooks/use-competition-join-state.ts +++ b/hooks/use-competition-join-state.ts @@ -2,7 +2,7 @@ import { useState } from "react"; import { toast } from "sonner"; -import { authClient } from "@/lib/auth-client"; +import { useWalletAddress } from "@/hooks/use-wallet-address"; import { useJoinCompetition, ContestError, @@ -22,17 +22,10 @@ interface CompetitionJoinState { export function useCompetitionJoinState( bounty: BountyFieldsFragment & Partial, ): CompetitionJoinState { - const { data: session } = authClient.useSession(); + const walletAddress = useWalletAddress(); const joinMutation = useJoinCompetition(); const [localJoined, setLocalJoined] = useState(false); - const walletAddress = - (session?.user as { walletAddress?: string; address?: string } | undefined) - ?.walletAddress || - (session?.user as { walletAddress?: string; address?: string } | undefined) - ?.address || - null; - const deadline = bounty.bountyWindow?.endDate ?? null; const isPastDeadline = useDeadlinePassed(deadline); diff --git a/hooks/use-wallet-address.ts b/hooks/use-wallet-address.ts new file mode 100644 index 00000000..676b21b4 --- /dev/null +++ b/hooks/use-wallet-address.ts @@ -0,0 +1,15 @@ +"use client"; + +import { authClient } from "@/lib/auth-client"; + +/** + * Returns the connected wallet address from the current session, or null + * if the user is not signed in or has no wallet attached. + */ +export function useWalletAddress(): string | null { + const { data: session } = authClient.useSession(); + const user = session?.user as + | { walletAddress?: string; address?: string } + | undefined; + return user?.walletAddress || user?.address || null; +}