Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/eighty-parents-stand.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/red-turkeys-repair.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/solid-hotels-juggle.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/wet-nights-write.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions .eslint/rules/no-extra-adapter-methods.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ module.exports = {
'getSupportedExecutionMethods',
'validateExecutionConfig',
'isViewFunction',
'filterAutoQueryableFunctions',
'queryViewFunction',
'formatFunctionResult',
'supportsWalletConnection',
Expand Down
8 changes: 6 additions & 2 deletions packages/adapter-evm/src/abi/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');

Expand All @@ -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) {
Expand Down
18 changes: 18 additions & 0 deletions packages/adapter-evm/src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
87 changes: 84 additions & 3 deletions packages/adapter-evm/src/proxy/detection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);
Expand All @@ -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<string | null> {
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
*/
Expand All @@ -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<string | null> {
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<string | null> {
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<string | null> {
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
*/
Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="space-y-3">
{/* Contract Definition Source Indicator */}
Expand Down Expand Up @@ -158,6 +166,7 @@ export function ContractSuccessStatus({
proxyInfo={proxyInfo}
proxyExplorerUrl={proxyExplorerUrl}
implementationExplorerUrl={implementationExplorerUrl}
adminExplorerUrl={adminExplorerUrl}
onIgnoreProxy={onIgnoreProxy}
/>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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,
},
};

Expand Down Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -26,6 +28,7 @@ export const ProxyStatusIndicator: React.FC<ProxyStatusIndicatorProps> = ({
proxyInfo,
proxyExplorerUrl,
implementationExplorerUrl,
adminExplorerUrl,
onIgnoreProxy,
className,
}) => {
Expand All @@ -34,6 +37,7 @@ export const ProxyStatusIndicator: React.FC<ProxyStatusIndicatorProps> = ({
}

const hasImplementation = !!proxyInfo.implementationAddress;
const hasAdmin = !!proxyInfo.adminAddress;

return (
<div
Expand Down Expand Up @@ -80,6 +84,23 @@ export const ProxyStatusIndicator: React.FC<ProxyStatusIndicatorProps> = ({
className="bg-blue-100 text-blue-800"
/>
</div>

{hasAdmin && (
<div className="flex items-center gap-2 text-blue-700">
<span>Admin:</span>
<AddressDisplay
address={proxyInfo.adminAddress!}
showCopyButton={true}
explorerUrl={adminExplorerUrl}
className="bg-blue-100 text-blue-800"
/>
</div>
)}

<p className="text-xs text-blue-700">
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.
</p>
</div>
) : (
<p className="text-sm text-blue-800">
Expand Down
Loading
Loading