diff --git a/README.md b/README.md index 2488338..002806e 100644 --- a/README.md +++ b/README.md @@ -181,13 +181,13 @@ A community-driven badge registry where anyone can create badges with unique nam - **Gas-efficient**: Simple storage patterns - **Event-driven**: Emits events for badge creation -**Contract Interface:** +**Contract Interface (V2 - current):** ```solidity // Create a new badge -function createBadge(bytes32 name, bytes32 description) external +function createBadge(bytes32 name, bytes calldata description) external // Get badge information -function getBadge(bytes32 name) external view returns (bytes32, bytes32, address) +function getBadge(bytes32 name) external view returns (bytes32, bytes memory, address) // Check if badge exists function exists(bytes32 name) external view returns (bool) @@ -197,13 +197,16 @@ function totalBadges() external view returns (uint256) // Enumerate badges function badgeNameAt(uint256 index) external view returns (bytes32) +function getBadgeAt(uint256 index) external view returns (bytes32, bytes memory, address) ``` **Events:** ```solidity -event BadgeCreated(bytes32 indexed name, bytes32 description, address indexed creator) +event BadgeCreated(bytes32 indexed name, bytes description, address indexed creator) ``` +> **Note:** V1 contracts used `bytes32` for descriptions (max 32 chars). V2 uses `bytes` for unlimited length. The frontend is retrocompatible with both versions. + ### TheGuildActivityToken (TGA) An ERC20 token used to reward attestations. Ownable; the owner is the attestation resolver contract. diff --git a/frontend/docs/V2_CLEANUP.md b/frontend/docs/V2_CLEANUP.md new file mode 100644 index 0000000..7ccc248 --- /dev/null +++ b/frontend/docs/V2_CLEANUP.md @@ -0,0 +1,48 @@ +# BadgeRegistry V2 Cleanup Guide + +## Overview + +This document outlines the cleanup steps required after full V2 deployment of BadgeRegistry contracts. The current codebase includes retro-compatibility logic to support both V1 and V2 contracts during the migration period. Once all registries are upgraded to V2, this temporary code can be removed. + +## Files to Delete + +- `frontend/src/lib/utils/abiDetection.ts` - Error-based ABI detection utilities +- `frontend/src/lib/badges/registryVersion.ts` - Version detection module + +## Functions / Logic to Remove + +- `detectBadgeRegistryVersion()` - Version detection function +- `isDecodeError()` - Decode error detection utility +- `isFunctionSelectorError()` - Function selector error detection utility +- `buildCreateBadgeArgs()` - Conditional argument builder for V1/V2 differences +- Any version-probe logic / error-based ABI inference + +## Hook Simplifications + +### use-create-badge.ts + +- Remove version detection and conditional ABI selection +- Remove `detectBadgeRegistryVersion()` call +- Always use `badgeRegistryAbiV2` (remove conditional `finalAbiMode === "v2" ? badgeRegistryAbiV2 : badgeRegistryAbiV1`) +- Always use `bytes` description format: replace `buildCreateBadgeArgs()` with direct `stringToBytes(description)` +- Remove `badgeRegistryAbiV1` import +- Remove `detectBadgeRegistryVersion` import +- Remove `buildCreateBadgeArgs` import + +### use-get-badges.ts + +- Remove version probing / `abiMode` inference +- Remove `versionProbeQuery` query +- Remove `abiMode` useMemo logic +- Always use `badgeRegistryAbiV2` in `badgeContracts` (remove conditional ABI selection) +- Always decode description as `bytes` using `bytesToString()` (remove conditional `bytesToString` vs `bytes32ToString`) +- Remove `isDecodeError` import and usage +- Remove `badgeRegistryAbiV1` import + +## Expected End State + +- Only V2 ABI (`badgeRegistryAbiV2`) used throughout the codebase +- No fallback branches or conditional logic based on contract version +- No error-based ABI detection or version probing +- Simpler, more maintainable codebase with reduced complexity + diff --git a/frontend/src/components/AppWrapper.tsx b/frontend/src/components/AppWrapper.tsx index ad2a6f4..66498dc 100644 --- a/frontend/src/components/AppWrapper.tsx +++ b/frontend/src/components/AppWrapper.tsx @@ -13,7 +13,17 @@ import { ActivityTokenBalance } from "@/components/ActivityTokenBalance"; import { Background } from "@/components/Background"; import { LoginButton } from "@/components/LoginButton"; -const queryClient = new QueryClient(); +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 60_000, // 1 minute - badges change infrequently + refetchOnWindowFocus: false, // Prevent refetch storms on alt-tab + refetchOnReconnect: false, // Prevent refetch storms on network reconnect + refetchOnMount: false, // Prevent refetch on component remount (cache is fresh) + retry: 1, // Single retry for transient errors + }, + }, +}); interface AppWrapperProps { children: React.ReactNode; diff --git a/frontend/src/components/badges/BadgesList.tsx b/frontend/src/components/badges/BadgesList.tsx index 6c86bb3..427cf2b 100644 --- a/frontend/src/components/badges/BadgesList.tsx +++ b/frontend/src/components/badges/BadgesList.tsx @@ -26,7 +26,7 @@ const ICONS: Record = { }; export function BadgesList(): React.ReactElement { - const { data, isLoading, error } = useGetBadges(); + const { data, isLoading, error, refetch } = useGetBadges(); const [searchQuery, setSearchQuery] = useState(""); const list = (data && data.length > 0 ? data : HARD_CODED_BADGES) as Badge[]; @@ -62,7 +62,7 @@ export function BadgesList(): React.ReactElement { /> - +
diff --git a/frontend/src/components/badges/CreateBadgeButton.tsx b/frontend/src/components/badges/CreateBadgeButton.tsx index af1fa7d..e1e29aa 100644 --- a/frontend/src/components/badges/CreateBadgeButton.tsx +++ b/frontend/src/components/badges/CreateBadgeButton.tsx @@ -23,7 +23,6 @@ import { import { useForm } from "react-hook-form"; import { z } from "zod"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useGetBadges } from "@/hooks/badges/use-get-badges"; const formSchema = z.object({ name: z.string().min(1, { message: "Name is required." }).max(32), @@ -32,11 +31,14 @@ const formSchema = z.object({ type FormValues = z.infer; -export function CreateBadgeButton() { +interface CreateBadgeButtonProps { + onBadgeCreated?: () => void; +} + +export function CreateBadgeButton({ onBadgeCreated }: CreateBadgeButtonProps) { const [open, setOpen] = useState(false); const { createBadge, isPending, error, reset, isConfirmed, isConfirming } = useCreateBadge(); - const { refetch } = useGetBadges(); const form = useForm({ resolver: zodResolver(formSchema), @@ -49,12 +51,12 @@ export function CreateBadgeButton() { useEffect(() => { if (isConfirmed) { - refetch(); + onBadgeCreated?.(); setOpen(false); form.reset(); reset(); } - }, [isConfirmed, refetch, form, reset]); + }, [isConfirmed, onBadgeCreated, form, reset]); return ( diff --git a/frontend/src/components/displayError/index.tsx b/frontend/src/components/displayError/index.tsx index e136d9f..f7e50b9 100644 --- a/frontend/src/components/displayError/index.tsx +++ b/frontend/src/components/displayError/index.tsx @@ -3,7 +3,7 @@ type ErrorDisplayProps = { }; export default function ErrorDisplay({ error }: ErrorDisplayProps) { - if (!error) return null; + if (!error) return null; return (

diff --git a/frontend/src/hooks/attestations/use-create-attestation.ts b/frontend/src/hooks/attestations/use-create-attestation.ts index 2942b99..8801038 100644 --- a/frontend/src/hooks/attestations/use-create-attestation.ts +++ b/frontend/src/hooks/attestations/use-create-attestation.ts @@ -12,27 +12,7 @@ import { EAS_CONTRACT_ADDRESS, SCHEMA_ID, } from "@/lib/constants/blockchainConstants"; - -function stringToBytes32(value: string): `0x${string}` { - const encoder = new TextEncoder(); - const bytes = encoder.encode(value); - const out = new Uint8Array(32); - const len = Math.min(32, bytes.length); - for (let i = 0; i < len; i++) out[i] = bytes[i]; - let hex = "0x"; - for (let i = 0; i < out.length; i++) - hex += out[i].toString(16).padStart(2, "0"); - return hex as `0x${string}`; -} - -function stringToBytes(value: string): `0x${string}` { - const encoder = new TextEncoder(); - const bytes = encoder.encode(value); - let hex = "0x"; - for (let i = 0; i < bytes.length; i++) - hex += bytes[i].toString(16).padStart(2, "0"); - return hex as `0x${string}`; -} +import { stringToBytes32, stringToBytes } from "@/lib/utils/blockchainUtils"; function encodeBadgeData( badgeName: `0x${string}`, @@ -69,7 +49,7 @@ export function useCreateAttestation() { ); } isBusyRef.current = true; - // Convert strings to bytes + // Convert strings to bytes32 const badgeNameBytes = stringToBytes32(badgeName); const justificationBytes = stringToBytes(justification); @@ -117,6 +97,7 @@ export function useCreateAttestation() { const wait = useWaitForTransactionReceipt({ hash: hash as `0x${string}` | undefined, + confirmations: 6, query: { enabled: Boolean(hash) }, }); diff --git a/frontend/src/hooks/badges/use-create-badge.ts b/frontend/src/hooks/badges/use-create-badge.ts index 399383a..dd7c7cb 100644 --- a/frontend/src/hooks/badges/use-create-badge.ts +++ b/frontend/src/hooks/badges/use-create-badge.ts @@ -1,26 +1,66 @@ import { useMemo } from "react"; -import { useWriteContract, useWaitForTransactionReceipt } from "wagmi"; +import { useWriteContract, useWaitForTransactionReceipt, useAccount, useConfig } from "wagmi"; +import { simulateContract, readContract } from "@wagmi/core"; import { BADGE_REGISTRY_ADDRESS } from "@/lib/constants/blockchainConstants"; -import { badgeRegistryAbi } from "@/lib/abis/badgeRegistryAbi"; -import { stringToBytes32 } from "@/lib/utils/blockchainUtils"; +import { + badgeRegistryAbiV1, + badgeRegistryAbiV2, +} from "@/lib/abis/badgeRegistryAbi"; +import { stringToBytes32, buildCreateBadgeArgs } from "@/lib/utils/blockchainUtils"; +import { detectBadgeRegistryVersion } from "@/lib/badges/registryVersion"; export function useCreateBadge() { + const config = useConfig(); + const { address: account, chainId } = useAccount(); const { writeContractAsync, isPending, error, data, reset } = useWriteContract(); const createBadge = useMemo(() => { return async (name: string, description: string) => { - if (!BADGE_REGISTRY_ADDRESS) throw new Error("Missing registry address"); + if (!BADGE_REGISTRY_ADDRESS) { + throw new Error("Badge registry address not configured"); + } + if (!account) { + throw new Error("No wallet connected"); + } + if (!chainId) { + throw new Error("No chain ID available"); + } + const nameBytes = stringToBytes32(name); - const descriptionBytes = stringToBytes32(description); - return writeContractAsync({ - abi: badgeRegistryAbi, + + // Determine ABI mode deterministically BEFORE sending transaction + // All version detection happens on-demand here to prevent refetch storm + // Fetch totalBadges on-demand + const totalBadgesResult = await readContract(config, { + abi: badgeRegistryAbiV2, + address: BADGE_REGISTRY_ADDRESS, + functionName: "totalBadges", + }); + const currentCount = Number(totalBadgesResult ?? 0n); + + // TODO(cleanup-after-v2): Remove V1 fallback logic after V2 full deployment. Always use V2 ABI. See docs/V2_CLEANUP.md. + const finalAbiMode = await detectBadgeRegistryVersion( + config, + account as `0x${string}`, + chainId, + currentCount + ); + + // Now that ABI mode is determined, simulate and send with correct ABI + const simulation = await simulateContract(config, { + abi: finalAbiMode === "v2" ? badgeRegistryAbiV2 : badgeRegistryAbiV1, address: BADGE_REGISTRY_ADDRESS, functionName: "createBadge", - args: [nameBytes, descriptionBytes], + args: buildCreateBadgeArgs(nameBytes, description, finalAbiMode), + account: account as `0x${string}` | undefined, + chainId, }); + + // Single writeContractAsync call with correct ABI + return await writeContractAsync(simulation.request); }; - }, [writeContractAsync]); + }, [writeContractAsync, account, chainId, config]); const wait = useWaitForTransactionReceipt({ hash: data as `0x${string}` | undefined, diff --git a/frontend/src/hooks/badges/use-get-badges.ts b/frontend/src/hooks/badges/use-get-badges.ts index 121fa4a..b9dc64c 100644 --- a/frontend/src/hooks/badges/use-get-badges.ts +++ b/frontend/src/hooks/badges/use-get-badges.ts @@ -1,9 +1,13 @@ import { useMemo } from "react"; import { useReadContract, useReadContracts } from "wagmi"; -import { badgeRegistryAbi } from "@/lib/abis/badgeRegistryAbi"; +import { + badgeRegistryAbiV1, + badgeRegistryAbiV2, +} 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"; +import { bytes32ToString, bytesToString } from "@/lib/utils/blockchainUtils"; +import { isDecodeError } from "@/lib/utils/abiDetection"; export function useGetBadges(): { data: Badge[] | undefined; @@ -14,53 +18,118 @@ export function useGetBadges(): { const address = BADGE_REGISTRY_ADDRESS; const totalBadgesQuery = useReadContract({ - abi: badgeRegistryAbi, + abi: badgeRegistryAbiV2, address, functionName: "totalBadges", query: { enabled: Boolean(address), + retry: false, }, }); const count = Number((totalBadgesQuery.data as bigint | undefined) ?? 0n); + // TODO(cleanup-after-v2): Remove V1 fallback logic after V2 full deployment. Always use V2 ABI. See docs/V2_CLEANUP.md. + // TODO(cleanup): Remove version probe after V2 full deployment + // Probe version with single getBadgeAt(0) call + // This probe runs once per address and is cached forever + // Only enabled if count > 0 (no probe for empty registries) + const versionProbeQuery = useReadContract({ + abi: badgeRegistryAbiV2, + address, + functionName: "getBadgeAt", + args: [0n], + query: { + enabled: Boolean(address) && count > 0, + staleTime: Infinity, + retry: 0, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: false, + }, + }); + + // TODO(cleanup): Simplify to always use V2 ABI after V2 full deployment + // Determine ABI mode: V2 if probe succeeds, V1 if decode error, undefined while loading + // If count === 0, abiMode remains undefined (no probe, no multicall) + const abiMode = useMemo<"v1" | "v2" | undefined>(() => { + if (count === 0) return undefined; // No badges, no ABI needed + if (versionProbeQuery.isSuccess) return "v2"; + if (versionProbeQuery.error && isDecodeError(versionProbeQuery.error)) { + return "v1"; // Decode error indicates V1 contract + } + return undefined; // Still loading or unknown + }, [count, versionProbeQuery.isSuccess, versionProbeQuery.error]); + + // Build multicall contracts with correct ABI based on probe result const badgeContracts = useMemo( () => - count > 0 + count > 0 && abiMode !== undefined ? Array.from({ length: count }, (_, i) => ({ - abi: badgeRegistryAbi, + // TODO(cleanup): Remove conditional ABI selection after V2 full deployment + abi: abiMode === "v2" ? badgeRegistryAbiV2 : badgeRegistryAbiV1, address, functionName: "getBadgeAt" as const, args: [BigInt(i)], })) : [], - [address, count] + [address, count, abiMode] ); + // Execute multicall with detected ABI const badgesQuery = useReadContracts({ contracts: badgeContracts, allowFailure: false, query: { - enabled: Boolean(address) && count > 0, + enabled: Boolean(address) && count > 0 && abiMode !== undefined, + retry: 0, + staleTime: Infinity, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: false, }, }); + // Decode descriptions based on detected ABI + // If count === 0, return empty array const data: Badge[] | undefined = useMemo(() => { + if (count === 0) return []; const results = badgesQuery.data as | [`0x${string}`, `0x${string}`, `0x${string}`][] | undefined; if (!results) return undefined; - return results.map(([nameBytes, descriptionBytes]) => ({ - name: bytes32ToString(nameBytes), - description: bytes32ToString(descriptionBytes), - })); - }, [badgesQuery.data]); - const isLoading = totalBadgesQuery.isLoading || badgesQuery.isLoading; + return results.map((item) => { + if (!Array.isArray(item) || item.length < 2) { + throw new Error(`Unexpected item shape: ${JSON.stringify(item)}`); + } + + const [nameBytes, descriptionBytes] = item as [`0x${string}`, `0x${string}`, `0x${string}`]; + const name = bytes32ToString(nameBytes); + // TODO(cleanup): Remove conditional decoding after V2 full deployment + const description = + abiMode === "v2" + ? bytesToString(descriptionBytes) // V2: bytes (variable length) + : bytes32ToString(descriptionBytes); // V1: bytes32 (fixed 32 bytes) + + return { name, description }; + }); + }, [count, badgesQuery.data, abiMode]); + + const isLoading = + totalBadgesQuery.isLoading || + (count > 0 && abiMode === undefined ? versionProbeQuery.isLoading : false) || + (count > 0 && badgesQuery.isLoading); const error = (totalBadgesQuery.error as Error | null) || - (badgesQuery.error as Error | null) || + // Only propagate probe error if it's NOT a decode error (decode errors are expected for V1) + (count > 0 && + versionProbeQuery.error && + !isDecodeError(versionProbeQuery.error) + ? (versionProbeQuery.error as Error | null) + : null) || + (count > 0 ? (badgesQuery.error as Error | null) : null) || null; return { data, isLoading, error, refetch: totalBadgesQuery.refetch }; diff --git a/frontend/src/lib/abis/badgeRegistryAbi.ts b/frontend/src/lib/abis/badgeRegistryAbi.ts index 6e79c27..5f1e23e 100644 --- a/frontend/src/lib/abis/badgeRegistryAbi.ts +++ b/frontend/src/lib/abis/badgeRegistryAbi.ts @@ -1,5 +1,6 @@ -// ABI fragments for the required contract read methods -export const badgeRegistryAbi = [ +// TODO(cleanup): Remove V1 ABI after all BadgeRegistry deployments are V2 +// ABI fragments for V1 contract (bytes32 description) +export const badgeRegistryAbiV1 = [ { type: "function", name: "totalBadges", @@ -29,3 +30,35 @@ export const badgeRegistryAbi = [ outputs: [], }, ] as const; + +// ABI fragments for V2 contract (bytes description) +export const badgeRegistryAbiV2 = [ + { + type: "function", + name: "totalBadges", + stateMutability: "view", + inputs: [], + outputs: [{ name: "", type: "uint256" }], + }, + { + type: "function", + name: "getBadgeAt", + stateMutability: "view", + inputs: [{ name: "index", type: "uint256" }], + outputs: [ + { name: "", type: "bytes32" }, + { name: "", type: "bytes" }, + { name: "", type: "address" }, + ], + }, + { + type: "function", + name: "createBadge", + stateMutability: "nonpayable", + inputs: [ + { name: "name", type: "bytes32" }, + { name: "description", type: "bytes" }, + ], + outputs: [], + }, +] as const; diff --git a/frontend/src/lib/badges/registryVersion.ts b/frontend/src/lib/badges/registryVersion.ts new file mode 100644 index 0000000..79e9d0a --- /dev/null +++ b/frontend/src/lib/badges/registryVersion.ts @@ -0,0 +1,79 @@ +/** + * TODO(cleanup-after-v2): Remove this entire module after V2 full deployment. See docs/V2_CLEANUP.md for details. + */ + +import type { Config } from "wagmi"; +import { simulateContract, readContract } from "@wagmi/core"; +import { BADGE_REGISTRY_ADDRESS } from "@/lib/constants/blockchainConstants"; +import { badgeRegistryAbiV2 } from "@/lib/abis/badgeRegistryAbi"; +import { stringToBytes32, stringToBytes } from "@/lib/utils/blockchainUtils"; +import { isDecodeError, isFunctionSelectorError } from "@/lib/utils/abiDetection"; + +/** + * Detects BadgeRegistry contract version (V1 or V2) by probing with V2 ABI. + * Returns "v2" if probe succeeds, "v1" if decode/selector error, throws otherwise. + * + * Strategy: + * - For non-empty registries: probes with getBadgeAt(0) read call. + * - For empty registries: probes with simulateContract on createBadge (V2-first assumption). + * + * This function encapsulates the version detection logic to make it testable + * and reusable. The detection happens on-demand to prevent refetch storms. + * + * NOTE: This is a pure "move + rename" refactor. The exact RPC call order and + * branching is preserved: totalBadges -> getBadgeAt probe if >0 else simulate probe. + */ +export async function detectBadgeRegistryVersion( + config: Config, + account: `0x${string}`, + chainId: number, + currentCount: number +): Promise<"v1" | "v2"> { + if (currentCount > 0) { + // Probe version with getBadgeAt(0) - same logic as use-get-badges.ts + try { + await readContract(config, { + abi: badgeRegistryAbiV2, + address: BADGE_REGISTRY_ADDRESS, + functionName: "getBadgeAt", + args: [0n], + }); + // Probe succeeded, contract is V2 + return "v2"; + } catch (err) { + // Check if decode error (V1 contract) + if (isDecodeError(err as Error)) { + return "v1"; + } else { + // Other error - surface it + throw err; + } + } + } else { + // Empty registry (count === 0): probe with simulateContract + // Use a unique name to avoid EMPTY_NAME/DUPLICATE_NAME false negatives + const probeName = stringToBytes32(`__probe_${Date.now()}_${Math.random()}`); + try { + // Try V2 first (assumption: new registries are V2) + await simulateContract(config, { + abi: badgeRegistryAbiV2, + address: BADGE_REGISTRY_ADDRESS, + functionName: "createBadge", + args: [probeName, stringToBytes("probe")], + account, + chainId, + }); + // V2 simulation succeeded, contract is V2 + return "v2"; + } catch (err) { + // Check if error indicates function selector not found (V1 contract) + if (isFunctionSelectorError(err as Error)) { + return "v1"; + } else { + // Other error (EMPTY_NAME, DUPLICATE_NAME, network, etc.) - surface it + throw err; + } + } + } +} + diff --git a/frontend/src/lib/utils/abiDetection.ts b/frontend/src/lib/utils/abiDetection.ts new file mode 100644 index 0000000..eae5dd5 --- /dev/null +++ b/frontend/src/lib/utils/abiDetection.ts @@ -0,0 +1,68 @@ +/** + * TODO(cleanup-after-v2): Remove this entire module after V2 full deployment. See docs/V2_CLEANUP.md for details. + * + * ABI version detection utilities for BadgeRegistry contracts. + * + * These functions detect contract version (V1 vs V2) by analyzing error messages + * when attempting to call with the wrong ABI. This is a temporary solution during + * the V1→V2 migration period. + * + * Limitations: + * - Error-based detection is fragile: error message formats may vary across RPC providers + * - Network/RPC errors must be carefully filtered to avoid false positives + * - This approach will be removed after V2 full deployment (see TODO comments) + * + * Why error-based detection: + * - No version getter exists on-chain + * - On-demand detection avoids refetch storms from version probes in hooks + * - Works for both read (decode errors) and write (selector errors) operations + */ + +/** + * TODO(cleanup): Remove decode error detection after V2 full deployment + * Check if error is a decode error (ABI mismatch). + * V2 ABI will fail to decode V1 contract responses with decode errors. + * Must NOT classify RPC/network errors as decode errors. + * + * NOTE: Matching logic is copied exactly from use-create-badge.ts and use-get-badges.ts. + * Do not modify matching patterns in this PR - only relocate and document. + */ +export function isDecodeError(error: Error | null): boolean { + if (!error) return false; + const message = error.message.toLowerCase(); + const name = error.name.toLowerCase(); + // Only detect actual decode errors, not RPC/network issues + return ( + name.includes("positionoutofbounds") || + name.includes("decodefunctionresult") || + (name.includes("contractfunctionexecution") && + (message.includes("decode") || message.includes("position"))) || + (message.includes("decode") && + (message.includes("function") || message.includes("abi"))) || + (message.includes("position") && message.includes("out of bounds")) + ); +} + +/** + * TODO(cleanup): Remove function selector error detection after V2 full deployment + * Check if error indicates function selector not found (ABI mismatch for write operations). + * When simulating a write with wrong ABI, we get "function selector not found" errors. + * Must NOT classify other revert reasons (EMPTY_NAME, DUPLICATE_NAME, etc.) as selector errors. + * + * NOTE: Matching logic is copied exactly from use-create-badge.ts. + * Do not modify matching patterns in this PR - only relocate and document. + */ +export function isFunctionSelectorError(error: Error | null): boolean { + if (!error) return false; + const message = error.message.toLowerCase(); + const name = error.name.toLowerCase(); + // Detect function selector not found errors + return ( + name.includes("functionnotfound") || + name.includes("functionselector") || + (message.includes("function") && + (message.includes("not found") || message.includes("selector"))) || + (message.includes("selector") && message.includes("not found")) + ); +} + diff --git a/frontend/src/lib/utils/blockchainUtils.ts b/frontend/src/lib/utils/blockchainUtils.ts index eef4347..1c8ffda 100644 --- a/frontend/src/lib/utils/blockchainUtils.ts +++ b/frontend/src/lib/utils/blockchainUtils.ts @@ -46,3 +46,28 @@ export function stringToBytes32(value: string): `0x${string}` { } return hex as `0x${string}`; } + +export function stringToBytes(value: string): `0x${string}` { + // Encode to utf8, return as hex (variable length) + const encoder = new TextEncoder(); + const bytes = encoder.encode(value); + let hex = "0x"; + for (let i = 0; i < bytes.length; i++) { + hex += bytes[i].toString(16).padStart(2, "0"); + } + return hex as `0x${string}`; +} + +/** + * Builds createBadge function arguments based on ABI version. + * V2 uses bytes for description, V1 uses bytes32. + */ +export function buildCreateBadgeArgs( + nameBytes: `0x${string}`, + description: string, + abiMode: "v1" | "v2" +): [`0x${string}`, `0x${string}`] { + return abiMode === "v2" + ? [nameBytes, stringToBytes(description)] + : [nameBytes, stringToBytes32(description)]; +} diff --git a/frontend/src/test/abiDetection.test.ts b/frontend/src/test/abiDetection.test.ts new file mode 100644 index 0000000..a759e5b --- /dev/null +++ b/frontend/src/test/abiDetection.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from "vitest"; +import { + isDecodeError, + isFunctionSelectorError, +} from "@/lib/utils/abiDetection"; + +describe("isDecodeError", () => { + it("returns false for null/undefined", () => { + expect(isDecodeError(null)).toBe(false); + }); + + it("detects PositionOutOfBounds errors", () => { + const error = new Error("Something went wrong"); + error.name = "PositionOutOfBoundsError"; + expect(isDecodeError(error)).toBe(true); + }); + + it("detects DecodeFunctionResult errors", () => { + const error = new Error("Decode failed"); + error.name = "DecodeFunctionResultError"; + expect(isDecodeError(error)).toBe(true); + }); + + it("detects decode errors in message", () => { + const error = new Error("Failed to decode function result"); + expect(isDecodeError(error)).toBe(true); + }); + + it("detects position out of bounds in message", () => { + const error = new Error("Position out of bounds"); + expect(isDecodeError(error)).toBe(true); + }); + + it("does not classify RPC errors as decode errors", () => { + const error = new Error("Internal JSON-RPC error"); + expect(isDecodeError(error)).toBe(false); + }); + + it("does not classify network errors as decode errors", () => { + const error = new Error("Network request failed"); + expect(isDecodeError(error)).toBe(false); + }); + + it("detects contract function execution decode errors", () => { + const error = new Error("Contract function execution failed: decode error"); + error.name = "ContractFunctionExecutionError"; + expect(isDecodeError(error)).toBe(true); + }); +}); + +describe("isFunctionSelectorError", () => { + it("returns false for null/undefined", () => { + expect(isFunctionSelectorError(null)).toBe(false); + }); + + it("detects FunctionNotFound errors", () => { + const error = new Error("Function not found"); + error.name = "FunctionNotFoundError"; + expect(isFunctionSelectorError(error)).toBe(true); + }); + + it("detects FunctionSelector errors", () => { + const error = new Error("Selector mismatch"); + error.name = "FunctionSelectorError"; + expect(isFunctionSelectorError(error)).toBe(true); + }); + + it("detects 'function not found' in message", () => { + const error = new Error("Function not found in ABI"); + expect(isFunctionSelectorError(error)).toBe(true); + }); + + it("detects 'selector not found' in message", () => { + const error = new Error("Selector not found"); + expect(isFunctionSelectorError(error)).toBe(true); + }); + + it("does not classify revert reasons as selector errors", () => { + const error = new Error("Contract function reverted: EMPTY_NAME"); + expect(isFunctionSelectorError(error)).toBe(false); + }); + + it("does not classify duplicate name errors as selector errors", () => { + const error = new Error("Contract function reverted: DUPLICATE_NAME"); + expect(isFunctionSelectorError(error)).toBe(false); + }); +}); + diff --git a/frontend/src/test/blockchainUtils.test.ts b/frontend/src/test/blockchainUtils.test.ts new file mode 100644 index 0000000..8e25995 --- /dev/null +++ b/frontend/src/test/blockchainUtils.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from "vitest"; +import { + buildCreateBadgeArgs, + stringToBytes32, + stringToBytes, +} from "@/lib/utils/blockchainUtils"; + +describe("buildCreateBadgeArgs", () => { + const nameBytes = stringToBytes32("TestBadge"); + + it("builds V2 args with bytes description", () => { + const args = buildCreateBadgeArgs(nameBytes, "Long description", "v2"); + expect(args).toHaveLength(2); + expect(args[0]).toBe(nameBytes); + // V2 uses stringToBytes (variable length) + expect(args[1]).toBe(stringToBytes("Long description")); + }); + + it("builds V1 args with bytes32 description", () => { + const args = buildCreateBadgeArgs(nameBytes, "Short", "v1"); + expect(args).toHaveLength(2); + expect(args[0]).toBe(nameBytes); + // V1 uses stringToBytes32 (fixed 32 bytes) + expect(args[1]).toBe(stringToBytes32("Short")); + }); + + it("handles empty description for V2", () => { + const args = buildCreateBadgeArgs(nameBytes, "", "v2"); + expect(args[1]).toBe(stringToBytes("")); + }); + + it("handles empty description for V1", () => { + const args = buildCreateBadgeArgs(nameBytes, "", "v1"); + expect(args[1]).toBe(stringToBytes32("")); + }); +}); + diff --git a/the-guild-smart-contracts/INTEGRATION.md b/the-guild-smart-contracts/INTEGRATION.md index fc2b3c4..c08e047 100644 --- a/the-guild-smart-contracts/INTEGRATION.md +++ b/the-guild-smart-contracts/INTEGRATION.md @@ -5,6 +5,8 @@ This guide shows how to create on-chain attestations of TheGuild badges from a f - Schema: `bytes32 badgeName, bytes justification` - Hardcoded schema id for now: set `SCHEMA_ID` to a placeholder and replace later +> **Note:** The schema uses `bytes` (not `bytes32`) for `justification` to allow longer text. + References: - EAS SDK: [Creating on-chain attestations](https://docs.attest.org/docs/developer-tools/eas-sdk#creating-onchain-attestations) - EAS SDK + wagmi: [wagmi integration](https://docs.attest.org/docs/developer-tools/sdk-wagmi) diff --git a/the-guild-smart-contracts/README.md b/the-guild-smart-contracts/README.md index b549280..0deedf0 100644 --- a/the-guild-smart-contracts/README.md +++ b/the-guild-smart-contracts/README.md @@ -7,6 +7,12 @@ 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. +**Contract Versions:** +- **V1**: `description` stored as `bytes32` (max 32 characters) +- **V2 (current)**: `description` stored as `bytes` (unlimited length) + +The frontend is retrocompatible with both V1 and V2 deployed contracts. + ### 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. @@ -324,7 +330,7 @@ Prepare your badges data in JSON format: ``` - `name`: Name of the badge (max 32 characters, will be converted to bytes32) -- `description`: Description of the badge (max 32 characters, will be converted to bytes32) +- `description`: Description of the badge (V2: unlimited length as bytes; V1: max 32 characters as bytes32) #### Usage