Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -52,6 +54,7 @@ export function StepContractDefinition({
definitionJson: contractDefinitionJson,
error: contractDefinitionError,
source: contractDefinitionSource,
metadata: contractDefinitionMetadata,
requiresManualReload,
} = contractState;

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) => (
<NetworkServiceErrorBanner
key={service.serviceId}
networkConfig={networkConfig}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { describe, expect, it } from 'vitest';

import type { ContractSchema } from '@openzeppelin/ui-types';

import type { ServiceHealthStatus } from '../../hooks/useNetworkServiceHealthCheck';
import {
filterUnhealthyServicesForContractDefinitionStep,
isSourcifyFetchedMetadata,
} from '../filterUnhealthyServicesForContractDefinitionStep';

const explorerUnhealthy: ServiceHealthStatus = {
serviceId: 'explorer',
serviceLabel: 'Block Explorer',
isHealthy: false,
error: 'API key is required',
};

const rpcUnhealthy: ServiceHealthStatus = {
serviceId: 'rpc',
serviceLabel: 'RPC',
isHealthy: false,
error: 'Connection refused',
};

const minimalSchema = { address: '0xabc' } as ContractSchema;

describe('isSourcifyFetchedMetadata', () => {
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]);
});
});
Original file line number Diff line number Diff line change
@@ -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;
});
}
Loading