diff --git a/the-guild-smart-contracts/README.md b/the-guild-smart-contracts/README.md index 6707ba6..f67039c 100644 --- a/the-guild-smart-contracts/README.md +++ b/the-guild-smart-contracts/README.md @@ -7,6 +7,9 @@ Anybody can create a badge. The idea is to let the community input whatever they We will create a smart contract TheGuildBadgeRegistry that will have a list of badges with unique non-duplicate names. +### Badge Ranking +Community members can vote on badge relevancy to filter spam and promote the most relevant badges. The BadgeRanking contract tracks upvotes per badge and prevents duplicate voting from the same address. + ### Attestations Then, we let users create an attestation of a badge to another user. The attestation can contain an optional justification (link to a project, or text explanation). @@ -153,6 +156,40 @@ forge script script/TheGuildActivityToken.s.sol:TheGuildActivityTokenScript \ --broadcast ``` +### Badge Ranking + +`TheGuildBadgeRanking` enables voting/ranking of badges for relevancy. Features: + +- Tracks upvotes per badge +- Prevents duplicate voting from the same address on the same badge +- Integrates with `TheGuildBadgeRegistry` to validate badges before voting +- Emits `BadgeUpvoted` event for off-chain indexing + +**Key Functions:** +- `upvoteBadge(bytes32 badgeName)`: Vote for a badge (one vote per address per badge) +- `getUpvotes(bytes32 badgeName)`: Get total upvote count for a badge +- `hasVotedForBadge(bytes32 badgeName, address voter)`: Check if address has voted for a badge + +Deploy individually: + +```shell +# Set BADGE_REGISTRY_ADDRESS to your deployed registry, or use default (Amoy dev) +forge script script/TheGuildBadgeRanking.s.sol:TheGuildBadgeRankingScript \ + --rpc-url \ + --private-key \ + --broadcast +``` + +Or use environment variable: + +```shell +export BADGE_REGISTRY_ADDRESS=0x8ac95734e778322684f1d318fb7633777baa8427 +forge script script/TheGuildBadgeRanking.s.sol:TheGuildBadgeRankingScript \ + --rpc-url \ + --private-key \ + --broadcast +``` + ### Cast ```shell diff --git a/the-guild-smart-contracts/script/FullDeploymentScript.s.sol b/the-guild-smart-contracts/script/FullDeploymentScript.s.sol index 9090698..6972c02 100644 --- a/the-guild-smart-contracts/script/FullDeploymentScript.s.sol +++ b/the-guild-smart-contracts/script/FullDeploymentScript.s.sol @@ -7,6 +7,7 @@ import {AttestationRequestData, AttestationRequest} from "eas-contracts/IEAS.sol import {SchemaRegistry} from "eas-contracts/SchemaRegistry.sol"; import {TheGuildActivityToken} from "../src/TheGuildActivityToken.sol"; import {TheGuildBadgeRegistry} from "../src/TheGuildBadgeRegistry.sol"; +import {TheGuildBadgeRanking} from "../src/TheGuildBadgeRanking.sol"; import {EASUtils} from "./utils/EASUtils.s.sol"; import {console} from "forge-std/console.sol"; @@ -23,17 +24,7 @@ contract FullDeploymentScript is Script { vm.startBroadcast(); // Deploy or attach to existing activity token via CREATE2 - TheGuildActivityToken activityToken; - try new TheGuildActivityToken{salt: salt}(eas) returns ( - TheGuildActivityToken deployed - ) { - activityToken = deployed; - } catch { - // If already deployed with same salt + initCode, attach to the predicted address - activityToken = TheGuildActivityToken( - payable(0x5a79Dd0F66E2C1203948dD49634E506b3D8723A0) - ); - } + TheGuildActivityToken activityToken = new TheGuildActivityToken{salt: salt}(eas); // Register TheGuild Schema string memory schema = "bytes32 badgeName, bytes justification"; @@ -45,31 +36,24 @@ contract FullDeploymentScript is Script { console.logBytes32(schemaId); // Deploy or attach to existing badge registry via CREATE2 - TheGuildBadgeRegistry badgeRegistry; - try new TheGuildBadgeRegistry{salt: salt}() returns ( - TheGuildBadgeRegistry deployed - ) { - badgeRegistry = deployed; + TheGuildBadgeRegistry badgeRegistry = new TheGuildBadgeRegistry{salt: salt}(); - // Create some badges - badgeRegistry.createBadge( - bytes32("Rust"), - bytes32("Know how to code in Rust") - ); - badgeRegistry.createBadge( - bytes32("Solidity"), - bytes32("Know how to code in Solidity") - ); - badgeRegistry.createBadge( - bytes32("TypeScript"), - bytes32("Know how to code in TypeScript") - ); - } catch { - // If already deployed with same salt + initCode, attach to the predicted address - badgeRegistry = TheGuildBadgeRegistry( - 0x8baA0d5135D241bd22a9eB35915300aCfB286307 - ); - } + // Create some badges + badgeRegistry.createBadge( + bytes32("Rust"), + bytes32("Know how to code in Rust") + ); + badgeRegistry.createBadge( + bytes32("Solidity"), + bytes32("Know how to code in Solidity") + ); + badgeRegistry.createBadge( + bytes32("TypeScript"), + bytes32("Know how to code in TypeScript") + ); + + // Deploy or attach to existing badge ranking via CREATE2 + TheGuildBadgeRanking badgeRanking = new TheGuildBadgeRanking{salt: salt}(badgeRegistry); // Create some attestations AttestationRequestData memory data = AttestationRequestData({ diff --git a/the-guild-smart-contracts/script/TheGuildBadgeRanking.s.sol b/the-guild-smart-contracts/script/TheGuildBadgeRanking.s.sol new file mode 100644 index 0000000..d91b2ea --- /dev/null +++ b/the-guild-smart-contracts/script/TheGuildBadgeRanking.s.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {Script} from "forge-std/Script.sol"; +import {TheGuildBadgeRegistry} from "../src/TheGuildBadgeRegistry.sol"; +import {TheGuildBadgeRanking} from "../src/TheGuildBadgeRanking.sol"; + +contract TheGuildBadgeRankingScript is Script { + function run() public { + // Badge registry address - can be provided via env var or defaults to Amoy dev address + address badgeRegistryAddress = vm.envOr( + "BADGE_REGISTRY_ADDRESS", + address(0x8aC95734e778322684f1D318fb7633777baa8427) // Amoy dev registry + ); + + // Salt can be provided via env var or defaults to zero salt + bytes32 salt = bytes32(vm.envOr("CREATE2_SALT", uint256(0))); + + vm.startBroadcast(); + // Pass the address (cast to TheGuildBadgeRegistry) + new TheGuildBadgeRanking{salt: salt}(TheGuildBadgeRegistry(badgeRegistryAddress)); + vm.stopBroadcast(); + } +} \ No newline at end of file diff --git a/the-guild-smart-contracts/src/TheGuildBadgeRanking.sol b/the-guild-smart-contracts/src/TheGuildBadgeRanking.sol new file mode 100644 index 0000000..dc3b954 --- /dev/null +++ b/the-guild-smart-contracts/src/TheGuildBadgeRanking.sol @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {TheGuildBadgeRegistry} from "./TheGuildBadgeRegistry.sol"; + +/// @title TheGuildBadgeRanking +/// @notice Allows voting on badge relevancy to filter spam and rank badges. +contract TheGuildBadgeRanking { + TheGuildBadgeRegistry public badgeRegistry; + + /// @notice Emitted when a badge receives an upvote. + event BadgeUpvoted( + bytes32 indexed badgeName, + address indexed voter + ); + + // badgeName => upvote count + mapping(bytes32 => uint256) public upvotes; + // badgeName => voter => hasVoted + mapping(bytes32 => mapping(address => bool)) private hasVoted; + + constructor(TheGuildBadgeRegistry _badgeRegistry) { + require(address(_badgeRegistry) != address(0), "INVALID_REGISTRY"); + badgeRegistry = _badgeRegistry; + } + + /// @notice Upvote a badge for relevancy. + /// @param badgeName The name of the badge to upvote. + /// @dev Reverts if badge doesn't exist or address has already voted for this badge. + function upvoteBadge(bytes32 badgeName) external { + require(badgeRegistry.exists(badgeName), "BADGE_NOT_FOUND"); + require(!hasVoted[badgeName][msg.sender], "ALREADY_VOTED"); + + hasVoted[badgeName][msg.sender] = true; + upvotes[badgeName]++; + + emit BadgeUpvoted(badgeName, msg.sender); + } + + /// @notice Check if an address has voted for a specific badge. + /// @param badgeName The name of the badge. + /// @param voter The address to check. + function hasVotedForBadge(bytes32 badgeName, address voter) external view returns (bool) { + return hasVoted[badgeName][voter]; + } + + /// @notice Get the upvote count for a badge. + /// @param badgeName The name of the badge. + function getUpvotes(bytes32 badgeName) external view returns (uint256) { + return upvotes[badgeName]; + } +} diff --git a/the-guild-smart-contracts/test/TheGuildBadgeRanking.t.sol b/the-guild-smart-contracts/test/TheGuildBadgeRanking.t.sol new file mode 100644 index 0000000..ba91b97 --- /dev/null +++ b/the-guild-smart-contracts/test/TheGuildBadgeRanking.t.sol @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {Test} from "forge-std/Test.sol"; +import {TheGuildBadgeRegistry} from "../src/TheGuildBadgeRegistry.sol"; +import {TheGuildBadgeRanking} from "../src/TheGuildBadgeRanking.sol"; + +contract TheGuildBadgeRankingTest is Test { + TheGuildBadgeRegistry private registry; + TheGuildBadgeRanking private ranking; + + address private voter1 = address(0x1); + address private voter2 = address(0x2); + bytes32 private badgeName = bytes32("TEST_BADGE"); + + function setUp() public { + registry = new TheGuildBadgeRegistry(); + ranking = new TheGuildBadgeRanking(registry); + + // Create a badge for testing + registry.createBadge(badgeName, bytes32("A test badge")); + } + + function test_UpvoteBadge_SucceedsAndEmitsEvent() public { + vm.prank(voter1); + vm.expectEmit(true, true, false, false); + emit TheGuildBadgeRanking.BadgeUpvoted(badgeName, voter1); + + ranking.upvoteBadge(badgeName); + assertEq(ranking.getUpvotes(badgeName), 1, "upvotes should be 1"); + } + + function test_UpvoteBadge_MultipleVoters() public { + vm.prank(voter1); + ranking.upvoteBadge(badgeName); + + vm.prank(voter2); + ranking.upvoteBadge(badgeName); + + assertEq(ranking.getUpvotes(badgeName), 2, "upvotes should be 2"); + } + + function test_UpvoteBadge_RevertOnDuplicateVote() public { + vm.prank(voter1); + ranking.upvoteBadge(badgeName); + + vm.expectRevert(bytes("ALREADY_VOTED")); + vm.prank(voter1); + ranking.upvoteBadge(badgeName); + } + + function test_UpvoteBadge_RevertOnNonexistentBadge() public { + bytes32 nonexistentBadge = bytes32("NONEXISTENT"); + + vm.prank(voter1); + vm.expectRevert(bytes("BADGE_NOT_FOUND")); + ranking.upvoteBadge(nonexistentBadge); + } + + function test_HasVotedForBadge_ReturnsTrueAfterVoting() public { + vm.prank(voter1); + ranking.upvoteBadge(badgeName); + + assertTrue(ranking.hasVotedForBadge(badgeName, voter1), "should have voted"); + assertFalse(ranking.hasVotedForBadge(badgeName, voter2), "should not have voted"); + } + + function test_Constructor_RevertOnInvalidRegistry() public { + vm.expectRevert(bytes("INVALID_REGISTRY")); + new TheGuildBadgeRanking(TheGuildBadgeRegistry(address(0))); + } +}