feat(trading): add negative oracle status banner (#3486)
This commit is contained in:
parent
75cb48a4b9
commit
6371199537
@ -7,7 +7,6 @@ import {
|
||||
useThrottledDataProvider,
|
||||
} from '@vegaprotocol/react-helpers';
|
||||
import { AsyncRenderer, ExternalLink, Splash } from '@vegaprotocol/ui-toolkit';
|
||||
|
||||
import { marketProvider, marketDataProvider } from '@vegaprotocol/market-list';
|
||||
import { useGlobalStore, usePageTitleStore } from '../../stores';
|
||||
import { TradeGrid, TradePanels } from './trade-grid';
|
||||
|
@ -12,6 +12,7 @@ import { memo, useState } from 'react';
|
||||
import type { ReactNode, ComponentProps } from 'react';
|
||||
import { DepthChartContainer } from '@vegaprotocol/market-depth';
|
||||
import { CandlesChartContainer } from '@vegaprotocol/candles-chart';
|
||||
import { OracleBanner } from '../../components/banner';
|
||||
import {
|
||||
Tab,
|
||||
LocalStoragePersistTabs as Tabs,
|
||||
@ -273,7 +274,10 @@ export const TradeGrid = ({
|
||||
}: TradeGridProps) => {
|
||||
return (
|
||||
<div className="h-full grid grid-rows-[min-content_1fr]">
|
||||
<TradeMarketHeader market={market} onSelect={onSelect} />
|
||||
<div>
|
||||
<TradeMarketHeader market={market} onSelect={onSelect} />
|
||||
<OracleBanner marketId={market?.id || ''} />
|
||||
</div>
|
||||
<MainGrid
|
||||
marketId={market?.id || ''}
|
||||
onSelect={onSelect}
|
||||
|
@ -1,16 +1,13 @@
|
||||
import { AnnouncementBanner } from '@vegaprotocol/announcements';
|
||||
import { AnnouncementBanner as Banner } from '@vegaprotocol/announcements';
|
||||
import { useEnvironment } from '@vegaprotocol/environment';
|
||||
|
||||
export const Banner = () => {
|
||||
export const AnnouncementBanner = () => {
|
||||
const { ANNOUNCEMENTS_CONFIG_URL } = useEnvironment();
|
||||
|
||||
// Return an empty div so that the grid layout in _app.page.ts
|
||||
// renders correctly
|
||||
if (!ANNOUNCEMENTS_CONFIG_URL) {
|
||||
return <div />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AnnouncementBanner app="console" configUrl={ANNOUNCEMENTS_CONFIG_URL} />
|
||||
);
|
||||
return <Banner app="console" configUrl={ANNOUNCEMENTS_CONFIG_URL} />;
|
||||
};
|
@ -1 +1,2 @@
|
||||
export * from './banner';
|
||||
export * from './announcement-banner';
|
||||
export * from './oracle-banner';
|
||||
|
44
apps/trading/components/banner/oracle-banner.tsx
Normal file
44
apps/trading/components/banner/oracle-banner.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import { t } from '@vegaprotocol/i18n';
|
||||
import { useMarketOracle } from '@vegaprotocol/market-info';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { Intent, NotificationBanner } from '@vegaprotocol/ui-toolkit';
|
||||
|
||||
const oracleStatuses = {
|
||||
UNKNOWN: t(
|
||||
"This public key's proofs have not been verified yet, or no proofs have been provided yet."
|
||||
),
|
||||
GOOD: t("This public key's proofs have been verified."),
|
||||
SUSPICIOUS: t(
|
||||
'This public key is suspected to be acting in bad faith, pending investigation.'
|
||||
),
|
||||
MALICIOUS: t('This public key has been observed acting in bad faith.'),
|
||||
RETIRED: t('This public key is no longer in use.'),
|
||||
COMPROMISED: t(
|
||||
'This public key is no longer in the control of its original owners.'
|
||||
),
|
||||
};
|
||||
|
||||
export const OracleBanner = ({ marketId }: { marketId: string }) => {
|
||||
const oracle = useMarketOracle(marketId);
|
||||
if (!oracle || oracle.status === 'GOOD') {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<NotificationBanner intent={Intent.Danger}>
|
||||
<div>
|
||||
Oracle status for this market is {oracle.status}.{' '}
|
||||
{oracleStatuses[oracle.status]}
|
||||
</div>
|
||||
{oracle.status_reason ? (
|
||||
<ReactMarkdown
|
||||
className="react-markdown-container"
|
||||
skipHtml={true}
|
||||
disallowedElements={['img']}
|
||||
linkTarget="_blank"
|
||||
>
|
||||
{oracle.status_reason}
|
||||
</ReactMarkdown>
|
||||
) : null}
|
||||
</NotificationBanner>
|
||||
);
|
||||
};
|
@ -30,7 +30,7 @@ import ToastsManager from './toasts-manager';
|
||||
import { HashRouter, useLocation, useSearchParams } from 'react-router-dom';
|
||||
import { Connectors } from '../lib/vega-connectors';
|
||||
import { ViewingBanner } from '../components/viewing-banner';
|
||||
import { Banner } from '../components/banner';
|
||||
import { AnnouncementBanner } from '../components/banner';
|
||||
import { AppLoader, DynamicLoader } from '../components/app-loader';
|
||||
import { Navbar } from '../components/navbar';
|
||||
import { ENV } from '../lib/config';
|
||||
@ -86,7 +86,7 @@ function AppBody({ Component }: AppProps) {
|
||||
</Head>
|
||||
<Title />
|
||||
<div className={gridClasses}>
|
||||
<Banner />
|
||||
<AnnouncementBanner />
|
||||
<Navbar theme={VEGA_ENV === Networks.TESTNET ? 'yellow' : 'system'} />
|
||||
<ViewingBanner />
|
||||
<main data-testid={location.pathname}>
|
||||
|
1
libs/market-info/src/hooks/index.ts
Normal file
1
libs/market-info/src/hooks/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './use-market-oracle';
|
132
libs/market-info/src/hooks/use-market-oracle.spec.ts
Normal file
132
libs/market-info/src/hooks/use-market-oracle.spec.ts
Normal file
@ -0,0 +1,132 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useMarketOracle } from './use-market-oracle';
|
||||
import type { MarketInfoQuery } from '../components/market-info/__generated__/MarketInfo';
|
||||
import type { Provider } from '@vegaprotocol/oracles';
|
||||
|
||||
const ORACLE_PROOFS_URL = 'ORACLE_PROOFS_URL';
|
||||
|
||||
const address = 'address';
|
||||
const key = 'key';
|
||||
|
||||
const mockEnvironment = jest.fn(() => ({ ORACLE_PROOFS_URL }));
|
||||
const mockDataProvider = jest.fn<
|
||||
{ data: MarketInfoQuery['market'] },
|
||||
unknown[]
|
||||
>(() => ({
|
||||
data: {
|
||||
tradableInstrument: {
|
||||
instrument: {
|
||||
product: {
|
||||
dataSourceSpecForSettlementData: {
|
||||
data: {
|
||||
sourceType: {
|
||||
__typename: 'DataSourceDefinitionExternal',
|
||||
sourceType: {
|
||||
signers: [
|
||||
{
|
||||
signer: {
|
||||
__typename: 'ETHAddress',
|
||||
address,
|
||||
},
|
||||
},
|
||||
{
|
||||
signer: {
|
||||
__typename: 'PubKey',
|
||||
key,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as MarketInfoQuery['market'],
|
||||
}));
|
||||
|
||||
const mockOracleProofs = jest.fn<{ data?: Provider[] }, unknown[]>(() => ({}));
|
||||
|
||||
jest.mock('@vegaprotocol/environment', () => ({
|
||||
useEnvironment: jest.fn((args) => mockEnvironment()),
|
||||
}));
|
||||
|
||||
jest.mock('@vegaprotocol/react-helpers', () => ({
|
||||
useDataProvider: jest.fn((args) => mockDataProvider()),
|
||||
}));
|
||||
|
||||
jest.mock('@vegaprotocol/oracles', () => ({
|
||||
useOracleProofs: jest.fn((args) => mockOracleProofs()),
|
||||
}));
|
||||
|
||||
const marketId = 'marketId';
|
||||
describe('useMarketOracle', () => {
|
||||
it('returns undefined if no market info present', () => {
|
||||
mockDataProvider.mockReturnValueOnce({ data: null });
|
||||
const { result } = renderHook(() => useMarketOracle(marketId));
|
||||
expect(result.current).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined if no oracle proofs present', () => {
|
||||
mockOracleProofs.mockReturnValueOnce({ data: undefined });
|
||||
const { result } = renderHook(() => useMarketOracle(marketId));
|
||||
expect(result.current).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns oracle matched by eth_address', () => {
|
||||
const data = [
|
||||
{
|
||||
proofs: [
|
||||
{
|
||||
eth_address: 'eth_address',
|
||||
type: 'eth_address',
|
||||
},
|
||||
],
|
||||
oracle: {},
|
||||
} as Provider,
|
||||
{
|
||||
proofs: [
|
||||
{
|
||||
eth_address: address,
|
||||
type: 'eth_address',
|
||||
},
|
||||
],
|
||||
oracle: {},
|
||||
} as Provider,
|
||||
];
|
||||
mockOracleProofs.mockReturnValueOnce({
|
||||
data,
|
||||
});
|
||||
const { result } = renderHook(() => useMarketOracle(marketId));
|
||||
expect(result.current).toBe(data[1].oracle);
|
||||
});
|
||||
|
||||
it('returns oracle matching by public_key', () => {
|
||||
const data = [
|
||||
{
|
||||
proofs: [
|
||||
{
|
||||
public_key: 'public_key',
|
||||
type: 'public_key',
|
||||
},
|
||||
],
|
||||
oracle: {},
|
||||
} as Provider,
|
||||
{
|
||||
proofs: [
|
||||
{
|
||||
public_key: key,
|
||||
type: 'public_key',
|
||||
},
|
||||
],
|
||||
oracle: {},
|
||||
} as Provider,
|
||||
];
|
||||
mockOracleProofs.mockReturnValueOnce({
|
||||
data,
|
||||
});
|
||||
const { result } = renderHook(() => useMarketOracle(marketId));
|
||||
expect(result.current).toBe(data[1].oracle);
|
||||
});
|
||||
});
|
43
libs/market-info/src/hooks/use-market-oracle.ts
Normal file
43
libs/market-info/src/hooks/use-market-oracle.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { useEnvironment } from '@vegaprotocol/environment';
|
||||
import { useOracleProofs } from '@vegaprotocol/oracles';
|
||||
import { useDataProvider } from '@vegaprotocol/react-helpers';
|
||||
import { marketInfoProvider } from '../components/market-info/market-info-data-provider';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export const useMarketOracle = (marketId: string) => {
|
||||
const { ORACLE_PROOFS_URL } = useEnvironment();
|
||||
const { data: marketInfo } = useDataProvider({
|
||||
dataProvider: marketInfoProvider,
|
||||
variables: { marketId },
|
||||
});
|
||||
const { data } = useOracleProofs(ORACLE_PROOFS_URL);
|
||||
return useMemo(() => {
|
||||
if (!data || !marketInfo) {
|
||||
return undefined;
|
||||
}
|
||||
const dataSource =
|
||||
marketInfo.tradableInstrument.instrument.product
|
||||
.dataSourceSpecForSettlementData.data;
|
||||
return data.find((provider) =>
|
||||
provider.proofs.some(
|
||||
(proof) =>
|
||||
(proof.type === 'eth_address' &&
|
||||
dataSource.sourceType.__typename ===
|
||||
'DataSourceDefinitionExternal' &&
|
||||
dataSource.sourceType.sourceType.signers?.some(
|
||||
(signer) =>
|
||||
signer.signer.__typename === 'ETHAddress' &&
|
||||
signer.signer.address === proof.eth_address
|
||||
)) ||
|
||||
(proof.type === 'public_key' &&
|
||||
dataSource.sourceType.__typename ===
|
||||
'DataSourceDefinitionExternal' &&
|
||||
dataSource.sourceType.sourceType.signers?.some(
|
||||
(signer) =>
|
||||
signer.signer.__typename === 'PubKey' &&
|
||||
signer.signer.key === proof.public_key
|
||||
))
|
||||
)
|
||||
)?.oracle;
|
||||
}, [data, marketInfo]);
|
||||
};
|
@ -1 +1,2 @@
|
||||
export * from './components';
|
||||
export * from './hooks';
|
||||
|
@ -16,7 +16,6 @@ export const Icon = ({ size = 4, name, className, ariaLabel }: IconProps) => {
|
||||
'inline-block',
|
||||
'fill-current',
|
||||
'align-text-bottom',
|
||||
'fill-current',
|
||||
'shrink-0',
|
||||
// Cant just concatenate as TW wont pick up that the class is being used
|
||||
// so below syntax is required
|
||||
|
@ -12,6 +12,7 @@ export * from './dialog';
|
||||
export * from './drawer';
|
||||
export * from './dropdown-menu';
|
||||
export * from './form-group';
|
||||
export * from './healthbar';
|
||||
export * from './icon';
|
||||
export * from './indicator';
|
||||
export * from './input-error';
|
||||
@ -25,6 +26,7 @@ export * from './nav-dropdown';
|
||||
export * from './nav';
|
||||
export * from './navigation';
|
||||
export * from './notification';
|
||||
export * from './notification-banner';
|
||||
export * from './pagination';
|
||||
export * from './popover';
|
||||
export * from './progress-bar';
|
||||
@ -48,4 +50,3 @@ export * from './traffic-light';
|
||||
export * from './vega-icons';
|
||||
export * from './vega-logo';
|
||||
export * from './viewing-as-user';
|
||||
export * from './healthbar';
|
||||
|
@ -0,0 +1 @@
|
||||
export * from './notification-banner';
|
@ -0,0 +1,45 @@
|
||||
/* eslint-disable jsx-a11y/accessible-emoji */
|
||||
import type { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||
import { Intent } from '../../utils/intent';
|
||||
import { NotificationBanner } from './notification-banner';
|
||||
import { Button } from '../button';
|
||||
|
||||
export default {
|
||||
title: 'NotificationBanner',
|
||||
component: NotificationBanner,
|
||||
} as ComponentMeta<typeof NotificationBanner>;
|
||||
|
||||
const Template: ComponentStory<typeof NotificationBanner> = (args) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<NotificationBanner
|
||||
intent={Intent.Warning}
|
||||
onClose={() => {
|
||||
return;
|
||||
}}
|
||||
>
|
||||
<div className="uppercase ">
|
||||
The network will upgrade to v0.68.5 in{' '}
|
||||
<span className="text-vega-orange-500">9234</span> blocks
|
||||
</div>
|
||||
<div>
|
||||
Trading activity will be interrupted, manage your risk appropriately.
|
||||
<a href="/">View details</a>
|
||||
</div>
|
||||
</NotificationBanner>
|
||||
<NotificationBanner intent={Intent.Danger}>
|
||||
The oracle for this market has been flagged as malicious by the
|
||||
community.
|
||||
</NotificationBanner>
|
||||
<NotificationBanner>
|
||||
Viewing as Vega user 0592X...20CKZ
|
||||
<Button size="sm" className="ml-2">
|
||||
Exit view as
|
||||
</Button>
|
||||
</NotificationBanner>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Default = Template.bind({});
|
||||
Default.args = {};
|
@ -0,0 +1,80 @@
|
||||
import classNames from 'classnames';
|
||||
import { toastIconMapping } from '../toast';
|
||||
import { Intent } from '../../utils/intent';
|
||||
import { Icon } from '../icon';
|
||||
|
||||
interface NotificationBannerProps {
|
||||
intent?: Intent;
|
||||
children?: React.ReactNode;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export const NotificationBanner = ({
|
||||
intent = Intent.None,
|
||||
children,
|
||||
onClose,
|
||||
}: NotificationBannerProps) => {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'flex items-center p-3 border-b min-h-[56px]',
|
||||
'text-[12px] leading-[16px] font-normal',
|
||||
{
|
||||
'bg-vega-light-100 dark:bg-vega-dark-100 ': intent === Intent.None,
|
||||
'bg-vega-blue-300 dark:bg-vega-blue-700': intent === Intent.Primary,
|
||||
'bg-vega-green-300 dark:bg-vega-green-700': intent === Intent.Success,
|
||||
'bg-vega-orange-300 dark:bg-vega-orange-700':
|
||||
intent === Intent.Warning,
|
||||
'bg-vega-pink-300 dark:bg-vega-pink-700': intent === Intent.Danger,
|
||||
},
|
||||
{
|
||||
'border-b-vega-light-200 dark:border-b-vega-dark-200 ':
|
||||
intent === Intent.None,
|
||||
|
||||
'border-b-vega-blue-500 dark:border-b-vega-blue-500':
|
||||
intent === Intent.Primary,
|
||||
|
||||
'border-b-vega-green-600 dark:border-b-vega-green-500':
|
||||
intent === Intent.Success,
|
||||
|
||||
'border-b-vega-orange-500 dark:border-b-vega-orange-500':
|
||||
intent === Intent.Warning,
|
||||
|
||||
'border-b-vega-pink-500 dark:border-b-vega-pink-500':
|
||||
intent === Intent.Danger,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{intent === Intent.None ? null : (
|
||||
<Icon
|
||||
name={toastIconMapping[intent]}
|
||||
size={4}
|
||||
className={classNames('mr-2', {
|
||||
'text-vega-blue-500 dark:text-vega-blue-500':
|
||||
intent === Intent.Primary,
|
||||
|
||||
'text-vega-green-600 dark:text-vega-green-500':
|
||||
intent === Intent.Success,
|
||||
|
||||
'text-vega-orange-500 dark:text-vega-orange-500':
|
||||
intent === Intent.Warning,
|
||||
|
||||
'text-vega-pink-500 dark:text-vega-pink-500':
|
||||
intent === Intent.Danger,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
<div className="grow">{children}</div>
|
||||
{onClose ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="notification-banner-close"
|
||||
onClick={onClose}
|
||||
className="ml-2"
|
||||
>
|
||||
<Icon name="cross" size={4} className="dark:text-white" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
@ -30,7 +30,7 @@ type ToastProps = Toast & {
|
||||
state?: ToastState;
|
||||
};
|
||||
|
||||
const toastIconMapping: { [i in Intent]: IconName } = {
|
||||
export const toastIconMapping: { [i in Intent]: IconName } = {
|
||||
[Intent.None]: IconNames.HELP,
|
||||
[Intent.Primary]: IconNames.INFO_SIGN,
|
||||
[Intent.Success]: IconNames.TICK_CIRCLE,
|
||||
|
Loading…
Reference in New Issue
Block a user