feat(explorer): data submission tx view (#2698)

This commit is contained in:
Edd 2023-01-25 13:33:12 +00:00 committed by GitHub
parent 7db2a58f9d
commit a60379b40f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 485 additions and 0 deletions

View File

@ -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(<JSONOracleData payload={p} />);
expect(screen.getByTestId('json-code')).toBeInTheDocument();
});
it('renders an error if b64 decoding fails', () => {
const p = 'not-encoded';
const screen = render(<JSONOracleData payload={p} />);
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}');
});
});

View File

@ -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 (
<section>
<code data-testid="json-code">{decodedSubmission}</code>
</section>
);
};
/**
* 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;
}
}

View File

@ -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(<OpenOracleData payload={p} />);
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(<OpenOracleData payload={p} />);
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();
});
});

View File

@ -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 (
<section>
<span>{formattedDate}</span>
<code>
<OpenOraclePrices
prices={decodedSubmission.prices}
messages={decodedSubmission.messages}
signatures={decodedSubmission.signatures}
/>
</code>
<details data-testid="decoded-payload">
<summary>{t('Decoded payload')}</summary>
<NestedDataList data={decodedSubmission} />
</details>
</section>
);
};
/**
* 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;
}
}

View File

@ -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(
<OpenOraclePrices prices={p} messages={m} signatures={s} />
);
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(
<OpenOraclePrices prices={p} messages={m} signatures={s} />
);
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(
<OpenOraclePrices prices={p} messages={m} signatures={s} />
);
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(
<OpenOraclePrices prices={p} messages={m} signatures={s} />
);
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(
<OpenOraclePrices prices={p} messages={m} signatures={s} />
);
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(
<OpenOraclePrices prices={p} messages={m} signatures={s} />
);
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
});
});

View File

@ -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 (
<table data-testid="openoracleprices">
<thead>
<TableRow modifier="bordered">
<th>{t('Asset')}</th>
<th className="text-right">{t('Price')}</th>
<th>{t('Signature')}</th>
<th>{t('Message')}</th>
{/* <th>{t('Signer')}</th> */}
</TableRow>
</thead>
<tbody>
{Object.keys(prices).map((k: string, i: number) => {
if (k && prices[k]) {
const message = messages[i];
const signature = signatures[i];
return (
<OpenOraclePrice
key={`price-${i}`}
asset={k}
value={prices[k]}
message={message}
signature={signature}
/>
);
} else {
return null;
}
})}
</tbody>
</table>
);
}
type OpenOraclePriceProps = {
asset: string;
value: string;
message: string;
signature: string;
};
export function OpenOraclePrice({
asset,
value,
signature,
message,
}: OpenOraclePriceProps) {
// const addr = getAddressFromMessageAndSignature(message, signature);
return (
<TableRow modifier="bordered" key={`${asset}`}>
<td className="px-4">{asset}</td>
<td className="px-4 text-right">{value}</td>
<td className="px-4">
<CopyWithTooltip text={signature}>
<button>
<TruncateInline text={signature} startChars={5} endChars={5} />
</button>
</CopyWithTooltip>
</td>
<td className="px-4">
<CopyWithTooltip text={message}>
<button>
<TruncateInline text={message} startChars={5} endChars={5} />
</button>
</CopyWithTooltip>
</td>
{/*<td className="px-4">
<CopyWithTooltip text={message}>
<button>
<TruncateInline text={addr} startChars={5} endChars={5} />
</button>
</CopyWithTooltip>
</td> */}
</TableRow>
);
}
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 '-';
}
}

View File

@ -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 (
<>
<TableWithTbody className="mb-8">
<TxDetailsShared
txData={txData}
pubKey={pubKey}
blockData={blockData}
/>
</TableWithTbody>
{type === 'ORACLE_SOURCE_OPEN_ORACLE' ? (
<OpenOracleData payload={payload} />
) : (
<JSONOracleData payload={payload} />
)}
</>
);
};

View File

@ -19,6 +19,7 @@ import { TxDetailsUndelegate } from './tx-undelegation';
import { TxDetailsLiquiditySubmission } from './tx-liquidity-submission'; import { TxDetailsLiquiditySubmission } from './tx-liquidity-submission';
import { TxDetailsLiquidityAmendment } from './tx-liquidity-amend'; import { TxDetailsLiquidityAmendment } from './tx-liquidity-amend';
import { TxDetailsLiquidityCancellation } from './tx-liquidity-cancel'; import { TxDetailsLiquidityCancellation } from './tx-liquidity-cancel';
import { TxDetailsDataSubmission } from './tx-data-submission';
interface TxDetailsWrapperProps { interface TxDetailsWrapperProps {
txData: BlockExplorerTransactionResult | undefined; txData: BlockExplorerTransactionResult | undefined;
@ -82,6 +83,8 @@ function getTransactionComponent(txData?: BlockExplorerTransactionResult) {
switch (txData.type) { switch (txData.type) {
case 'Submit Order': case 'Submit Order':
return TxDetailsOrder; return TxDetailsOrder;
case 'Submit Oracle Data':
return TxDetailsDataSubmission;
case 'Cancel Order': case 'Cancel Order':
return TxDetailsOrderCancel; return TxDetailsOrderCancel;
case 'Amend Order': case 'Amend Order':