From a29cc5b108da27217c581f3569be8c1d849518b0 Mon Sep 17 00:00:00 2001 From: tusharshah21 Date: Wed, 22 Oct 2025 18:04:39 +0000 Subject: [PATCH] feat: Add badge hierarchy and voting system --- frontend/src/components/badges/BadgesList.tsx | 43 +++++++++- frontend/src/hooks/badges/use-add-pointers.ts | 28 +++++++ .../src/hooks/badges/use-get-badge-details.ts | 53 +++++++++++++ frontend/src/hooks/badges/use-get-badges.ts | 6 +- frontend/src/hooks/badges/use-vote-badge.ts | 28 +++++++ frontend/src/lib/abis/badgeRegistryAbi.ts | 57 ++++++++++++++ frontend/src/lib/constants/badgeConstants.ts | 10 +++ frontend/src/lib/types/badges.d.ts | 3 + .../src/TheGuildBadgeRegistry.sol | 78 +++++++++++++++---- .../test/TheGuildBadgeRegistry.t.sol | 70 ++++++++++++++++- 10 files changed, 351 insertions(+), 25 deletions(-) create mode 100644 frontend/src/hooks/badges/use-add-pointers.ts create mode 100644 frontend/src/hooks/badges/use-get-badge-details.ts create mode 100644 frontend/src/hooks/badges/use-vote-badge.ts diff --git a/frontend/src/components/badges/BadgesList.tsx b/frontend/src/components/badges/BadgesList.tsx index cd3a7cf..9b784b0 100644 --- a/frontend/src/components/badges/BadgesList.tsx +++ b/frontend/src/components/badges/BadgesList.tsx @@ -1,5 +1,5 @@ import React, { useMemo, useState } from "react"; -import { BadgeCheck } from "lucide-react"; +import { BadgeCheck, ThumbsUp, ThumbsDown } from "lucide-react"; import { Card, @@ -8,7 +8,9 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; import { useGetBadges } from "@/hooks/badges/use-get-badges"; +import { useVoteBadge } from "@/hooks/badges/use-vote-badge"; import { HARD_CODED_BADGES } from "@/lib/constants/badgeConstants"; import type { Badge } from "@/lib/types/badges"; import { Search } from "lucide-react"; @@ -18,7 +20,8 @@ import { AiOutlineLoading3Quarters } from "react-icons/ai"; import ErrorDisplay from "@/components/displayError/index"; export function BadgesList(): React.ReactElement { - const { data, isLoading, error } = useGetBadges(); + const { data, isLoading, error, refetch } = useGetBadges(); + const { vote, isPending } = useVoteBadge(); const [searchQuery, setSearchQuery] = useState(""); const list = (data && data.length > 0 ? data : HARD_CODED_BADGES) as Badge[]; @@ -28,6 +31,16 @@ export function BadgesList(): React.ReactElement { return list.filter((b) => b.name.toLowerCase().includes(q)); }, [list, searchQuery]); + const handleVote = async (badgeName: string, isUpvote: boolean) => { + try { + await vote(badgeName, isUpvote); + // Refetch badges after voting + refetch(); + } catch (error) { + console.error("Voting failed:", error); + } + }; + if (isLoading) { return (
@@ -69,7 +82,31 @@ export function BadgesList(): React.ReactElement { {badge.description} - + +
+
+ Score: {badge.voteScore || 0} +
+
+ + +
+
+
))}
diff --git a/frontend/src/hooks/badges/use-add-pointers.ts b/frontend/src/hooks/badges/use-add-pointers.ts new file mode 100644 index 0000000..c4289b4 --- /dev/null +++ b/frontend/src/hooks/badges/use-add-pointers.ts @@ -0,0 +1,28 @@ +import { useWriteContract, useWaitForTransactionReceipt } from "wagmi"; +import { badgeRegistryAbi } from "@/lib/abis/badgeRegistryAbi"; +import { BADGE_REGISTRY_ADDRESS } from "@/lib/constants/blockchainConstants"; + +export function useAddPointers() { + const { writeContract, data: hash, isPending, error } = useWriteContract(); + + const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({ + hash, + }); + + const addPointers = (fromBadge: string, toBadges: string[]) => { + writeContract({ + abi: badgeRegistryAbi, + address: BADGE_REGISTRY_ADDRESS, + functionName: "addPointers", + args: [fromBadge as `0x${string}`, toBadges as `0x${string}`[]], + }); + }; + + return { + addPointers, + isPending, + isConfirming, + isSuccess, + error, + }; +} \ No newline at end of file diff --git a/frontend/src/hooks/badges/use-get-badge-details.ts b/frontend/src/hooks/badges/use-get-badge-details.ts new file mode 100644 index 0000000..c9f847e --- /dev/null +++ b/frontend/src/hooks/badges/use-get-badge-details.ts @@ -0,0 +1,53 @@ +import { useMemo } from "react"; +import { useReadContract } from "wagmi"; +import { badgeRegistryAbi } from "@/lib/abis/badgeRegistryAbi"; +import { BADGE_REGISTRY_ADDRESS } from "@/lib/constants/blockchainConstants"; +import type { Badge } from "@/lib/types/badges"; +import { bytes32ToString } from "@/lib/utils/blockchainUtils"; + +export function useGetBadgeDetails(badgeName?: string) { + const address = BADGE_REGISTRY_ADDRESS; + + const badgeQuery = useReadContract({ + abi: badgeRegistryAbi, + address, + functionName: "getBadge", + args: badgeName ? [badgeName as `0x${string}`] : undefined, + query: { + enabled: Boolean(address) && Boolean(badgeName), + }, + }); + + const pointersQuery = useReadContract({ + abi: badgeRegistryAbi, + address, + functionName: "getPointers", + args: badgeName ? [badgeName as `0x${string}`] : undefined, + query: { + enabled: Boolean(address) && Boolean(badgeName), + }, + }); + + const data: Badge | undefined = useMemo(() => { + const badgeResult = badgeQuery.data as + | [`0x${string}`, `0x${string}`, `0x${string}`, bigint] + | undefined; + const pointersResult = pointersQuery.data as `0x${string}`[] | undefined; + + if (!badgeResult) return undefined; + + const [nameBytes, descriptionBytes, creator, voteScore] = badgeResult; + return { + name: bytes32ToString(nameBytes), + description: bytes32ToString(descriptionBytes), + creator, + voteScore: Number(voteScore), + pointers: pointersResult?.map(bytes32ToString), + }; + }, [badgeQuery.data, pointersQuery.data]); + + const isLoading = badgeQuery.isLoading || pointersQuery.isLoading; + const error = badgeQuery.error || pointersQuery.error; + + return { data, isLoading, error, refetch: badgeQuery.refetch }; +} \ No newline at end of file diff --git a/frontend/src/hooks/badges/use-get-badges.ts b/frontend/src/hooks/badges/use-get-badges.ts index 121fa4a..83c493b 100644 --- a/frontend/src/hooks/badges/use-get-badges.ts +++ b/frontend/src/hooks/badges/use-get-badges.ts @@ -47,12 +47,14 @@ export function useGetBadges(): { const data: Badge[] | undefined = useMemo(() => { const results = badgesQuery.data as - | [`0x${string}`, `0x${string}`, `0x${string}`][] + | [`0x${string}`, `0x${string}`, `0x${string}`, bigint][] | undefined; if (!results) return undefined; - return results.map(([nameBytes, descriptionBytes]) => ({ + return results.map(([nameBytes, descriptionBytes, creator, voteScore]) => ({ name: bytes32ToString(nameBytes), description: bytes32ToString(descriptionBytes), + creator, + voteScore: Number(voteScore), })); }, [badgesQuery.data]); diff --git a/frontend/src/hooks/badges/use-vote-badge.ts b/frontend/src/hooks/badges/use-vote-badge.ts new file mode 100644 index 0000000..de22ca8 --- /dev/null +++ b/frontend/src/hooks/badges/use-vote-badge.ts @@ -0,0 +1,28 @@ +import { useWriteContract, useWaitForTransactionReceipt } from "wagmi"; +import { badgeRegistryAbi } from "@/lib/abis/badgeRegistryAbi"; +import { BADGE_REGISTRY_ADDRESS } from "@/lib/constants/blockchainConstants"; + +export function useVoteBadge() { + const { writeContract, data: hash, isPending, error } = useWriteContract(); + + const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({ + hash, + }); + + const vote = (badgeName: string, isUpvote: boolean) => { + writeContract({ + abi: badgeRegistryAbi, + address: BADGE_REGISTRY_ADDRESS, + functionName: "vote", + args: [badgeName as `0x${string}`, isUpvote], + }); + }; + + return { + vote, + isPending, + isConfirming, + isSuccess, + error, + }; +} \ No newline at end of file diff --git a/frontend/src/lib/abis/badgeRegistryAbi.ts b/frontend/src/lib/abis/badgeRegistryAbi.ts index 6e79c27..3757097 100644 --- a/frontend/src/lib/abis/badgeRegistryAbi.ts +++ b/frontend/src/lib/abis/badgeRegistryAbi.ts @@ -16,8 +16,45 @@ export const badgeRegistryAbi = [ { name: "", type: "bytes32" }, { name: "", type: "bytes32" }, { name: "", type: "address" }, + { name: "", type: "int256" }, ], }, + { + type: "function", + name: "getBadge", + stateMutability: "view", + inputs: [{ name: "name", type: "bytes32" }], + outputs: [ + { name: "", type: "bytes32" }, + { name: "", type: "bytes32" }, + { name: "", type: "address" }, + { name: "", type: "int256" }, + ], + }, + { + type: "function", + name: "getPointers", + stateMutability: "view", + inputs: [{ name: "name", type: "bytes32" }], + outputs: [{ name: "", type: "bytes32[]" }], + }, + { + type: "function", + name: "getVoteScore", + stateMutability: "view", + inputs: [{ name: "name", type: "bytes32" }], + outputs: [{ name: "", type: "int256" }], + }, + { + type: "function", + name: "hasVoted", + stateMutability: "view", + inputs: [ + { name: "badgeName", type: "bytes32" }, + { name: "voter", type: "address" }, + ], + outputs: [{ name: "", type: "bool" }], + }, { type: "function", name: "createBadge", @@ -28,4 +65,24 @@ export const badgeRegistryAbi = [ ], outputs: [], }, + { + type: "function", + name: "addPointers", + stateMutability: "nonpayable", + inputs: [ + { name: "fromBadge", type: "bytes32" }, + { name: "toBadges", type: "bytes32[]" }, + ], + outputs: [], + }, + { + type: "function", + name: "vote", + stateMutability: "nonpayable", + inputs: [ + { name: "badgeName", type: "bytes32" }, + { name: "isUpvote", type: "bool" }, + ], + outputs: [], + }, ] as const; diff --git a/frontend/src/lib/constants/badgeConstants.ts b/frontend/src/lib/constants/badgeConstants.ts index f0ec9de..4c89fae 100644 --- a/frontend/src/lib/constants/badgeConstants.ts +++ b/frontend/src/lib/constants/badgeConstants.ts @@ -4,23 +4,33 @@ export const HARD_CODED_BADGES: Badge[] = [ { name: "Open Source Contributor", description: "Contributed code to The Guild Genesis repositories.", + creator: "0x0000000000000000000000000000000000000000", + voteScore: 0, }, { name: "Smart Contract Deployer", description: "Successfully deployed a smart contract to testnet or mainnet.", + creator: "0x0000000000000000000000000000000000000000", + voteScore: 0, }, { name: "Community Mentor", description: "Helped onboard and mentor new members of the community.", + creator: "0x0000000000000000000000000000000000000000", + voteScore: 0, }, { name: "Bug Hunter", description: "Reported and helped resolve significant issues or vulnerabilities.", + creator: "0x0000000000000000000000000000000000000000", + voteScore: 0, }, { name: "Docs Champion", description: "Improved documentation or tutorials for the project.", + creator: "0x0000000000000000000000000000000000000000", + voteScore: 0, }, ]; diff --git a/frontend/src/lib/types/badges.d.ts b/frontend/src/lib/types/badges.d.ts index 35b700f..e3ce187 100644 --- a/frontend/src/lib/types/badges.d.ts +++ b/frontend/src/lib/types/badges.d.ts @@ -1,4 +1,7 @@ export type Badge = { name: string; description: string; + creator: string; + voteScore: number; + pointers?: string[]; // optional for backward compatibility }; diff --git a/the-guild-smart-contracts/src/TheGuildBadgeRegistry.sol b/the-guild-smart-contracts/src/TheGuildBadgeRegistry.sol index 966f902..3071ecb 100644 --- a/the-guild-smart-contracts/src/TheGuildBadgeRegistry.sol +++ b/the-guild-smart-contracts/src/TheGuildBadgeRegistry.sol @@ -10,6 +10,9 @@ contract TheGuildBadgeRegistry { bytes32 name; bytes32 description; address creator; + bytes32[] pointers; // badges this badge points to (hierarchy) + mapping(address => bool) hasVoted; // prevent double voting + int256 voteScore; // net upvotes - downvotes } /// @notice Emitted when a new badge is created. @@ -34,26 +37,59 @@ contract TheGuildBadgeRegistry { require(name != bytes32(0), "EMPTY_NAME"); require(!nameExists[name], "DUPLICATE_NAME"); - Badge memory badge = Badge({ - name: name, - description: description, - creator: msg.sender - }); - nameToBadge[name] = badge; + Badge storage badge = nameToBadge[name]; + badge.name = name; + badge.description = description; + badge.creator = msg.sender; + badge.voteScore = 0; nameExists[name] = true; badgeNames.push(name); emit BadgeCreated(name, description, msg.sender); } - /// @notice Get a badge by its name. - /// @dev Reverts if the badge does not exist. - function getBadge( - bytes32 name - ) external view returns (bytes32, bytes32, address) { + /// @notice Add pointers from this badge to other badges (hierarchy). + /// @param fromBadge The badge that points to others. + /// @param toBadges Array of badge names this badge points to. + function addPointers(bytes32 fromBadge, bytes32[] calldata toBadges) external { + require(nameExists[fromBadge], "FROM_BADGE_NOT_FOUND"); + Badge storage badge = nameToBadge[fromBadge]; + require(badge.creator == msg.sender, "ONLY_CREATOR_CAN_ADD_POINTERS"); + + for (uint256 i = 0; i < toBadges.length; i++) { + require(nameExists[toBadges[i]], "TO_BADGE_NOT_FOUND"); + badge.pointers.push(toBadges[i]); + } + } + /// @param badgeName The badge to vote on. + /// @param isUpvote True for upvote, false for downvote. + function vote(bytes32 badgeName, bool isUpvote) external { + require(nameExists[badgeName], "BADGE_NOT_FOUND"); + Badge storage badge = nameToBadge[badgeName]; + require(!badge.hasVoted[msg.sender], "ALREADY_VOTED"); + + badge.hasVoted[msg.sender] = true; + if (isUpvote) { + badge.voteScore += 1; + } else { + badge.voteScore -= 1; + } + } + + /// @notice Get pointers for a badge. + /// @param name The badge name. + /// @return Array of badge names this badge points to. + function getPointers(bytes32 name) external view returns (bytes32[] memory) { require(nameExists[name], "NOT_FOUND"); - Badge memory b = nameToBadge[name]; - return (b.name, b.description, b.creator); + return nameToBadge[name].pointers; + } + + /// @notice Get vote score for a badge. + /// @param name The badge name. + /// @return The net vote score (upvotes - downvotes). + function getVoteScore(bytes32 name) external view returns (int256) { + require(nameExists[name], "NOT_FOUND"); + return nameToBadge[name].voteScore; } /// @notice Get whether a badge name exists. @@ -61,6 +97,16 @@ contract TheGuildBadgeRegistry { return nameExists[name]; } + /// @notice Get a badge by its name. + /// @dev Reverts if the badge does not exist. + function getBadge( + bytes32 name + ) external view returns (bytes32, bytes32, address, int256) { + require(nameExists[name], "NOT_FOUND"); + Badge storage b = nameToBadge[name]; + return (b.name, b.description, b.creator, b.voteScore); + } + /// @notice Total number of badges created. function totalBadges() external view returns (uint256) { return badgeNames.length; @@ -76,9 +122,9 @@ contract TheGuildBadgeRegistry { /// @dev Reverts if index is out of bounds. function getBadgeAt( uint256 index - ) external view returns (bytes32, bytes32, address) { + ) external view returns (bytes32, bytes32, address, int256) { bytes32 name = badgeNames[index]; - Badge memory b = nameToBadge[name]; - return (b.name, b.description, b.creator); + Badge storage b = nameToBadge[name]; + return (b.name, b.description, b.creator, b.voteScore); } } diff --git a/the-guild-smart-contracts/test/TheGuildBadgeRegistry.t.sol b/the-guild-smart-contracts/test/TheGuildBadgeRegistry.t.sol index a521c40..eef0748 100644 --- a/the-guild-smart-contracts/test/TheGuildBadgeRegistry.t.sol +++ b/the-guild-smart-contracts/test/TheGuildBadgeRegistry.t.sol @@ -24,12 +24,13 @@ contract TheGuildBadgeRegistryTest is Test { registry.createBadge(name, description); - (bytes32 rName, bytes32 rDesc, address creator) = registry.getBadge( + (bytes32 rName, bytes32 rDesc, address creator, int256 voteScore) = registry.getBadge( name ); assertEq(rName, name, "name mismatch"); assertEq(rDesc, description, "description mismatch"); assertEq(creator, address(this), "creator mismatch"); + assertEq(voteScore, 0, "initial vote score should be 0"); assertTrue(registry.exists(name), "should exist"); assertEq(registry.totalBadges(), 1); assertEq(registry.badgeNameAt(0), name); @@ -48,8 +49,69 @@ contract TheGuildBadgeRegistryTest is Test { registry.getBadge(bytes32("MISSING")); } - function test_CreateBadge_RevertOnEmptyName() public { - vm.expectRevert(bytes("EMPTY_NAME")); - registry.createBadge(bytes32(0), bytes32("desc")); + function test_Vote_Upvote_IncreasesScore() public { + bytes32 name = bytes32("VOTE_TEST"); + registry.createBadge(name, bytes32("test")); + + registry.vote(name, true); + + (, , , int256 score) = registry.getBadge(name); + assertEq(score, 1); + } + + function test_Vote_Downvote_DecreasesScore() public { + bytes32 name = bytes32("VOTE_TEST_DOWN"); + registry.createBadge(name, bytes32("test")); + + registry.vote(name, false); + + (, , , int256 score) = registry.getBadge(name); + assertEq(score, -1); + } + + function test_Vote_CannotVoteTwice() public { + bytes32 name = bytes32("VOTE_TWICE"); + registry.createBadge(name, bytes32("test")); + + registry.vote(name, true); + vm.expectRevert(bytes("ALREADY_VOTED")); + registry.vote(name, true); + } + + function test_AddPointers_Succeeds() public { + bytes32 fromName = bytes32("FROM_BADGE"); + bytes32 toName1 = bytes32("TO_BADGE_1"); + bytes32 toName2 = bytes32("TO_BADGE_2"); + + registry.createBadge(fromName, bytes32("from")); + registry.createBadge(toName1, bytes32("to1")); + registry.createBadge(toName2, bytes32("to2")); + + bytes32[] memory pointers = new bytes32[](2); + pointers[0] = toName1; + pointers[1] = toName2; + + registry.addPointers(fromName, pointers); + + bytes32[] memory retrieved = registry.getPointers(fromName); + assertEq(retrieved.length, 2); + assertEq(retrieved[0], toName1); + assertEq(retrieved[1], toName2); + } + + function test_AddPointers_OnlyCreator() public { + bytes32 fromName = bytes32("FROM_BADGE_CREATOR"); + bytes32 toName = bytes32("TO_BADGE_CREATOR"); + + registry.createBadge(fromName, bytes32("from")); + registry.createBadge(toName, bytes32("to")); + + // Try to add pointers as different user + vm.prank(address(1)); + bytes32[] memory pointers = new bytes32[](1); + pointers[0] = toName; + + vm.expectRevert(bytes("ONLY_CREATOR_CAN_ADD_POINTERS")); + registry.addPointers(fromName, pointers); } }