From a60379b40fac5b051fc054b6f568f899e7b17314 Mon Sep 17 00:00:00 2001 From: Edd Date: Wed, 25 Jan 2023 13:33:12 +0000 Subject: [PATCH] feat(explorer): data submission tx view (#2698) --- .../data-submission-json-oracle.spec.tsx | 45 ++++++ .../data-submission-json-oracle.tsx | 40 ++++++ .../data-submission-open-oracle.spec.tsx | 58 ++++++++ .../data-submission-open-oracle.tsx | 64 +++++++++ .../open-oracle/open-oracle-prices.spec.tsx | 88 ++++++++++++ .../open-oracle/open-oracle-prices.tsx | 132 ++++++++++++++++++ .../txs/details/tx-data-submission.tsx | 55 ++++++++ .../txs/details/tx-details-wrapper.tsx | 3 + 8 files changed, 485 insertions(+) create mode 100644 apps/explorer/src/app/components/txs/details/oracle-data/data-submission-json-oracle.spec.tsx create mode 100644 apps/explorer/src/app/components/txs/details/oracle-data/data-submission-json-oracle.tsx create mode 100644 apps/explorer/src/app/components/txs/details/oracle-data/data-submission-open-oracle.spec.tsx create mode 100644 apps/explorer/src/app/components/txs/details/oracle-data/data-submission-open-oracle.tsx create mode 100644 apps/explorer/src/app/components/txs/details/oracle-data/open-oracle/open-oracle-prices.spec.tsx create mode 100644 apps/explorer/src/app/components/txs/details/oracle-data/open-oracle/open-oracle-prices.tsx create mode 100644 apps/explorer/src/app/components/txs/details/tx-data-submission.tsx diff --git a/apps/explorer/src/app/components/txs/details/oracle-data/data-submission-json-oracle.spec.tsx b/apps/explorer/src/app/components/txs/details/oracle-data/data-submission-json-oracle.spec.tsx new file mode 100644 index 000000000..d09ff92f7 --- /dev/null +++ b/apps/explorer/src/app/components/txs/details/oracle-data/data-submission-json-oracle.spec.tsx @@ -0,0 +1,45 @@ +import { render } from '@testing-library/react'; +import { JSONOracleData, parseData } from './data-submission-json-oracle'; + +describe('JSONOracleData', () => { + it('renders a code block if there is a payload that is base64 encoded', () => { + const p = 'eyJwcmljZXMuRVRIOTkudmFsdWUiOiAiMTMwNTAwMDAwMCJ9'; + const screen = render(); + expect(screen.getByTestId('json-code')).toBeInTheDocument(); + }); + + it('renders an error if b64 decoding fails', () => { + const p = 'not-encoded'; + const screen = render(); + + expect( + screen.getByText('Awaiting Block Explorer transaction details') + ).toBeInTheDocument(); + }); +}); + +describe('Parse JSON oracle data', () => { + it('returns null for an invalid b64 string', () => { + expect(parseData('🤷')).toBeNull(); + }); + + it('returns null for a string that looks like an object', () => { + expect(parseData('{}')).toBeNull(); + }); + + it('returns null for null', () => { + expect(parseData(null as unknown as string)).toBeNull(); + }); + + it('returns a string for b64 encoded data', () => { + const res = parseData('dGVzdCA9IHRydWU='); + expect(typeof res).toEqual('string'); + expect(res).toEqual('test = true'); + }); + + it('returns a string for b64 encoded json, without any extra parsing', () => { + const res = parseData('eyJ0ZXN0Ijp0cnVlfQ=='); + expect(typeof res).toEqual('string'); + expect(res).toEqual('{"test":true}'); + }); +}); diff --git a/apps/explorer/src/app/components/txs/details/oracle-data/data-submission-json-oracle.tsx b/apps/explorer/src/app/components/txs/details/oracle-data/data-submission-json-oracle.tsx new file mode 100644 index 000000000..06562c2a1 --- /dev/null +++ b/apps/explorer/src/app/components/txs/details/oracle-data/data-submission-json-oracle.tsx @@ -0,0 +1,40 @@ +import { t } from '@vegaprotocol/react-helpers'; + +interface JSONOracleDataProps { + payload: string; +} + +/** + * Renders a JSON oracle. Given the shape could be any valid JSON, + * this doesn't try to do anything smart - it just renders the message + */ +export const JSONOracleData = ({ payload }: JSONOracleDataProps) => { + const decodedSubmission = parseData(payload); + + if (!decodedSubmission) { + return <>{t('Awaiting Block Explorer transaction details')}; + } + + return ( +
+ {decodedSubmission} +
+ ); +}; + +/** + * Safely parses the JSON Oracle payload + * + * @param payload base64 encoded JSON + * @returns Object or null + */ +export function parseData(payload: string) { + try { + if (!payload || typeof payload !== 'string') { + throw new Error('Not a string'); + } + return atob(payload); + } catch (e) { + return null; + } +} diff --git a/apps/explorer/src/app/components/txs/details/oracle-data/data-submission-open-oracle.spec.tsx b/apps/explorer/src/app/components/txs/details/oracle-data/data-submission-open-oracle.spec.tsx new file mode 100644 index 000000000..a3aebbf38 --- /dev/null +++ b/apps/explorer/src/app/components/txs/details/oracle-data/data-submission-open-oracle.spec.tsx @@ -0,0 +1,58 @@ +import { render } from '@testing-library/react'; +import { OpenOracleData, parseData } from './data-submission-open-oracle'; + +describe('OpenOracleData', () => { + it('renders a table if there is data', () => { + const p = + 'eyJtZXNzYWdlcyI6IFsiMHgwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDgwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA2M2M3ZTMwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwYzAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwNGYxZjM4ZWEwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwNjcwNzI2OTYzNjU3MzAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAzNDI1NDQzMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMCJdLCAicHJpY2VzIjogeyJFVEgiOiIxMjMifSwgInNpZ25hdHVyZXMiOiBbIjB4N2U0Nzc2OWEyNzI5NzIwN2Q5ZTIyMmM3YTY2YjBlYzk3Njg4MWY3NmVkYjg5OGFhYzQ5OTRlZWYwOTc4MmI3NGUxMGE5MTJmZWQ1Y2E3NmFlN2U3NzVjOWZiOTBjZGE4ZjhkMDhlNzUyOTE1NzQ2ZTY2OGE1ZWM3OWRmOTZmNmQwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDFjIl0sICJ0aW1lc3RhbXAiOiAiMTIzIn0='; + + const screen = render(); + expect(screen.getByTestId('decoded-payload')).toBeInTheDocument(); + expect(screen.getByText('ETH')).toBeInTheDocument(); + expect(screen.getByText('123')).toBeInTheDocument(); + }); + + it('renders an error if decoding fails', () => { + const p = 'not-encoded'; + const screen = render(); + + expect( + screen.getByText('Awaiting Block Explorer transaction details') + ).toBeInTheDocument(); + }); +}); + +describe('Parse Open oracle data', () => { + it('returns null for an invalid b64 string', () => { + expect(parseData('🤷')).toBeNull(); + }); + + it('returns null for a string that looks like an object', () => { + expect(parseData('{}')).toBeNull(); + }); + + it('returns null for null', () => { + expect(parseData(null as unknown as string)).toBeNull(); + }); + + it('returns a string for b64 encoded data', () => { + const res = parseData('dGVzdCA9IHRydWU='); + expect(res).toBeNull(); + }); + + it('returns null if the parsed object does not look like te right shape', () => { + // Encoded: {"test":true} + const res = parseData('eyJ0ZXN0Ijp0cnVlfQ=='); + expect(res).toBeNull(); + }); + + it('returns an object if the payload parses and looks fine', () => { + // Encoded: {"messages": [], "prices": {}, "signatures": [], "timestamp": "123"} + const res = parseData( + 'eyJtZXNzYWdlcyI6IFtdLCAicHJpY2VzIjoge30sICJzaWduYXR1cmVzIjogW10sICJ0aW1lc3RhbXAiOiAiMTIzIn0=' + ); + expect(typeof res.prices).toEqual('object'); + expect(Array.isArray(res.messages)).toBeTruthy(); + expect(Array.isArray(res.signatures)).toBeTruthy(); + }); +}); diff --git a/apps/explorer/src/app/components/txs/details/oracle-data/data-submission-open-oracle.tsx b/apps/explorer/src/app/components/txs/details/oracle-data/data-submission-open-oracle.tsx new file mode 100644 index 000000000..4b312e341 --- /dev/null +++ b/apps/explorer/src/app/components/txs/details/oracle-data/data-submission-open-oracle.tsx @@ -0,0 +1,64 @@ +import { t } from '@vegaprotocol/react-helpers'; +import { NestedDataList } from '../../../nested-data-list'; +import { OpenOraclePrices } from './open-oracle/open-oracle-prices'; + +interface OpenOracleDataProps { + payload: string; +} + +/** + * Decodes Open Oracle data based on it's main parts: + * - messages + * - signatures + * - prices + */ +export const OpenOracleData = ({ payload }: OpenOracleDataProps) => { + const decodedSubmission = parseData(payload); + + if (!decodedSubmission) { + return <>{t('Awaiting Block Explorer transaction details')}; + } + + const date = decodedSubmission.timestamp; + const formattedDate = new Date(date * 1000).toLocaleString(); + + return ( +
+ {formattedDate} + + + +
+ {t('Decoded payload')} + +
+
+ ); +}; + +/** + * Safely parses the Open Oracle payload + * + * @param payload base64 encoded JSON + * @returns Object or null + */ +export function parseData(payload: string) { + try { + if (!payload || typeof payload !== 'string') { + throw new Error('Not a string'); + } + + const res = JSON.parse(atob(payload)); + if (!res.prices || !res.messages || !res.signatures) { + throw new Error('Not an open oracle format message'); + } + + return res; + } catch (e) { + return null; + } +} diff --git a/apps/explorer/src/app/components/txs/details/oracle-data/open-oracle/open-oracle-prices.spec.tsx b/apps/explorer/src/app/components/txs/details/oracle-data/open-oracle/open-oracle-prices.spec.tsx new file mode 100644 index 000000000..72b1ef6c8 --- /dev/null +++ b/apps/explorer/src/app/components/txs/details/oracle-data/open-oracle/open-oracle-prices.spec.tsx @@ -0,0 +1,88 @@ +import { render } from '@testing-library/react'; +import { OpenOraclePrices } from './open-oracle-prices'; +import type { Price } from './open-oracle-prices'; + +describe('OpenOraclePrices', () => { + it('Will not render with no prices', () => { + const p = undefined as unknown as Price; + const m: string[] = []; + const s: string[] = []; + const screen = render( + + ); + expect(screen.container).toBeEmptyDOMElement(); + }); + + it('Will not render with no messages', () => { + const m = undefined as unknown as string[]; + const p: Price = {}; + const s: string[] = []; + const screen = render( + + ); + expect(screen.container).toBeEmptyDOMElement(); + }); + + it('will not render with no signatures', () => { + const s = undefined as unknown as string[]; + const m: string[] = []; + const p: Price = {}; + const screen = render( + + ); + expect(screen.container).toBeEmptyDOMElement(); + }); + + it('will not render with mismatched prices/signatures', () => { + const p: Price = { ETH: '123' }; + const m = ['0x123', '0x456']; + const s: string[] = []; + const screen = render( + + ); + expect(screen.container).toBeEmptyDOMElement(); + }); + + it('will not render with mismatched signatures and messages', () => { + const p: Price = { ETH: '123', BTC: '12' }; + const s = ['0xcac', '0xb33f']; + const m = ['0x1204o42c', '0xb3ddd3f', '0x9999']; + + const screen = render( + + ); + expect(screen.container).toBeEmptyDOMElement(); + }); + + it('renders a table when there are prices and signatures and messages', () => { + const p: Price = { ETH: '123', BTC: '12' }; + const s = ['0xcac', '0xb33f']; + const m = ['0x120442c', '0xb3ddd3f']; + + const screen = render( + + ); + expect(screen.getByTestId('openoracleprices')).toBeInTheDocument(); + + // Table headings + expect(screen.getByText('Asset')).toBeInTheDocument(); + expect(screen.getByText('Price')).toBeInTheDocument(); + expect(screen.getByText('Signature')).toBeInTheDocument(); + expect(screen.getByText('Message')).toBeInTheDocument(); + // Disabled - see vegaprotocol/frontend-monorepo#/2726 + // expect(screen.getByText('Signer')).toBeInTheDocument(); + + // One row per asset + expect(screen.getByText('ETH')).toBeInTheDocument(); + expect(screen.getByText('123')).toBeInTheDocument(); + expect(screen.getByText('0xcac')).toBeInTheDocument(); + expect(screen.getByText('0xb33f')).toBeInTheDocument(); + // Does not test signer element for this row + + expect(screen.getByText('BTC')).toBeInTheDocument(); + expect(screen.getByText('12')).toBeInTheDocument(); + expect(screen.getByText('0x120442c')).toBeInTheDocument(); + expect(screen.getByText('0xb3ddd3f')).toBeInTheDocument(); + // Does not test signer element for this row + }); +}); diff --git a/apps/explorer/src/app/components/txs/details/oracle-data/open-oracle/open-oracle-prices.tsx b/apps/explorer/src/app/components/txs/details/oracle-data/open-oracle/open-oracle-prices.tsx new file mode 100644 index 000000000..4b7199e65 --- /dev/null +++ b/apps/explorer/src/app/components/txs/details/oracle-data/open-oracle/open-oracle-prices.tsx @@ -0,0 +1,132 @@ +import { t } from '@vegaprotocol/react-helpers'; +import { CopyWithTooltip } from '@vegaprotocol/ui-toolkit'; +import { TableRow } from '../../../../table'; +import { TruncateInline } from '../../../../truncate/truncate'; +import { utils } from 'ethers'; + +export type Price = { + [key: string]: string; +}; + +interface OpenOraclePricesProps { + prices: Price; + signatures: string[]; + messages: string[]; +} + +/** + * Open Oracle price table, showing the entries in this + * decoded message + * + * Notes: + * - Signer is derived by recovering the address from the + * signature and the message. This is currently disabled + * as the implementation is incorrect + */ +export function OpenOraclePrices({ + prices, + messages, + signatures, +}: OpenOraclePricesProps) { + if ( + !prices || + !messages || + !signatures || + Object.keys(prices).length !== messages.length || + signatures.length !== messages.length + ) { + return null; + } + + return ( + + + + + + + + {/* */} + + + + {Object.keys(prices).map((k: string, i: number) => { + if (k && prices[k]) { + const message = messages[i]; + const signature = signatures[i]; + return ( + + ); + } else { + return null; + } + })} + +
{t('Asset')}{t('Price')}{t('Signature')}{t('Message')}{t('Signer')}
+ ); +} + +type OpenOraclePriceProps = { + asset: string; + value: string; + message: string; + signature: string; +}; + +export function OpenOraclePrice({ + asset, + value, + signature, + message, +}: OpenOraclePriceProps) { + // const addr = getAddressFromMessageAndSignature(message, signature); + return ( + + {asset} + {value} + + + + + + + + + + + {/* + + + + */} + + ); +} + +export function getAddressFromMessageAndSignature( + message: string, + signature: string +) { + try { + const m = utils.hashMessage(utils.arrayify(utils.keccak256(message))); + const s = utils.splitSignature( + signature.slice(0, 130) + signature.slice(-2) + ); + + return utils.recoverAddress(m, s); + } catch (e) { + return '-'; + } +} diff --git a/apps/explorer/src/app/components/txs/details/tx-data-submission.tsx b/apps/explorer/src/app/components/txs/details/tx-data-submission.tsx new file mode 100644 index 000000000..10383b242 --- /dev/null +++ b/apps/explorer/src/app/components/txs/details/tx-data-submission.tsx @@ -0,0 +1,55 @@ +import { t } from '@vegaprotocol/react-helpers'; +import type { BlockExplorerTransactionResult } from '../../../routes/types/block-explorer-response'; +import type { TendermintBlocksResponse } from '../../../routes/blocks/tendermint-blocks-response'; +import { TxDetailsShared } from './shared/tx-details-shared'; +import { TableWithTbody } from '../../table'; +import type { components } from '../../../../types/explorer'; +import { OpenOracleData } from './oracle-data/data-submission-open-oracle'; +import { JSONOracleData } from './oracle-data/data-submission-json-oracle'; + +export type OracleSubmissionSource = + components['schemas']['OracleDataSubmissionOracleSource']; + +interface TxDetailsDataSubmissionProps { + txData: BlockExplorerTransactionResult | undefined; + pubKey: string | undefined; + blockData: TendermintBlocksResponse | undefined; +} + +/** + * Someone cancelled an order + */ +export const TxDetailsDataSubmission = ({ + txData, + pubKey, + blockData, +}: TxDetailsDataSubmissionProps) => { + if (!txData || !txData.command.oracleDataSubmission) { + return <>{t('Awaiting Block Explorer transaction details')}; + } + + const type: OracleSubmissionSource = + txData.command.oracleDataSubmission.source; + if (!type) { + return <>{t('Awaiting Block Explorer transaction details')}; + } + + const payload = txData.command.oracleDataSubmission.payload; + + return ( + <> + + + + {type === 'ORACLE_SOURCE_OPEN_ORACLE' ? ( + + ) : ( + + )} + + ); +}; diff --git a/apps/explorer/src/app/components/txs/details/tx-details-wrapper.tsx b/apps/explorer/src/app/components/txs/details/tx-details-wrapper.tsx index 07b7cc230..0d68324f0 100644 --- a/apps/explorer/src/app/components/txs/details/tx-details-wrapper.tsx +++ b/apps/explorer/src/app/components/txs/details/tx-details-wrapper.tsx @@ -19,6 +19,7 @@ import { TxDetailsUndelegate } from './tx-undelegation'; import { TxDetailsLiquiditySubmission } from './tx-liquidity-submission'; import { TxDetailsLiquidityAmendment } from './tx-liquidity-amend'; import { TxDetailsLiquidityCancellation } from './tx-liquidity-cancel'; +import { TxDetailsDataSubmission } from './tx-data-submission'; interface TxDetailsWrapperProps { txData: BlockExplorerTransactionResult | undefined; @@ -82,6 +83,8 @@ function getTransactionComponent(txData?: BlockExplorerTransactionResult) { switch (txData.type) { case 'Submit Order': return TxDetailsOrder; + case 'Submit Oracle Data': + return TxDetailsDataSubmission; case 'Cancel Order': return TxDetailsOrderCancel; case 'Amend Order':