diff --git a/apps/builder/src/components/UIBuilder/StepContractDefinition/StepContractDefinition.tsx b/apps/builder/src/components/UIBuilder/StepContractDefinition/StepContractDefinition.tsx index c0157534..62ee9a54 100644 --- a/apps/builder/src/components/UIBuilder/StepContractDefinition/StepContractDefinition.tsx +++ b/apps/builder/src/components/UIBuilder/StepContractDefinition/StepContractDefinition.tsx @@ -4,6 +4,8 @@ import { useCallback, useMemo, useState } from 'react'; import { NetworkServiceErrorBanner } from '@openzeppelin/ui-components'; +import { filterUnhealthyServicesForContractDefinitionStep } from './utils/filterUnhealthyServicesForContractDefinitionStep'; + import { ActionBar } from '../../Common/ActionBar'; import { STEP_INDICES } from '../constants/stepIndices'; import { @@ -52,6 +54,7 @@ export function StepContractDefinition({ definitionJson: contractDefinitionJson, error: contractDefinitionError, source: contractDefinitionSource, + metadata: contractDefinitionMetadata, requiresManualReload, } = contractState; @@ -98,11 +101,30 @@ export function StepContractDefinition({ }); // Proactive network service health check - const { hasUnhealthyServices, unhealthyServices } = useNetworkServiceHealthCheck( - adapter, - networkConfig + const { unhealthyServices } = useNetworkServiceHealthCheck(adapter, networkConfig); + + const unhealthyServicesForBanners = useMemo( + () => + filterUnhealthyServicesForContractDefinitionStep(unhealthyServices, { + contractSource: contractDefinitionSource, + contractSchema, + contractMetadata: contractDefinitionMetadata, + contractDefinitionError, + isContractLoading: isLoading || isLoadingFromService, + }), + [ + unhealthyServices, + contractDefinitionSource, + contractSchema, + contractDefinitionMetadata, + contractDefinitionError, + isLoading, + isLoadingFromService, + ] ); + const hasUnhealthyServicesForBanners = unhealthyServicesForBanners.length > 0; + // Form-store synchronization useFormSync({ debouncedManualDefinition, @@ -162,9 +184,9 @@ export function StepContractDefinition({ isWidgetExpanded={isWidgetExpanded} /> - {/* Show banners for unhealthy network services */} - {hasUnhealthyServices && - unhealthyServices.map((service) => ( + {/* Show banners for unhealthy network services (suppress explorer noise when load succeeds without it) */} + {hasUnhealthyServicesForBanners && + unhealthyServicesForBanners.map((service) => ( { + it('returns true for sourcify URLs', () => { + expect(isSourcifyFetchedMetadata({ fetchedFrom: 'https://repo.sourcify.dev/...' })).toBe(true); + expect(isSourcifyFetchedMetadata({ fetchedFrom: 'Sourcify' })).toBe(true); + }); + + it('returns false for explorer or missing', () => { + expect(isSourcifyFetchedMetadata({ fetchedFrom: 'https://etherscan.io/address/0x' })).toBe( + false + ); + expect(isSourcifyFetchedMetadata(null)).toBe(false); + expect(isSourcifyFetchedMetadata({})).toBe(false); + }); +}); + +describe('filterUnhealthyServicesForContractDefinitionStep', () => { + it('keeps non-explorer unhealthy services', () => { + const out = filterUnhealthyServicesForContractDefinitionStep( + [rpcUnhealthy, explorerUnhealthy], + { + contractSource: null, + contractSchema: null, + contractMetadata: null, + contractDefinitionError: null, + isContractLoading: false, + } + ); + expect(out).toEqual([rpcUnhealthy, explorerUnhealthy]); + }); + + it('hides explorer while contract is loading', () => { + const out = filterUnhealthyServicesForContractDefinitionStep([explorerUnhealthy], { + contractSource: null, + contractSchema: null, + contractMetadata: null, + contractDefinitionError: null, + isContractLoading: true, + }); + expect(out).toEqual([]); + }); + + it('shows explorer when load failed', () => { + const out = filterUnhealthyServicesForContractDefinitionStep([explorerUnhealthy], { + contractSource: null, + contractSchema: null, + contractMetadata: null, + contractDefinitionError: 'Contract not verified', + isContractLoading: false, + }); + expect(out).toEqual([explorerUnhealthy]); + }); + + it('hides explorer when manual ABI loaded successfully', () => { + const out = filterUnhealthyServicesForContractDefinitionStep([explorerUnhealthy], { + contractSource: 'manual', + contractSchema: minimalSchema, + contractMetadata: { verificationStatus: 'unknown' }, + contractDefinitionError: null, + isContractLoading: false, + }); + expect(out).toEqual([]); + }); + + it('hides explorer when fetch succeeded via Sourcify metadata', () => { + const out = filterUnhealthyServicesForContractDefinitionStep([explorerUnhealthy], { + contractSource: 'fetched', + contractSchema: minimalSchema, + contractMetadata: { fetchedFrom: 'https://repo.sourcify.dev/foo' }, + contractDefinitionError: null, + isContractLoading: false, + }); + expect(out).toEqual([]); + }); + + it('shows explorer when fetch succeeded from etherscan URL', () => { + const out = filterUnhealthyServicesForContractDefinitionStep([explorerUnhealthy], { + contractSource: 'fetched', + contractSchema: minimalSchema, + contractMetadata: { fetchedFrom: 'https://etherscan.io/address/0x' }, + contractDefinitionError: null, + isContractLoading: false, + }); + expect(out).toEqual([explorerUnhealthy]); + }); +}); diff --git a/apps/builder/src/components/UIBuilder/StepContractDefinition/utils/filterUnhealthyServicesForContractDefinitionStep.ts b/apps/builder/src/components/UIBuilder/StepContractDefinition/utils/filterUnhealthyServicesForContractDefinitionStep.ts new file mode 100644 index 00000000..9c2f1799 --- /dev/null +++ b/apps/builder/src/components/UIBuilder/StepContractDefinition/utils/filterUnhealthyServicesForContractDefinitionStep.ts @@ -0,0 +1,64 @@ +import type { ContractDefinitionMetadata, ContractSchema } from '@openzeppelin/ui-types'; + +import type { ServiceHealthStatus } from '../hooks/useNetworkServiceHealthCheck'; + +/** + * True when contract metadata indicates the ABI was satisfied via Sourcify + * (used to suppress explorer probe errors after a successful fallback). + */ +export function isSourcifyFetchedMetadata( + metadata: ContractDefinitionMetadata | null | undefined +): boolean { + const from = metadata?.fetchedFrom?.toLowerCase() ?? ''; + return from.includes('sourcify'); +} + +export interface ContractDefinitionBannerContext { + contractSource: 'fetched' | 'manual' | null; + contractSchema: ContractSchema | null; + contractMetadata: ContractDefinitionMetadata | null; + contractDefinitionError: string | null; + isContractLoading: boolean; +} + +/** + * Filters proactive {@link ServiceHealthStatus} rows before showing {@link NetworkServiceErrorBanner}. + * + * For the block explorer service, hides the banner while a contract load is in flight (avoids + * flashing "explorer unavailable" before Sourcify is tried), when the user supplied a manual ABI, + * or when an automatic fetch succeeded via Sourcify after the explorer probe failed. + * + * Other unhealthy services (e.g. RPC) are always kept. + */ +export function filterUnhealthyServicesForContractDefinitionStep( + unhealthyServices: ServiceHealthStatus[], + ctx: ContractDefinitionBannerContext +): ServiceHealthStatus[] { + return unhealthyServices.filter((service) => { + if (service.serviceId !== 'explorer') { + return true; + } + + if (ctx.isContractLoading) { + return false; + } + + if (ctx.contractDefinitionError) { + return true; + } + + if (!ctx.contractSchema) { + return true; + } + + if (ctx.contractSource === 'manual') { + return false; + } + + if (ctx.contractSource === 'fetched' && isSourcifyFetchedMetadata(ctx.contractMetadata)) { + return false; + } + + return true; + }); +}