diff --git a/public/kleros-logo.png b/public/kleros-logo.png new file mode 100644 index 00000000..edf80a4e Binary files /dev/null and b/public/kleros-logo.png differ diff --git a/src/components/pages/evm/address/displays/AccountDisplay.tsx b/src/components/pages/evm/address/displays/AccountDisplay.tsx index 125a97d2..55f8b11d 100644 --- a/src/components/pages/evm/address/displays/AccountDisplay.tsx +++ b/src/components/pages/evm/address/displays/AccountDisplay.tsx @@ -1,6 +1,7 @@ import type React from "react"; import { useCallback, useMemo, useState } from "react"; import { getNetworkById } from "../../../../../config/networks"; +import type { KlerosTag } from "../../../../../services/KlerosService"; import type { Address, ENSReverseResult, RPCMetadata, Transaction } from "../../../../../types"; import AIAnalysisPanel from "../../../../common/AIAnalysis/AIAnalysisPanel"; import { AddressHeader, TransactionHistory } from "../shared"; @@ -18,6 +19,7 @@ interface AccountDisplayProps { ensName?: string | null; reverseResult?: ENSReverseResult | null; isMainnet?: boolean; + klerosTag?: KlerosTag | null; } const AccountDisplay: React.FC = ({ @@ -30,6 +32,7 @@ const AccountDisplay: React.FC = ({ ensName, reverseResult, isMainnet = true, + klerosTag, }) => { const network = getNetworkById(networkId); const networkName = network?.name ?? "Unknown Network"; @@ -82,6 +85,7 @@ const AccountDisplay: React.FC = ({ metadata={metadata} selectedProvider={selectedProvider} onProviderSelect={onProviderSelect} + klerosTag={klerosTag} />
diff --git a/src/components/pages/evm/address/displays/ContractDisplay.tsx b/src/components/pages/evm/address/displays/ContractDisplay.tsx index 950cef44..e46c61b3 100644 --- a/src/components/pages/evm/address/displays/ContractDisplay.tsx +++ b/src/components/pages/evm/address/displays/ContractDisplay.tsx @@ -3,6 +3,7 @@ import { useContext, useMemo } from "react"; import { getNetworkById } from "../../../../../config/networks"; import { AppContext } from "../../../../../context"; import { useSourcify } from "../../../../../hooks/useSourcify"; +import type { KlerosTag } from "../../../../../services/KlerosService"; import type { Address, ENSReverseResult, RPCMetadata } from "../../../../../types"; import AIAnalysisPanel from "../../../../common/AIAnalysis/AIAnalysisPanel"; import { AddressHeader } from "../shared"; @@ -23,6 +24,7 @@ interface ContractDisplayProps { ensName?: string | null; reverseResult?: ENSReverseResult | null; isMainnet?: boolean; + klerosTag?: KlerosTag | null; } const ContractDisplay: React.FC = ({ @@ -35,6 +37,7 @@ const ContractDisplay: React.FC = ({ ensName, reverseResult, isMainnet = true, + klerosTag, }) => { const { jsonFiles } = useContext(AppContext); const network = getNetworkById(networkId); @@ -126,6 +129,7 @@ const ContractDisplay: React.FC = ({ metadata={metadata} selectedProvider={selectedProvider} onProviderSelect={onProviderSelect} + klerosTag={klerosTag} />
diff --git a/src/components/pages/evm/address/displays/ERC1155Display.tsx b/src/components/pages/evm/address/displays/ERC1155Display.tsx index d370b8f5..ec5375d7 100644 --- a/src/components/pages/evm/address/displays/ERC1155Display.tsx +++ b/src/components/pages/evm/address/displays/ERC1155Display.tsx @@ -8,6 +8,7 @@ import { getAssetUrl, type TokenMetadata, } from "../../../../../services/MetadataService"; +import type { KlerosTag } from "../../../../../services/KlerosService"; import type { Address, ENSReverseResult, RPCMetadata } from "../../../../../types"; import { decodeAbiString } from "../../../../../utils/hexUtils"; import { logger } from "../../../../../utils/logger"; @@ -29,6 +30,7 @@ interface ERC1155DisplayProps { ensName?: string | null; reverseResult?: ENSReverseResult | null; isMainnet?: boolean; + klerosTag?: KlerosTag | null; } const ERC1155Display: React.FC = ({ @@ -41,6 +43,7 @@ const ERC1155Display: React.FC = ({ ensName, reverseResult, isMainnet = true, + klerosTag, }) => { const { jsonFiles, rpcUrls } = useContext(AppContext); const [tokenMetadata, setTokenMetadata] = useState(null); @@ -234,6 +237,7 @@ const ERC1155Display: React.FC = ({ onProviderSelect={onProviderSelect} tokenSymbol={collectionSymbol} tokenName={collectionName} + klerosTag={klerosTag} />
diff --git a/src/components/pages/evm/address/displays/ERC20Display.tsx b/src/components/pages/evm/address/displays/ERC20Display.tsx index eb64cf6f..e1d093a9 100644 --- a/src/components/pages/evm/address/displays/ERC20Display.tsx +++ b/src/components/pages/evm/address/displays/ERC20Display.tsx @@ -8,6 +8,7 @@ import { getAssetUrl, type TokenMetadata, } from "../../../../../services/MetadataService"; +import type { KlerosTag } from "../../../../../services/KlerosService"; import type { Address, ENSReverseResult, RPCMetadata } from "../../../../../types"; import { hexToUtf8 } from "../../../../../utils/erc20Utils"; import { logger } from "../../../../../utils/logger"; @@ -29,6 +30,7 @@ interface ERC20DisplayProps { ensName?: string | null; reverseResult?: ENSReverseResult | null; isMainnet?: boolean; + klerosTag?: KlerosTag | null; } const ERC20Display: React.FC = ({ @@ -41,6 +43,7 @@ const ERC20Display: React.FC = ({ ensName, reverseResult, isMainnet = true, + klerosTag, }) => { const { jsonFiles, rpcUrls } = useContext(AppContext); const [tokenMetadata, setTokenMetadata] = useState(null); @@ -247,6 +250,7 @@ const ERC20Display: React.FC = ({ onProviderSelect={onProviderSelect} tokenSymbol={tokenSymbol} tokenName={tokenName} + klerosTag={klerosTag} />
diff --git a/src/components/pages/evm/address/displays/ERC721Display.tsx b/src/components/pages/evm/address/displays/ERC721Display.tsx index 10905af5..e9387815 100644 --- a/src/components/pages/evm/address/displays/ERC721Display.tsx +++ b/src/components/pages/evm/address/displays/ERC721Display.tsx @@ -8,6 +8,7 @@ import { getAssetUrl, type TokenMetadata, } from "../../../../../services/MetadataService"; +import type { KlerosTag } from "../../../../../services/KlerosService"; import type { Address, ENSReverseResult, RPCMetadata } from "../../../../../types"; import { decodeAbiString } from "../../../../../utils/hexUtils"; import { logger } from "../../../../../utils/logger"; @@ -29,6 +30,7 @@ interface ERC721DisplayProps { ensName?: string | null; reverseResult?: ENSReverseResult | null; isMainnet?: boolean; + klerosTag?: KlerosTag | null; } const ERC721Display: React.FC = ({ @@ -41,6 +43,7 @@ const ERC721Display: React.FC = ({ ensName, reverseResult, isMainnet = true, + klerosTag, }) => { const { jsonFiles, rpcUrls } = useContext(AppContext); const [tokenMetadata, setTokenMetadata] = useState(null); @@ -216,6 +219,7 @@ const ERC721Display: React.FC = ({ onProviderSelect={onProviderSelect} tokenSymbol={collectionSymbol} tokenName={collectionName} + klerosTag={klerosTag} />
diff --git a/src/components/pages/evm/address/index.tsx b/src/components/pages/evm/address/index.tsx index 90c637f4..2fa94e57 100644 --- a/src/components/pages/evm/address/index.tsx +++ b/src/components/pages/evm/address/index.tsx @@ -4,6 +4,7 @@ import { useLocation, useParams } from "react-router-dom"; import { AppContext } from "../../../../context"; import { useDataService } from "../../../../hooks/useDataService"; import { useENS } from "../../../../hooks/useENS"; +import { useKlerosTag } from "../../../../hooks/useKlerosTag"; import { useProviderSelection } from "../../../../hooks/useProviderSelection"; import { ENSService } from "../../../../services/ENS/ENSService"; import type { Address as AddressData, AddressType, DataWithMetadata } from "../../../../types"; @@ -60,6 +61,8 @@ export default function Address() { `address_${numericNetworkId}_${address}`, ); + const klerosTag = useKlerosTag(address, numericNetworkId); + // Resolve ENS name to address useEffect(() => { if (!isEnsName || !addressParam) { @@ -250,6 +253,7 @@ export default function Address() { metadata: addressDataResult?.metadata, selectedProvider, onProviderSelect: setSelectedProvider, + klerosTag, }; // Render appropriate display component based on detected type diff --git a/src/components/pages/evm/address/shared/AddressHeader.tsx b/src/components/pages/evm/address/shared/AddressHeader.tsx index b7e82539..34fc60b5 100644 --- a/src/components/pages/evm/address/shared/AddressHeader.tsx +++ b/src/components/pages/evm/address/shared/AddressHeader.tsx @@ -1,4 +1,6 @@ import type React from "react"; +import { useTranslation } from "react-i18next"; +import { getKlerosCurateItemUrl, type KlerosTag } from "../../../../../services/KlerosService"; import type { AddressType, RPCMetadata } from "../../../../../types"; import { getAddressTypeIcon, getAddressTypeLabel } from "../../../../../utils/addressTypeDetection"; import { RPCIndicator } from "../../../../common/RPCIndicator"; @@ -12,6 +14,7 @@ interface AddressHeaderProps { onProviderSelect?: (provider: string) => void; tokenSymbol?: string; tokenName?: string; + klerosTag?: KlerosTag | null; } // Truncate hash to show first and last N characters @@ -31,7 +34,9 @@ const AddressHeader: React.FC = ({ onProviderSelect, tokenSymbol, tokenName, + klerosTag, }) => { + const { t } = useTranslation("address"); const truncatedHash = truncateHash(addressHash, 4); return ( @@ -41,6 +46,22 @@ const AddressHeader: React.FC = ({ {getAddressTypeIcon(addressType)} {getAddressTypeLabel(addressType)} {tokenSymbol && {tokenSymbol}} + {klerosTag && ( + + Kleros + {klerosTag.publicNameTag} ↗ + + )}
{(ensName || tokenName) && {ensName || tokenName}} {addressHash} diff --git a/src/components/pages/home/index.tsx b/src/components/pages/home/index.tsx index a2588392..10437e44 100644 --- a/src/components/pages/home/index.tsx +++ b/src/components/pages/home/index.tsx @@ -51,7 +51,7 @@ export default function Home() { const [showTestnets, setShowTestnets] = useState(false); const { productionNetworks, testnetNetworks } = useMemo(() => { - const isDevelopment = process.env.REACT_APP_ENVIRONMENT === "development"; + const isDevelopment = import.meta.env.VITE_ENVIRONMENT === "development"; const localhostChainId = 31337; // In development, treat localhost as a production network (show with other networks) diff --git a/src/config/networks.ts b/src/config/networks.ts index eacaa5ad..b4853235 100644 --- a/src/config/networks.ts +++ b/src/config/networks.ts @@ -100,8 +100,8 @@ export function getEnabledNetworks(): NetworkConfig[] { const envNetworks = process.env.REACT_APP_OPENSCAN_NETWORKS; const localhostChainId = 31337; - // REACT_APP_ENVIRONMENT is set by webpack DefinePlugin based on NODE_ENV - const isDevelopment = process.env.REACT_APP_ENVIRONMENT === "development"; + // VITE_ENVIRONMENT is injected via vite.config.ts define block based on NODE_ENV + const isDevelopment = import.meta.env.VITE_ENVIRONMENT === "development"; // Check if localhost is explicitly enabled in REACT_APP_OPENSCAN_NETWORKS const isLocalhostExplicitlyEnabled = envNetworks diff --git a/src/config/subdomains.ts b/src/config/subdomains.ts index 74874640..2bd520c4 100644 --- a/src/config/subdomains.ts +++ b/src/config/subdomains.ts @@ -14,7 +14,7 @@ export interface SubdomainConfig { const WEENUS_SEPOLIA_ADDRESS = "0x7E0987E5b3a30e3f2828572Bb659A548460a3003"; // Check if we're in development mode -const isDevelopment = process.env.REACT_APP_ENVIRONMENT === "development"; +const isDevelopment = import.meta.env.VITE_ENVIRONMENT === "development"; export const subdomainConfig: SubdomainConfig[] = [ // Network subdomains diff --git a/src/context/AppContext.tsx b/src/context/AppContext.tsx index a8e36006..c1f1fab3 100644 --- a/src/context/AppContext.tsx +++ b/src/context/AppContext.tsx @@ -111,7 +111,7 @@ export const AppContextProvider = ({ children }: { children: ReactNode }) => { // Check if Hardhat should be included (only when both conditions are met) const envNetworks = process.env.REACT_APP_OPENSCAN_NETWORKS; - const isDevelopment = process.env.REACT_APP_ENVIRONMENT === "development"; + const isDevelopment = import.meta.env.VITE_ENVIRONMENT === "development"; const hardhatInEnv = envNetworks?.split(",").some((id) => id.trim() === "31337"); // Add Hardhat network if in development AND explicitly enabled diff --git a/src/hooks/useKlerosTag.ts b/src/hooks/useKlerosTag.ts new file mode 100644 index 00000000..e13ed34b --- /dev/null +++ b/src/hooks/useKlerosTag.ts @@ -0,0 +1,40 @@ +import { useEffect, useState } from "react"; +import { fetchAddress } from "../services/MetadataService"; +import { type KlerosTag, fetchKlerosTag, isKlerosGraphEnabled } from "../services/KlerosService"; + +export function useKlerosTag( + address: string | null | undefined, + chainId: number, +): KlerosTag | null { + const [tag, setTag] = useState(null); + + useEffect(() => { + if (!address || chainId !== 1) { + setTag(null); + return; + } + + setTag(null); + + if (isKlerosGraphEnabled()) { + fetchKlerosTag(address) + .then(setTag) + .catch(() => { + // Fallback to metadata service on Graph error + fetchAddress(chainId, address).then((meta) => { + if (meta?.source?.[0] === "kleros" && meta.label) { + setTag({ itemID: "", publicNameTag: meta.label }); + } + }); + }); + } else { + fetchAddress(chainId, address).then((meta) => { + if (meta?.source?.[0] === "kleros" && meta.label) { + setTag({ itemID: "", publicNameTag: meta.label }); + } + }); + } + }, [address, chainId]); + + return tag; +} diff --git a/src/locales/en/address.json b/src/locales/en/address.json index de30b4f4..30b956da 100644 --- a/src/locales/en/address.json +++ b/src/locales/en/address.json @@ -1,4 +1,5 @@ { + "klerosVerifiedTooltip": "Verified by Kleros curated registry", "balance": "Balance", "contractName": "Contract Name", "compiler": "Compiler", diff --git a/src/locales/es/address.json b/src/locales/es/address.json index c2f5295a..d7870d38 100644 --- a/src/locales/es/address.json +++ b/src/locales/es/address.json @@ -1,4 +1,5 @@ { + "klerosVerifiedTooltip": "Verificado por el registro curado de Kleros", "balance": "Balance", "contractName": "Nombre del contrato", "compiler": "Compilador", diff --git a/src/services/KlerosService.ts b/src/services/KlerosService.ts new file mode 100644 index 00000000..d2e4b2e9 --- /dev/null +++ b/src/services/KlerosService.ts @@ -0,0 +1,82 @@ +export interface KlerosTag { + itemID: string; + publicNameTag: string; + projectName?: string; +} + +// Single source of truth — derive both forms from this +const ATQ_REGISTRY_CHECKSUM = "0x66260C69d03837016d88c9877e61e08Ef74C59F2"; +const ATQ_REGISTRY = ATQ_REGISTRY_CHECKSUM.toLowerCase(); + +export const KLEROS_CURATE_REGISTRY_URL = `https://curate.kleros.io/tcr/100/${ATQ_REGISTRY_CHECKSUM}`; + +export const getKlerosCurateItemUrl = (itemID: string) => + itemID ? `${KLEROS_CURATE_REGISTRY_URL}/${itemID}` : KLEROS_CURATE_REGISTRY_URL; + +// In-memory cache: address (lowercase) → { tag, expires } +const cache = new Map(); +const CACHE_TTL = 60 * 60 * 1000; // 1 hour + +// Public key with authorized domains — safe to include in source +const THE_GRAPH_API_KEY = "90d378ffb102d0bcce31c59bc16057b6"; + +export function isKlerosGraphEnabled(): boolean { + return true; +} + +const GRAPH_URL = () => + `https://gateway-arbitrum.network.thegraph.com/api/${THE_GRAPH_API_KEY}/subgraphs/id/9hHo5MpjpC1JqfD3BsgFnojGurXRHTrHWcUcZPPCo6m8`; + +async function graphQuery(query: string): Promise { + const response = await fetch(GRAPH_URL(), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ query }), + }); + if (!response.ok) throw new Error(`The Graph request failed: ${response.status}`); + const text = await response.text(); + try { + return JSON.parse(text); + } catch { + throw new Error(`The Graph returned invalid JSON: ${text.slice(0, 100)}`); + } +} + +export async function fetchKlerosTag(address: string): Promise { + if (!/^0x[0-9a-fA-F]{40}$/.test(address)) return null; + + const key = address.toLowerCase(); + const cached = cache.get(key); + if (cached && Date.now() < cached.expires) return cached.tag; + + const query = `{ itemProps(where: { label: "Contract Address", value_contains_nocase: "${address}" }, first: 1) { item { item { itemID registryAddress status metadata { props { label value } } } } } }`; + + const json = (await graphQuery(query)) as { + data?: { + itemProps?: Array<{ + item?: { + item?: { + itemID: string; + registryAddress: string; + status: string; + metadata?: { props: Array<{ label: string; value: string }> }; + }; + }; + }>; + }; + }; + const litem = json.data?.itemProps?.[0]?.item?.item; + + let tag: KlerosTag | null = null; + if (litem?.status === "Registered" && litem.registryAddress === ATQ_REGISTRY) { + const props = litem.metadata?.props ?? []; + const publicNameTag = props.find((p) => p.label === "Public Name Tag")?.value ?? ""; + const projectName = props.find((p) => p.label === "Project Name")?.value || undefined; + if (publicNameTag) { + tag = { itemID: litem.itemID, publicNameTag, projectName }; + } + } + + cache.set(key, { tag, expires: Date.now() + CACHE_TTL }); + return tag; +} diff --git a/src/services/MetadataService.ts b/src/services/MetadataService.ts index badb81ce..79fa55dd 100644 --- a/src/services/MetadataService.ts +++ b/src/services/MetadataService.ts @@ -662,6 +662,40 @@ export function getAssetUrl(assetPath: string): string { return `${METADATA_BASE_URL}/${assetPath}`; } +/** + * Address metadata from addresses/evm/{chainId}/{address}.json + */ +export interface AddressMetadata { + address: string; + chainId: number; + supporter: string; + label?: string; + source?: [string, string]; +} + +/** + * Fetch address metadata from addresses/evm/{chainId}/{address}.json + */ +export async function fetchAddress( + chainId: number, + address: string, +): Promise { + try { + const response = await fetch( + `${METADATA_BASE_URL}/addresses/evm/${chainId}/${address.toLowerCase()}.json`, + ); + + if (!response.ok) { + return null; + } + + return await response.json(); + } catch (error) { + logger.error("Error fetching address metadata:", error); + return null; + } +} + /** * Fetch token metadata from tokens/{chainId}/{address}.json */ diff --git a/src/styles/components.css b/src/styles/components.css index e8d2f17f..0dd975b6 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -4563,6 +4563,30 @@ button.tx-section-header-toggle { border-radius: 4px; } +.kleros-verified-tag { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.75rem; + font-weight: 600; + color: #6b3fa0; + padding: 2px 8px; + background: rgba(107, 63, 160, 0.1); + border: 1px solid rgba(107, 63, 160, 0.3); + border-radius: 4px; + text-decoration: none; +} + +.kleros-verified-tag:hover { + background: rgba(107, 63, 160, 0.2); +} + +.kleros-logo { + width: 14px; + height: 14px; + object-fit: contain; +} + /* Token Info Row */ .token-info-row { display: flex; diff --git a/src/utils/constants.ts b/src/utils/constants.ts index b9e64034..47d5063e 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -1 +1 @@ -export const ENVIRONMENT = process.env.REACT_APP_ENVIRONMENT || "development"; +export const ENVIRONMENT = import.meta.env.VITE_ENVIRONMENT || "development"; diff --git a/vite.config.ts b/vite.config.ts index c8360636..38fa1a12 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -58,7 +58,7 @@ export default defineConfig({ "process.env.REACT_APP_OPENSCAN_NETWORKS": JSON.stringify( process.env.REACT_APP_OPENSCAN_NETWORKS || "" ), - "process.env.REACT_APP_ENVIRONMENT": JSON.stringify( + "import.meta.env.VITE_ENVIRONMENT": JSON.stringify( process.env.NODE_ENV || "development" ), },