From 2c187e00aa460c0a89c456d207fa3d86ae079f8c Mon Sep 17 00:00:00 2001 From: V-Vaal Date: Wed, 17 Dec 2025 00:10:24 +0100 Subject: [PATCH 1/6] feat(frontend): badge registry V1/V2 retrocompat --- README.md | 11 ++- .../attestations/use-create-attestation.ts | 22 +---- .../badges/use-badge-registry-version.ts | 82 +++++++++++++++++++ frontend/src/hooks/badges/use-create-badge.ts | 32 ++++++-- frontend/src/hooks/badges/use-get-badges.ts | 34 +++++--- frontend/src/lib/abis/badgeRegistryAbi.ts | 36 +++++++- frontend/src/lib/utils/blockchainUtils.ts | 11 +++ the-guild-smart-contracts/INTEGRATION.md | 2 + the-guild-smart-contracts/README.md | 8 +- 9 files changed, 191 insertions(+), 47 deletions(-) create mode 100644 frontend/src/hooks/badges/use-badge-registry-version.ts 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/src/hooks/attestations/use-create-attestation.ts b/frontend/src/hooks/attestations/use-create-attestation.ts index 2942b99..5113ee2 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}`, diff --git a/frontend/src/hooks/badges/use-badge-registry-version.ts b/frontend/src/hooks/badges/use-badge-registry-version.ts new file mode 100644 index 0000000..f3ec2c0 --- /dev/null +++ b/frontend/src/hooks/badges/use-badge-registry-version.ts @@ -0,0 +1,82 @@ +import { useState, useEffect } from "react"; +import { useReadContract } from "wagmi"; +import { badgeRegistryAbiV2 } from "@/lib/abis/badgeRegistryAbi"; + +export function useBadgeRegistryVersion(address?: `0x${string}`): { + version: "v1" | "v2" | null; + isLoading: boolean; + error: Error | null; +} { + const [version, setVersion] = useState<"v1" | "v2" | null>(null); + + // Reset version when address changes + useEffect(() => { + setVersion(null); + }, [address]); + + // Read totalBadges using V2 ABI (same signature in both versions) + const totalBadgesQuery = useReadContract({ + abi: badgeRegistryAbiV2, + address, + functionName: "totalBadges", + query: { + enabled: Boolean(address), + }, + }); + + const count = Number((totalBadgesQuery.data as bigint | undefined) ?? 0n); + + // Test version by calling getBadgeAt(0) with V2 ABI + const versionTestQuery = useReadContract({ + abi: badgeRegistryAbiV2, + address, + functionName: "getBadgeAt", + args: [0n], + query: { + enabled: Boolean(address) && count > 0 && version === null, + }, + }); + + // Detection logic + useEffect(() => { + if (!address) return; + + if (totalBadgesQuery.isSuccess && count === 0) { + // Empty registry defaults to V2 + setVersion("v2"); + return; + } + + if (versionTestQuery.isSuccess) { + // V2 call succeeded + setVersion("v2"); + } else if (versionTestQuery.isError) { + // V2 call failed, assume V1 + setVersion("v1"); + } + }, [ + address, + count, + totalBadgesQuery.isSuccess, + versionTestQuery.isSuccess, + versionTestQuery.isError, + ]); + + // Calculate isLoading + const isLoading = + Boolean(address) && + (totalBadgesQuery.isLoading || + (count > 0 && versionTestQuery.isLoading) || + version === null); + + // If address is undefined, return early state + if (!address) { + return { version: null, isLoading: false, error: null }; + } + + // Note: versionTestQuery.error is expected for V1, not a real error to propagate + const error = (totalBadgesQuery.error as Error | null) || null; + + return { version, isLoading, error }; +} + diff --git a/frontend/src/hooks/badges/use-create-badge.ts b/frontend/src/hooks/badges/use-create-badge.ts index 399383a..20bd4cc 100644 --- a/frontend/src/hooks/badges/use-create-badge.ts +++ b/frontend/src/hooks/badges/use-create-badge.ts @@ -1,8 +1,11 @@ import { useMemo } from "react"; import { useWriteContract, useWaitForTransactionReceipt } from "wagmi"; 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, stringToBytes } from "@/lib/utils/blockchainUtils"; export function useCreateBadge() { const { writeContractAsync, isPending, error, data, reset } = @@ -12,13 +15,24 @@ export function useCreateBadge() { return async (name: string, description: string) => { if (!BADGE_REGISTRY_ADDRESS) throw new Error("Missing registry address"); const nameBytes = stringToBytes32(name); - const descriptionBytes = stringToBytes32(description); - return writeContractAsync({ - abi: badgeRegistryAbi, - address: BADGE_REGISTRY_ADDRESS, - functionName: "createBadge", - args: [nameBytes, descriptionBytes], - }); + + // Try V2 first (bytes description), fallback to V1 (bytes32) + try { + return await writeContractAsync({ + abi: badgeRegistryAbiV2, + address: BADGE_REGISTRY_ADDRESS, + functionName: "createBadge", + args: [nameBytes, stringToBytes(description)], + }); + } catch { + // Fallback to V1 (bytes32 description with truncation/padding) + return writeContractAsync({ + abi: badgeRegistryAbiV1, + address: BADGE_REGISTRY_ADDRESS, + functionName: "createBadge", + args: [nameBytes, stringToBytes32(description)], + }); + } }; }, [writeContractAsync]); diff --git a/frontend/src/hooks/badges/use-get-badges.ts b/frontend/src/hooks/badges/use-get-badges.ts index 121fa4a..4a6afec 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 { useBadgeRegistryVersion } from "./use-badge-registry-version"; export function useGetBadges(): { data: Badge[] | undefined; @@ -12,9 +16,14 @@ export function useGetBadges(): { refetch: () => void; } { const address = BADGE_REGISTRY_ADDRESS; + const { + version, + isLoading: versionLoading, + error: versionError, + } = useBadgeRegistryVersion(address); const totalBadgesQuery = useReadContract({ - abi: badgeRegistryAbi, + abi: badgeRegistryAbiV2, // totalBadges has same signature in both versions address, functionName: "totalBadges", query: { @@ -26,22 +35,22 @@ export function useGetBadges(): { const badgeContracts = useMemo( () => - count > 0 + count > 0 && version !== null ? Array.from({ length: count }, (_, i) => ({ - abi: badgeRegistryAbi, + abi: version === "v2" ? badgeRegistryAbiV2 : badgeRegistryAbiV1, address, functionName: "getBadgeAt" as const, args: [BigInt(i)], })) : [], - [address, count] + [address, count, version] ); const badgesQuery = useReadContracts({ contracts: badgeContracts, allowFailure: false, query: { - enabled: Boolean(address) && count > 0, + enabled: Boolean(address) && count > 0 && version !== null, }, }); @@ -50,15 +59,20 @@ export function useGetBadges(): { | [`0x${string}`, `0x${string}`, `0x${string}`][] | undefined; if (!results) return undefined; + const isV2 = version === "v2"; return results.map(([nameBytes, descriptionBytes]) => ({ name: bytes32ToString(nameBytes), - description: bytes32ToString(descriptionBytes), + description: isV2 + ? bytesToString(descriptionBytes) + : bytes32ToString(descriptionBytes), })); - }, [badgesQuery.data]); + }, [badgesQuery.data, version]); - const isLoading = totalBadgesQuery.isLoading || badgesQuery.isLoading; + const isLoading = + versionLoading || totalBadgesQuery.isLoading || badgesQuery.isLoading; const error = + versionError || (totalBadgesQuery.error as Error | null) || (badgesQuery.error as Error | null) || null; diff --git a/frontend/src/lib/abis/badgeRegistryAbi.ts b/frontend/src/lib/abis/badgeRegistryAbi.ts index 6e79c27..053de73 100644 --- a/frontend/src/lib/abis/badgeRegistryAbi.ts +++ b/frontend/src/lib/abis/badgeRegistryAbi.ts @@ -1,5 +1,5 @@ -// ABI fragments for the required contract read methods -export const badgeRegistryAbi = [ +// ABI fragments for V1 contract (bytes32 description) +export const badgeRegistryAbiV1 = [ { type: "function", name: "totalBadges", @@ -29,3 +29,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/utils/blockchainUtils.ts b/frontend/src/lib/utils/blockchainUtils.ts index eef4347..26830a1 100644 --- a/frontend/src/lib/utils/blockchainUtils.ts +++ b/frontend/src/lib/utils/blockchainUtils.ts @@ -46,3 +46,14 @@ 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}`; +} 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 From af3278d45e0beb8f0242685d478ab8bd396a1425 Mon Sep 17 00:00:00 2001 From: V-Vaal Date: Fri, 19 Dec 2025 15:30:05 +0100 Subject: [PATCH 2/6] fix(frontend): robust V1/V2 badge registry retro-compat --- frontend/src/components/AppWrapper.tsx | 12 +- frontend/src/components/badges/BadgesList.tsx | 4 +- .../components/badges/CreateBadgeButton.tsx | 12 +- .../src/components/displayError/index.tsx | 2 +- .../badges/use-badge-registry-version.ts | 82 ------------- frontend/src/hooks/badges/use-get-badges.ts | 114 ++++++++++++++---- 6 files changed, 113 insertions(+), 113 deletions(-) delete mode 100644 frontend/src/hooks/badges/use-badge-registry-version.ts diff --git a/frontend/src/components/AppWrapper.tsx b/frontend/src/components/AppWrapper.tsx index 9aa3b3a..84c0113 100644 --- a/frontend/src/components/AppWrapper.tsx +++ b/frontend/src/components/AppWrapper.tsx @@ -12,7 +12,17 @@ import { AppSidebar } from "@/components/AppSidebar"; import { ActivityTokenBalance } from "@/components/ActivityTokenBalance"; import { Background } from "@/components/Background"; -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/badges/use-badge-registry-version.ts b/frontend/src/hooks/badges/use-badge-registry-version.ts deleted file mode 100644 index f3ec2c0..0000000 --- a/frontend/src/hooks/badges/use-badge-registry-version.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { useState, useEffect } from "react"; -import { useReadContract } from "wagmi"; -import { badgeRegistryAbiV2 } from "@/lib/abis/badgeRegistryAbi"; - -export function useBadgeRegistryVersion(address?: `0x${string}`): { - version: "v1" | "v2" | null; - isLoading: boolean; - error: Error | null; -} { - const [version, setVersion] = useState<"v1" | "v2" | null>(null); - - // Reset version when address changes - useEffect(() => { - setVersion(null); - }, [address]); - - // Read totalBadges using V2 ABI (same signature in both versions) - const totalBadgesQuery = useReadContract({ - abi: badgeRegistryAbiV2, - address, - functionName: "totalBadges", - query: { - enabled: Boolean(address), - }, - }); - - const count = Number((totalBadgesQuery.data as bigint | undefined) ?? 0n); - - // Test version by calling getBadgeAt(0) with V2 ABI - const versionTestQuery = useReadContract({ - abi: badgeRegistryAbiV2, - address, - functionName: "getBadgeAt", - args: [0n], - query: { - enabled: Boolean(address) && count > 0 && version === null, - }, - }); - - // Detection logic - useEffect(() => { - if (!address) return; - - if (totalBadgesQuery.isSuccess && count === 0) { - // Empty registry defaults to V2 - setVersion("v2"); - return; - } - - if (versionTestQuery.isSuccess) { - // V2 call succeeded - setVersion("v2"); - } else if (versionTestQuery.isError) { - // V2 call failed, assume V1 - setVersion("v1"); - } - }, [ - address, - count, - totalBadgesQuery.isSuccess, - versionTestQuery.isSuccess, - versionTestQuery.isError, - ]); - - // Calculate isLoading - const isLoading = - Boolean(address) && - (totalBadgesQuery.isLoading || - (count > 0 && versionTestQuery.isLoading) || - version === null); - - // If address is undefined, return early state - if (!address) { - return { version: null, isLoading: false, error: null }; - } - - // Note: versionTestQuery.error is expected for V1, not a real error to propagate - const error = (totalBadgesQuery.error as Error | null) || null; - - return { version, isLoading, error }; -} - diff --git a/frontend/src/hooks/badges/use-get-badges.ts b/frontend/src/hooks/badges/use-get-badges.ts index 4a6afec..761eafc 100644 --- a/frontend/src/hooks/badges/use-get-badges.ts +++ b/frontend/src/hooks/badges/use-get-badges.ts @@ -7,7 +7,27 @@ import { import { BADGE_REGISTRY_ADDRESS } from "@/lib/constants/blockchainConstants"; import type { Badge } from "@/lib/types/badges"; import { bytes32ToString, bytesToString } from "@/lib/utils/blockchainUtils"; -import { useBadgeRegistryVersion } from "./use-badge-registry-version"; + +/** + * 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. + */ +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")) + ); +} export function useGetBadges(): { data: Badge[] | undefined; @@ -16,65 +36,115 @@ export function useGetBadges(): { refetch: () => void; } { const address = BADGE_REGISTRY_ADDRESS; - const { - version, - isLoading: versionLoading, - error: versionError, - } = useBadgeRegistryVersion(address); const totalBadgesQuery = useReadContract({ - abi: badgeRegistryAbiV2, // totalBadges has same signature in both versions + abi: badgeRegistryAbiV2, address, functionName: "totalBadges", query: { enabled: Boolean(address), + retry: false, }, }); const count = Number((totalBadgesQuery.data as bigint | undefined) ?? 0n); + // 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, + }, + }); + + // 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 && version !== null + count > 0 && abiMode !== undefined ? Array.from({ length: count }, (_, i) => ({ - abi: version === "v2" ? badgeRegistryAbiV2 : badgeRegistryAbiV1, + abi: abiMode === "v2" ? badgeRegistryAbiV2 : badgeRegistryAbiV1, address, functionName: "getBadgeAt" as const, args: [BigInt(i)], })) : [], - [address, count, version] + [address, count, abiMode] ); + // Execute multicall with detected ABI const badgesQuery = useReadContracts({ contracts: badgeContracts, allowFailure: false, query: { - enabled: Boolean(address) && count > 0 && version !== null, + 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; - const isV2 = version === "v2"; - return results.map(([nameBytes, descriptionBytes]) => ({ - name: bytes32ToString(nameBytes), - description: isV2 - ? bytesToString(descriptionBytes) - : bytes32ToString(descriptionBytes), - })); - }, [badgesQuery.data, version]); + + 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); + 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 = - versionLoading || totalBadgesQuery.isLoading || badgesQuery.isLoading; + totalBadgesQuery.isLoading || + (count > 0 && abiMode === undefined ? versionProbeQuery.isLoading : false) || + (count > 0 && badgesQuery.isLoading); const error = - versionError || (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 }; From 3e2ef031f6ae6168e0eaa33639779d2a06db679b Mon Sep 17 00:00:00 2001 From: V-Vaal Date: Sat, 20 Dec 2025 01:40:26 +0100 Subject: [PATCH 3/6] chore(frontend): mark BadgeRegistry V1/V2 retro-compat code for cleanup --- frontend/src/hooks/badges/use-create-badge.ts | 1 + frontend/src/hooks/badges/use-get-badges.ts | 5 +++++ frontend/src/lib/abis/badgeRegistryAbi.ts | 1 + 3 files changed, 7 insertions(+) diff --git a/frontend/src/hooks/badges/use-create-badge.ts b/frontend/src/hooks/badges/use-create-badge.ts index 20bd4cc..dfbe136 100644 --- a/frontend/src/hooks/badges/use-create-badge.ts +++ b/frontend/src/hooks/badges/use-create-badge.ts @@ -16,6 +16,7 @@ export function useCreateBadge() { if (!BADGE_REGISTRY_ADDRESS) throw new Error("Missing registry address"); const nameBytes = stringToBytes32(name); + // TODO(cleanup): Remove V1 fallback after V2 full deployment // Try V2 first (bytes description), fallback to V1 (bytes32) try { return await writeContractAsync({ diff --git a/frontend/src/hooks/badges/use-get-badges.ts b/frontend/src/hooks/badges/use-get-badges.ts index 761eafc..2a3b544 100644 --- a/frontend/src/hooks/badges/use-get-badges.ts +++ b/frontend/src/hooks/badges/use-get-badges.ts @@ -9,6 +9,7 @@ import type { Badge } from "@/lib/types/badges"; import { bytes32ToString, bytesToString } from "@/lib/utils/blockchainUtils"; /** + * 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. @@ -49,6 +50,7 @@ export function useGetBadges(): { const count = Number((totalBadgesQuery.data as bigint | undefined) ?? 0n); + // 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) @@ -67,6 +69,7 @@ export function useGetBadges(): { }, }); + // 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>(() => { @@ -83,6 +86,7 @@ export function useGetBadges(): { () => count > 0 && abiMode !== undefined ? Array.from({ length: count }, (_, i) => ({ + // TODO(cleanup): Remove conditional ABI selection after V2 full deployment abi: abiMode === "v2" ? badgeRegistryAbiV2 : badgeRegistryAbiV1, address, functionName: "getBadgeAt" as const, @@ -122,6 +126,7 @@ export function useGetBadges(): { 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) diff --git a/frontend/src/lib/abis/badgeRegistryAbi.ts b/frontend/src/lib/abis/badgeRegistryAbi.ts index 053de73..5f1e23e 100644 --- a/frontend/src/lib/abis/badgeRegistryAbi.ts +++ b/frontend/src/lib/abis/badgeRegistryAbi.ts @@ -1,3 +1,4 @@ +// TODO(cleanup): Remove V1 ABI after all BadgeRegistry deployments are V2 // ABI fragments for V1 contract (bytes32 description) export const badgeRegistryAbiV1 = [ { From 9ccec1e59a84f21d6dcb467d7d68660ed4f6bc04 Mon Sep 17 00:00:00 2001 From: V-Vaal Date: Tue, 23 Dec 2025 23:29:14 +0100 Subject: [PATCH 4/6] fix(frontend): deterministic createBadge ABI selection + align attestation bytes --- .../attestations/use-create-attestation.ts | 3 +- frontend/src/hooks/badges/use-create-badge.ts | 152 +++++++++++++++--- 2 files changed, 134 insertions(+), 21 deletions(-) diff --git a/frontend/src/hooks/attestations/use-create-attestation.ts b/frontend/src/hooks/attestations/use-create-attestation.ts index 5113ee2..8801038 100644 --- a/frontend/src/hooks/attestations/use-create-attestation.ts +++ b/frontend/src/hooks/attestations/use-create-attestation.ts @@ -49,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); @@ -97,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 dfbe136..d0788e6 100644 --- a/frontend/src/hooks/badges/use-create-badge.ts +++ b/frontend/src/hooks/badges/use-create-badge.ts @@ -1,5 +1,6 @@ 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 { badgeRegistryAbiV1, @@ -7,35 +8,146 @@ import { } from "@/lib/abis/badgeRegistryAbi"; import { stringToBytes32, stringToBytes } from "@/lib/utils/blockchainUtils"; +/** + * 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. + */ +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. + */ +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")) + ); +} + 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); - // TODO(cleanup): Remove V1 fallback after V2 full deployment - // Try V2 first (bytes description), fallback to V1 (bytes32) - try { - return await writeContractAsync({ - abi: badgeRegistryAbiV2, - address: BADGE_REGISTRY_ADDRESS, - functionName: "createBadge", - args: [nameBytes, stringToBytes(description)], - }); - } catch { - // Fallback to V1 (bytes32 description with truncation/padding) - return writeContractAsync({ - abi: badgeRegistryAbiV1, - address: BADGE_REGISTRY_ADDRESS, - functionName: "createBadge", - args: [nameBytes, stringToBytes32(description)], - }); + // Determine ABI mode deterministically BEFORE sending transaction + // All version detection happens on-demand here to prevent refetch storm + let finalAbiMode: "v1" | "v2"; + + // Fetch totalBadges on-demand + const totalBadgesResult = await readContract(config, { + abi: badgeRegistryAbiV2, + address: BADGE_REGISTRY_ADDRESS, + functionName: "totalBadges", + }); + const currentCount = Number(totalBadgesResult ?? 0n); + + 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 + finalAbiMode = "v2"; + } catch (err) { + // Check if decode error (V1 contract) + if (isDecodeError(err as Error)) { + finalAbiMode = "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: account as `0x${string}` | undefined, + chainId, + }); + // V2 simulation succeeded, contract is V2 + finalAbiMode = "v2"; + } catch (err) { + // Check if error indicates function selector not found (V1 contract) + if (isFunctionSelectorError(err as Error)) { + // Function selector not found, contract is V1 + finalAbiMode = "v1"; + } else { + // Other error (EMPTY_NAME, DUPLICATE_NAME, network, etc.) - surface it + throw err; + } + } } + + // 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: + finalAbiMode === "v2" + ? [nameBytes, stringToBytes(description)] + : [nameBytes, stringToBytes32(description)], + 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, From 2d43d2b42622a90123ca13593b5d10804bb9cc02 Mon Sep 17 00:00:00 2001 From: V-Vaal Date: Fri, 26 Dec 2025 06:54:00 +0100 Subject: [PATCH 5/6] refactor(frontend): extract ABI detection helpers and add tests (no behavior change) --- frontend/src/hooks/badges/use-create-badge.ts | 106 ++---------------- frontend/src/hooks/badges/use-get-badges.ts | 23 +--- frontend/src/lib/badges/registryVersion.ts | 75 +++++++++++++ frontend/src/lib/utils/abiDetection.ts | 66 +++++++++++ frontend/src/lib/utils/blockchainUtils.ts | 14 +++ frontend/src/test/abiDetection.test.ts | 88 +++++++++++++++ frontend/src/test/blockchainUtils.test.ts | 37 ++++++ 7 files changed, 290 insertions(+), 119 deletions(-) create mode 100644 frontend/src/lib/badges/registryVersion.ts create mode 100644 frontend/src/lib/utils/abiDetection.ts create mode 100644 frontend/src/test/abiDetection.test.ts create mode 100644 frontend/src/test/blockchainUtils.test.ts diff --git a/frontend/src/hooks/badges/use-create-badge.ts b/frontend/src/hooks/badges/use-create-badge.ts index d0788e6..146a32f 100644 --- a/frontend/src/hooks/badges/use-create-badge.ts +++ b/frontend/src/hooks/badges/use-create-badge.ts @@ -6,49 +6,8 @@ import { badgeRegistryAbiV1, badgeRegistryAbiV2, } from "@/lib/abis/badgeRegistryAbi"; -import { stringToBytes32, stringToBytes } from "@/lib/utils/blockchainUtils"; - -/** - * 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. - */ -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. - */ -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")) - ); -} +import { stringToBytes32, buildCreateBadgeArgs } from "@/lib/utils/blockchainUtils"; +import { detectBadgeRegistryVersion } from "@/lib/badges/registryVersion"; export function useCreateBadge() { const config = useConfig(); @@ -56,7 +15,6 @@ export function useCreateBadge() { const { writeContractAsync, isPending, error, data, reset } = useWriteContract(); - const createBadge = useMemo(() => { return async (name: string, description: string) => { if (!BADGE_REGISTRY_ADDRESS) { @@ -73,8 +31,6 @@ export function useCreateBadge() { // Determine ABI mode deterministically BEFORE sending transaction // All version detection happens on-demand here to prevent refetch storm - let finalAbiMode: "v1" | "v2"; - // Fetch totalBadges on-demand const totalBadgesResult = await readContract(config, { abi: badgeRegistryAbiV2, @@ -83,63 +39,19 @@ export function useCreateBadge() { }); const currentCount = Number(totalBadgesResult ?? 0n); - 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 - finalAbiMode = "v2"; - } catch (err) { - // Check if decode error (V1 contract) - if (isDecodeError(err as Error)) { - finalAbiMode = "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: account as `0x${string}` | undefined, - chainId, - }); - // V2 simulation succeeded, contract is V2 - finalAbiMode = "v2"; - } catch (err) { - // Check if error indicates function selector not found (V1 contract) - if (isFunctionSelectorError(err as Error)) { - // Function selector not found, contract is V1 - finalAbiMode = "v1"; - } else { - // Other error (EMPTY_NAME, DUPLICATE_NAME, network, etc.) - surface it - throw err; - } - } - } + 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: - finalAbiMode === "v2" - ? [nameBytes, stringToBytes(description)] - : [nameBytes, stringToBytes32(description)], + args: buildCreateBadgeArgs(nameBytes, description, finalAbiMode), account: account as `0x${string}` | undefined, chainId, }); diff --git a/frontend/src/hooks/badges/use-get-badges.ts b/frontend/src/hooks/badges/use-get-badges.ts index 2a3b544..181ecec 100644 --- a/frontend/src/hooks/badges/use-get-badges.ts +++ b/frontend/src/hooks/badges/use-get-badges.ts @@ -7,28 +7,7 @@ import { import { BADGE_REGISTRY_ADDRESS } from "@/lib/constants/blockchainConstants"; import type { Badge } from "@/lib/types/badges"; import { bytes32ToString, bytesToString } from "@/lib/utils/blockchainUtils"; - -/** - * 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. - */ -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")) - ); -} +import { isDecodeError } from "@/lib/utils/abiDetection"; export function useGetBadges(): { data: Badge[] | undefined; diff --git a/frontend/src/lib/badges/registryVersion.ts b/frontend/src/lib/badges/registryVersion.ts new file mode 100644 index 0000000..fbd86f3 --- /dev/null +++ b/frontend/src/lib/badges/registryVersion.ts @@ -0,0 +1,75 @@ +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..d104173 --- /dev/null +++ b/frontend/src/lib/utils/abiDetection.ts @@ -0,0 +1,66 @@ +/** + * 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 26830a1..1c8ffda 100644 --- a/frontend/src/lib/utils/blockchainUtils.ts +++ b/frontend/src/lib/utils/blockchainUtils.ts @@ -57,3 +57,17 @@ export function stringToBytes(value: string): `0x${string}` { } 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("")); + }); +}); + From d14db2db4acebf1edef0298cdfe4c9a99531e1cc Mon Sep 17 00:00:00 2001 From: V-Vaal Date: Fri, 26 Dec 2025 09:22:01 +0100 Subject: [PATCH 6/6] docs: document post-V2 cleanup for BadgeRegistry retro-compat --- frontend/docs/V2_CLEANUP.md | 48 +++++++++++++++++++ frontend/src/hooks/badges/use-create-badge.ts | 1 + frontend/src/hooks/badges/use-get-badges.ts | 1 + frontend/src/lib/badges/registryVersion.ts | 4 ++ frontend/src/lib/utils/abiDetection.ts | 2 + 5 files changed, 56 insertions(+) create mode 100644 frontend/docs/V2_CLEANUP.md 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/hooks/badges/use-create-badge.ts b/frontend/src/hooks/badges/use-create-badge.ts index 146a32f..dd7c7cb 100644 --- a/frontend/src/hooks/badges/use-create-badge.ts +++ b/frontend/src/hooks/badges/use-create-badge.ts @@ -39,6 +39,7 @@ export function useCreateBadge() { }); 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}`, diff --git a/frontend/src/hooks/badges/use-get-badges.ts b/frontend/src/hooks/badges/use-get-badges.ts index 181ecec..b9dc64c 100644 --- a/frontend/src/hooks/badges/use-get-badges.ts +++ b/frontend/src/hooks/badges/use-get-badges.ts @@ -29,6 +29,7 @@ export function useGetBadges(): { 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 diff --git a/frontend/src/lib/badges/registryVersion.ts b/frontend/src/lib/badges/registryVersion.ts index fbd86f3..79e9d0a 100644 --- a/frontend/src/lib/badges/registryVersion.ts +++ b/frontend/src/lib/badges/registryVersion.ts @@ -1,3 +1,7 @@ +/** + * 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"; diff --git a/frontend/src/lib/utils/abiDetection.ts b/frontend/src/lib/utils/abiDetection.ts index d104173..eae5dd5 100644 --- a/frontend/src/lib/utils/abiDetection.ts +++ b/frontend/src/lib/utils/abiDetection.ts @@ -1,4 +1,6 @@ /** + * 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