From de7dc24a2d6f838a7d3df159e4dcb9550b8b7f19 Mon Sep 17 00:00:00 2001 From: Aleksandr Pasevin Date: Wed, 13 Aug 2025 17:03:01 +0300 Subject: [PATCH 1/5] fix(builder): prevent render loop in case contract loading on a wrong network --- .../components/ContractsUIBuilder/hooks/useUIBuilderState.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/builder/src/components/ContractsUIBuilder/hooks/useUIBuilderState.ts b/packages/builder/src/components/ContractsUIBuilder/hooks/useUIBuilderState.ts index 47bc2f3eb..42aec6550 100644 --- a/packages/builder/src/components/ContractsUIBuilder/hooks/useUIBuilderState.ts +++ b/packages/builder/src/components/ContractsUIBuilder/hooks/useUIBuilderState.ts @@ -62,6 +62,10 @@ export function useUIBuilderState() { original: originalDefinition ?? '', }); }, + onError: (err) => { + // Record error and stop auto-retry loop + uiBuilderStore.setContractDefinitionError(err.message); + }, }); const load = useCallback( From 49e0e45c06df0afc80c86826fe3051bc5351bbfe Mon Sep 17 00:00:00 2001 From: Aleksandr Pasevin Date: Wed, 13 Aug 2025 17:45:57 +0300 Subject: [PATCH 2/5] feat(adapter-evm,renderer,types): adapter filters auto-query via filterAutoQueryableFunctions --- .eslint/rules/no-extra-adapter-methods.cjs | 1 + packages/adapter-evm/src/abi/loader.ts | 8 +- packages/adapter-evm/src/adapter.ts | 18 ++++ packages/adapter-evm/src/proxy/detection.ts | 87 ++++++++++++++++++- .../StepContractDefinition.tsx | 2 +- .../hooks/useContractLoader.ts | 16 +++- .../components/ViewFunctionsPanel.tsx | 19 ++-- packages/types/src/adapters/base.ts | 8 ++ packages/types/src/contracts/proxy.ts | 2 + 9 files changed, 144 insertions(+), 17 deletions(-) diff --git a/.eslint/rules/no-extra-adapter-methods.cjs b/.eslint/rules/no-extra-adapter-methods.cjs index bb727aa91..3b48fe018 100644 --- a/.eslint/rules/no-extra-adapter-methods.cjs +++ b/.eslint/rules/no-extra-adapter-methods.cjs @@ -49,6 +49,7 @@ module.exports = { 'getSupportedExecutionMethods', 'validateExecutionConfig', 'isViewFunction', + 'filterAutoQueryableFunctions', 'queryViewFunction', 'formatFunctionResult', 'supportsWalletConnection', diff --git a/packages/adapter-evm/src/abi/loader.ts b/packages/adapter-evm/src/abi/loader.ts index a53d3bd32..172cad440 100644 --- a/packages/adapter-evm/src/abi/loader.ts +++ b/packages/adapter-evm/src/abi/loader.ts @@ -9,7 +9,7 @@ import type { } from '@openzeppelin/contracts-ui-builder-types'; import { logger, simpleHash } from '@openzeppelin/contracts-ui-builder-utils'; -import { detectProxyFromAbi, getImplementationAddress } from '../proxy/detection'; +import { detectProxyFromAbi, getAdminAddress, getImplementationAddress } from '../proxy/detection'; import type { AbiItem, TypedEvmNetworkConfig } from '../types'; import { loadAbiFromEtherscan } from './etherscan'; import { transformAbiToSchema } from './transformer'; @@ -204,6 +204,9 @@ async function handleProxyDetection( proxyType ); + // Attempt to resolve admin address as well for display purposes + const adminAddress = await getAdminAddress(contractAddress, networkConfig); + if (!implementationAddress) { logger.info('handleProxyDetection', 'Proxy detected but implementation address not found'); @@ -226,12 +229,13 @@ async function handleProxyDetection( proxyType ); - const baseProxyInfo: ProxyInfo = { + const baseProxyInfo = { isProxy: true, proxyType, implementationAddress, proxyAddress: contractAddress, detectionMethod: 'automatic', + ...(adminAddress ? { adminAddress } : {}), }; if (implementationResult) { diff --git a/packages/adapter-evm/src/adapter.ts b/packages/adapter-evm/src/adapter.ts index 214a8343b..d2a8f56c0 100644 --- a/packages/adapter-evm/src/adapter.ts +++ b/packages/adapter-evm/src/adapter.ts @@ -306,6 +306,24 @@ export class EvmAdapter implements ContractAdapter { return isEvmViewFunction(functionDetails); } + /** + * @inheritdoc + */ + public filterAutoQueryableFunctions(functions: ContractFunction[]): ContractFunction[] { + // Exclude admin/upgrade management getters that often revert or require permissions + const skipNames = new Set([ + 'admin', + 'implementation', + 'getImplementation', + '_implementation', + 'proxyAdmin', + 'changeAdmin', + 'upgradeTo', + 'upgradeToAndCall', + ]); + return functions.filter((f) => !skipNames.has(f.name)); + } + /** * @inheritdoc */ diff --git a/packages/adapter-evm/src/proxy/detection.ts b/packages/adapter-evm/src/proxy/detection.ts index 2b04b534a..5dae5c04d 100644 --- a/packages/adapter-evm/src/proxy/detection.ts +++ b/packages/adapter-evm/src/proxy/detection.ts @@ -4,7 +4,7 @@ * Automatically detects proxy contracts and resolves implementation addresses * for UUPS, Transparent, Beacon, and other proxy patterns. */ -import { createPublicClient, http, parseAbi } from 'viem'; +import { createPublicClient, http, keccak256, parseAbi, toHex } from 'viem'; import { logger } from '@openzeppelin/contracts-ui-builder-utils'; @@ -157,8 +157,17 @@ export async function getImplementationAddress( try { switch (proxyType) { case 'uups': - case 'transparent': - return await getEIP1967Implementation(proxyAddress, networkConfig); + case 'transparent': { + // Try modern EIP-1967 slot first + const eip1967Impl = await getEIP1967Implementation(proxyAddress, networkConfig); + if (eip1967Impl) return eip1967Impl; + + // Fall back to legacy OZ Unstructured Storage slot used by older proxies + const legacyImpl = await getLegacyOZImplementation(proxyAddress, networkConfig); + if (legacyImpl) return legacyImpl; + + return null; + } case 'beacon': return await getBeaconImplementation(proxyAddress, networkConfig); @@ -182,6 +191,28 @@ export async function getImplementationAddress( } } +/** + * Attempts to resolve the admin address for a proxy contract + * Tries EIP-1967 admin slot first, then legacy OZ slot + */ +export async function getAdminAddress( + proxyAddress: string, + networkConfig: TypedEvmNetworkConfig +): Promise { + try { + const eip1967Admin = await getEIP1967Admin(proxyAddress, networkConfig); + if (eip1967Admin) return eip1967Admin; + + const legacyAdmin = await getLegacyOZAdmin(proxyAddress, networkConfig); + if (legacyAdmin) return legacyAdmin; + + return null; + } catch (error) { + logger.warn('getAdminAddress', `Failed to resolve admin: ${error}`); + return null; + } +} + /** * Reads implementation address from EIP-1967 storage slot */ @@ -195,6 +226,55 @@ async function getEIP1967Implementation( return await readStorageSlot(proxyAddress, implementationSlot, networkConfig); } +/** + * Reads admin address from EIP-1967 admin storage slot + * Slot: bytes32(uint256(keccak256('eip1967.proxy.admin')) - 1) + */ +async function getEIP1967Admin( + proxyAddress: string, + networkConfig: TypedEvmNetworkConfig +): Promise { + const adminSlot = '0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103'; + return await readStorageSlot(proxyAddress, adminSlot, networkConfig); +} + +/** + * Reads admin address from legacy OpenZeppelin Unstructured Storage slot + * Slot: keccak256("org.zeppelinos.proxy.admin") + */ +async function getLegacyOZAdmin( + proxyAddress: string, + networkConfig: TypedEvmNetworkConfig +): Promise { + try { + const slot = keccak256(toHex('org.zeppelinos.proxy.admin')); + logger.info('getLegacyOZAdmin', `Trying legacy OZ admin slot: ${slot}`); + return await readStorageSlot(proxyAddress, slot, networkConfig); + } catch (error) { + logger.warn('getLegacyOZAdmin', `Failed computing or reading legacy admin slot: ${error}`); + return null; + } +} + +/** + * Reads implementation address from legacy OpenZeppelin Unstructured Storage slot + * Slot: keccak256("org.zeppelinos.proxy.implementation") + */ +async function getLegacyOZImplementation( + proxyAddress: string, + networkConfig: TypedEvmNetworkConfig +): Promise { + try { + // Compute slot deterministically at runtime to avoid hardcoding + const slot = keccak256(toHex('org.zeppelinos.proxy.implementation')); + logger.info('getLegacyOZImplementation', `Trying legacy OZ slot: ${slot}`); + return await readStorageSlot(proxyAddress, slot, networkConfig); + } catch (error) { + logger.warn('getLegacyOZImplementation', `Failed computing or reading legacy slot: ${error}`); + return null; + } +} + /** * Resolves implementation through beacon proxy pattern */ @@ -313,6 +393,7 @@ async function readStorageSlot( storageValue && storageValue !== '0x0000000000000000000000000000000000000000000000000000000000000000' ) { + logger.info('readStorageSlot', `Found non-zero value at slot ${slot}: ${storageValue}`); const implAddress = '0x' + storageValue.slice(-40); // Last 20 bytes return implAddress; } diff --git a/packages/builder/src/components/ContractsUIBuilder/StepContractDefinition/StepContractDefinition.tsx b/packages/builder/src/components/ContractsUIBuilder/StepContractDefinition/StepContractDefinition.tsx index f3ffd4f39..306b9927b 100644 --- a/packages/builder/src/components/ContractsUIBuilder/StepContractDefinition/StepContractDefinition.tsx +++ b/packages/builder/src/components/ContractsUIBuilder/StepContractDefinition/StepContractDefinition.tsx @@ -90,7 +90,7 @@ export function StepContractDefinition({ // Trigger reload with proxy detection disabled const currentFormValues = contractState.formValues; if (currentFormValues && adapter) { - void loadContract(currentFormValues); + void loadContract(currentFormValues, { skipProxyDetection: true }); } }, [contractState.formValues, adapter, loadContract]); diff --git a/packages/builder/src/components/ContractsUIBuilder/StepContractDefinition/hooks/useContractLoader.ts b/packages/builder/src/components/ContractsUIBuilder/StepContractDefinition/hooks/useContractLoader.ts index 95fd45945..53ab9b658 100644 --- a/packages/builder/src/components/ContractsUIBuilder/StepContractDefinition/hooks/useContractLoader.ts +++ b/packages/builder/src/components/ContractsUIBuilder/StepContractDefinition/hooks/useContractLoader.ts @@ -75,7 +75,10 @@ export function useContractLoader({ adapter, ignoreProxy }: UseContractLoaderPro * @param data - Form values containing contract address and optional manual definition */ const loadContract = useCallback( - async (data: FormValues) => { + async ( + data: FormValues, + options?: { skipProxyDetection?: boolean; treatAsImplementation?: boolean } + ) => { if (!adapter || loadingRef.current) return; // Narrow contractAddress to string before validation const address = @@ -115,7 +118,14 @@ export function useContractLoader({ adapter, ignoreProxy }: UseContractLoaderPro const enhancedData = { ...data, __proxyDetectionOptions: { - skipProxyDetection: ignoreProxyRef.current, + skipProxyDetection: + options && typeof options.skipProxyDetection === 'boolean' + ? options.skipProxyDetection + : ignoreProxyRef.current, + treatAsImplementation: + options && typeof options.treatAsImplementation === 'boolean' + ? options.treatAsImplementation + : false, }, }; @@ -189,7 +199,7 @@ export function useContractLoader({ adapter, ignoreProxy }: UseContractLoaderPro circuitBreakerActive, // Whether circuit breaker is preventing loads // Actions - loadContract, // Main function to load a contract definition + loadContract, // Main function to load a contract definition (supports proxy detection options) canAttemptLoad, // Check if load should be attempted markAttempted, // Mark form values as attempted diff --git a/packages/renderer/src/components/ContractStateWidget/components/ViewFunctionsPanel.tsx b/packages/renderer/src/components/ContractStateWidget/components/ViewFunctionsPanel.tsx index 82dfda6ae..7063d61b2 100644 --- a/packages/renderer/src/components/ContractStateWidget/components/ViewFunctionsPanel.tsx +++ b/packages/renderer/src/components/ContractStateWidget/components/ViewFunctionsPanel.tsx @@ -35,6 +35,9 @@ export function ViewFunctionsPanel({ contractSchema, className, }: ViewFunctionsPanelProps): JSX.Element { + const safeFunctions = adapter.filterAutoQueryableFunctions + ? adapter.filterAutoQueryableFunctions(functions) + : functions; const [results, setResults] = useState>({}); const [loadingStates, setLoadingStates] = useState>({}); const [hasQueried, setHasQueried] = useState(false); @@ -42,7 +45,7 @@ export function ViewFunctionsPanel({ // Query all view functions at once const handleQueryAll = useCallback(async () => { - if (functions.length === 0) return; + if (safeFunctions.length === 0) return; // Prevent duplicate queries if (isQueryInProgress) { @@ -54,14 +57,14 @@ export function ViewFunctionsPanel({ // Set all functions to loading state const initialLoadingStates: Record = {}; - functions.forEach((func) => { + safeFunctions.forEach((func) => { initialLoadingStates[func.id] = true; }); setLoadingStates(initialLoadingStates); try { // Create query functions with incremental result updates - const queryFunctions = functions.map( + const queryFunctions = safeFunctions.map( (func) => async (): Promise<{ funcId: string; success: boolean }> => { try { const result = await adapter.queryViewFunction( @@ -112,7 +115,7 @@ export function ViewFunctionsPanel({ setHasQueried(true); setIsQueryInProgress(false); } - }, [functions, contractAddress, adapter, contractSchema, isQueryInProgress]); + }, [safeFunctions, contractAddress, adapter, contractSchema, isQueryInProgress]); // Auto-query all functions on component mount useEffect(() => { @@ -120,7 +123,7 @@ export function ViewFunctionsPanel({ let timeoutId: NodeJS.Timeout | null = null; const performInitialQuery = async (): Promise => { - if (functions.length > 0 && mounted && !hasQueried && !isQueryInProgress) { + if (safeFunctions.length > 0 && mounted && !hasQueried && !isQueryInProgress) { // Add a small delay to help with React StrictMode double mounting timeoutId = setTimeout(() => { if (mounted) { @@ -138,7 +141,7 @@ export function ViewFunctionsPanel({ clearTimeout(timeoutId); } }; - }, [functions.length, hasQueried, isQueryInProgress, handleQueryAll]); + }, [safeFunctions.length, hasQueried, isQueryInProgress, handleQueryAll]); // Listen for RPC configuration changes useEffect(() => { @@ -157,7 +160,7 @@ export function ViewFunctionsPanel({ return unsubscribe; }, [adapter.networkConfig?.id, handleQueryAll]); - if (functions.length === 0) { + if (safeFunctions.length === 0) { return (
No simple view functions found in this contract. @@ -186,7 +189,7 @@ export function ViewFunctionsPanel({
- {functions.map((func) => ( + {safeFunctions.map((func) => ( Date: Wed, 13 Aug 2025 17:56:48 +0300 Subject: [PATCH 3/5] feat(builder): proxy banner shows admin and uses chain-agnostic note --- .../components/ContractSuccessStatus.tsx | 9 ++++++++ .../warnings/ProxyStatusIndicator.tsx | 21 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/packages/builder/src/components/ContractsUIBuilder/StepContractDefinition/components/ContractSuccessStatus.tsx b/packages/builder/src/components/ContractsUIBuilder/StepContractDefinition/components/ContractSuccessStatus.tsx index fc616770b..631ba1068 100644 --- a/packages/builder/src/components/ContractsUIBuilder/StepContractDefinition/components/ContractSuccessStatus.tsx +++ b/packages/builder/src/components/ContractsUIBuilder/StepContractDefinition/components/ContractSuccessStatus.tsx @@ -118,6 +118,14 @@ export function ContractSuccessStatus({ [proxyInfo?.implementationAddress, adapter] ); + const adminExplorerUrl = useMemo( + () => + proxyInfo?.adminAddress + ? adapter?.getExplorerUrl(proxyInfo.adminAddress) || undefined + : undefined, + [proxyInfo?.adminAddress, adapter] + ); + return (
{/* Contract Definition Source Indicator */} @@ -158,6 +166,7 @@ export function ContractSuccessStatus({ proxyInfo={proxyInfo} proxyExplorerUrl={proxyExplorerUrl} implementationExplorerUrl={implementationExplorerUrl} + adminExplorerUrl={adminExplorerUrl} onIgnoreProxy={onIgnoreProxy} /> )} diff --git a/packages/builder/src/components/warnings/ProxyStatusIndicator.tsx b/packages/builder/src/components/warnings/ProxyStatusIndicator.tsx index 70dc5a738..a4170db54 100644 --- a/packages/builder/src/components/warnings/ProxyStatusIndicator.tsx +++ b/packages/builder/src/components/warnings/ProxyStatusIndicator.tsx @@ -12,6 +12,8 @@ export interface ProxyStatusIndicatorProps { proxyExplorerUrl?: string; /** Pre-generated explorer URL for the implementation address (optional) */ implementationExplorerUrl?: string; + /** Pre-generated explorer URL for the admin address (optional) */ + adminExplorerUrl?: string; /** Callback when user wants to reload without proxy detection */ onIgnoreProxy?: () => void; /** Optional CSS class name */ @@ -26,6 +28,7 @@ export const ProxyStatusIndicator: React.FC = ({ proxyInfo, proxyExplorerUrl, implementationExplorerUrl, + adminExplorerUrl, onIgnoreProxy, className, }) => { @@ -34,6 +37,7 @@ export const ProxyStatusIndicator: React.FC = ({ } const hasImplementation = !!proxyInfo.implementationAddress; + const hasAdmin = !!proxyInfo.adminAddress; return (
= ({ className="bg-blue-100 text-blue-800" />
+ + {hasAdmin && ( +
+ Admin: + +
+ )} + +

+ Some management or diagnostic getters may not be callable by all accounts. The + implementation and controller addresses shown here are derived from on-chain state. +

) : (

From 70068cdea289922a31ab2098efcf95dd3326bdf6 Mon Sep 17 00:00:00 2001 From: Aleksandr Pasevin Date: Wed, 13 Aug 2025 19:05:49 +0300 Subject: [PATCH 4/5] feat(renderer): adaptive FunctionResult header layout to avoid overflow --- .../components/FunctionResult.tsx | 52 +++++++++++++++++-- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/packages/renderer/src/components/ContractStateWidget/components/FunctionResult.tsx b/packages/renderer/src/components/ContractStateWidget/components/FunctionResult.tsx index 1ff62a101..a12094f47 100644 --- a/packages/renderer/src/components/ContractStateWidget/components/FunctionResult.tsx +++ b/packages/renderer/src/components/ContractStateWidget/components/FunctionResult.tsx @@ -1,4 +1,4 @@ -import { JSX } from 'react'; +import { JSX, useEffect, useMemo, useRef, useState } from 'react'; import type { ContractFunction } from '@openzeppelin/contracts-ui-builder-types'; import { cn } from '@openzeppelin/contracts-ui-builder-utils'; @@ -21,14 +21,56 @@ export function FunctionResult({ const hasResult = typeof result === 'string'; const isError = hasResult && (result.startsWith('Error:') || result.startsWith('[Error:')); - const outputs = functionDetails.outputs || []; + const headerRef = useRef(null); + const [fontSize, setFontSize] = useState<'xs' | '2xs'>('xs'); + const hasScaledDownRef = useRef(false); + const lastContentRef = useRef(''); + + const outputs = useMemo(() => functionDetails.outputs || [], [functionDetails.outputs]); + const currentContent = `${functionDetails.name}-${outputs.map((o) => o.type).join(',')}`; + + // Check if content overflows and adjust font size accordingly + useEffect(() => { + // Reset scaling state when content changes + if (lastContentRef.current !== currentContent) { + lastContentRef.current = currentContent; + hasScaledDownRef.current = false; + setFontSize('xs'); + return; + } + + const checkOverflow = (): void => { + if (!headerRef.current) return; + + const container = headerRef.current; + const isOverflowing = container.scrollWidth > container.clientWidth; + + if (isOverflowing && fontSize === 'xs' && !hasScaledDownRef.current) { + hasScaledDownRef.current = true; + setFontSize('2xs'); + } + }; + + // Small delay to ensure rendering is complete + const timeoutId = setTimeout(checkOverflow, 10); + + return (): void => { + clearTimeout(timeoutId); + }; + }, [functionDetails.name, outputs, fontSize, currentContent]); return (

-
- {functionDetails.name} +
+ {functionDetails.name} {outputs.length > 0 && ( - + {`→ ${outputs.map((o) => o.type).join(', ')}`} )} From 25b906f07a42c4de9a1da5185720bbefb5851112 Mon Sep 17 00:00:00 2001 From: Aleksandr Pasevin Date: Wed, 13 Aug 2025 19:13:04 +0300 Subject: [PATCH 5/5] chore(release): add changeset entries for adapter-evm, renderer, builder, and types --- .changeset/eighty-parents-stand.md | 5 +++++ .changeset/red-turkeys-repair.md | 5 +++++ .changeset/solid-hotels-juggle.md | 5 +++++ .changeset/wet-nights-write.md | 5 +++++ 4 files changed, 20 insertions(+) create mode 100644 .changeset/eighty-parents-stand.md create mode 100644 .changeset/red-turkeys-repair.md create mode 100644 .changeset/solid-hotels-juggle.md create mode 100644 .changeset/wet-nights-write.md diff --git a/.changeset/eighty-parents-stand.md b/.changeset/eighty-parents-stand.md new file mode 100644 index 000000000..7f15bee7a --- /dev/null +++ b/.changeset/eighty-parents-stand.md @@ -0,0 +1,5 @@ +--- +'@openzeppelin/contracts-ui-builder-adapter-evm': minor +--- + +Resolve legacy OpenZeppelin proxy implementation/admin via storage slots; expose adminAddress in proxy info; delegate auto-query filtering to adapter to avoid admin-only getters; add storage-slot debug logs. diff --git a/.changeset/red-turkeys-repair.md b/.changeset/red-turkeys-repair.md new file mode 100644 index 000000000..1cfcd0b88 --- /dev/null +++ b/.changeset/red-turkeys-repair.md @@ -0,0 +1,5 @@ +--- +'@openzeppelin/contracts-ui-builder-types': minor +--- + +Extend ProxyInfo with optional adminAddress; add optional adapter method filterAutoQueryableFunctions for chain-specific auto-query filtering. diff --git a/.changeset/solid-hotels-juggle.md b/.changeset/solid-hotels-juggle.md new file mode 100644 index 000000000..5caa0e97a --- /dev/null +++ b/.changeset/solid-hotels-juggle.md @@ -0,0 +1,5 @@ +--- +'@openzeppelin/contracts-ui-builder-renderer': minor +--- + +Use adapter-provided filtering for safe auto-queries to prevent calling admin-only getters; improve FunctionResult header layout to avoid overflow. diff --git a/.changeset/wet-nights-write.md b/.changeset/wet-nights-write.md new file mode 100644 index 000000000..22f46de01 --- /dev/null +++ b/.changeset/wet-nights-write.md @@ -0,0 +1,5 @@ +--- +'@openzeppelin/contracts-ui-builder-app': minor +--- + +Show proxy implementation/admin in banner with explorer links and chain-agnostic copy; “Reset detection” now uses proxy ABI only (no implementation fetch); prevent reload loop on fatal load errors.