Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ Activity: Add Timelock Controller Events #1256

Merged
merged 2 commits into from
Oct 26, 2023
Merged
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
109 changes: 80 additions & 29 deletions components/RiskFeed/Decode/index.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import React, { type PropsWithChildren, createContext, useContext } from 'react';
import { decodeFunctionData, type Abi, type Address, isHex, isAddress, Hex } from 'viem';
import { Box, ButtonBase } from '@mui/material';
import { decodeFunctionData, type Abi, type Address, isHex, isAddress, Hex, getAddress } from 'viem';
import { Box, ButtonBase, IconButton } from '@mui/material';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import { useTranslation } from 'react-i18next';
import Link from 'next/link';

import { DataDecoded } from '../api';
import useEtherscanLink from 'hooks/useEtherscanLink';
import { formatWallet } from 'utils/utils';
import { formatWallet, formatHex } from 'utils/utils';

export type Contracts = Record<Address, { name: string; abi: Abi }>;

Expand All @@ -27,7 +28,20 @@ export default function Decode({ to, data }: Props) {
}

const contract = contracts[to];
if (!contract || contract.name !== 'TimelockController') {
const target = data.parameters.find((p) => p.name === 'target');
const payload = data.parameters.find((p) => p.name === 'payload' || p.name === 'data');

const targetContract = target?.value !== undefined && isAddress(target.value) ? contracts[target.value] : undefined;

if (
!contract ||
contract.name !== 'TimelockController' ||
!target ||
!payload ||
!isAddress(target.value) ||
!isHex(payload.value) ||
!targetContract
) {
return (
<FunctionDecode name={contract?.name || to} method={data.method}>
{data.parameters.map((p) => (
Expand All @@ -36,6 +50,12 @@ export default function Decode({ to, data }: Props) {
<Link href={address(p.value)} target="_blank" rel="noopener noreferrer">
{formatWallet(p.value)}
</Link>
) : isHex(p.value) ? (
<CopyHex value={p.value} />
) : Array.isArray(p.value) && p.value.every((x) => !Array.isArray(x)) ? (
JSON.stringify(p.value)
) : Array.isArray(p.value) ? (
<CopyTuple value={stringifyTuple(p.value)} />
) : (
p.value
)}
Expand All @@ -45,22 +65,6 @@ export default function Decode({ to, data }: Props) {
);
}

const target = data.parameters.find((p) => p.name === 'target');
const payload = data.parameters.find((p) => p.name === 'payload' || p.name === 'data');

if (!target || !payload) {
return null;
}

if (!isHex(payload.value) || !isAddress(target.value)) {
return null;
}

const targetContract = contracts[target.value];
if (!targetContract) {
return null;
}

return (
<FunctionDecode name="TimelockController" method={data.method}>
<Argument name="target">
Expand All @@ -75,6 +79,56 @@ export default function Decode({ to, data }: Props) {
);
}

function stringifyTuple(tuple: unknown) {
return JSON.stringify(tuple, (_, value) => (typeof value === 'bigint' ? String(value) : value), 2);
}

function CopyTuple({ value }: { value: string }) {
const { t } = useTranslation();
return (
<Box display="flex" alignItems="center" gap={0.5}>
<Box>{t('Tuple')}</Box>
<IconButton onClick={() => void navigator.clipboard.writeText(value)} size="small">
<ContentCopyIcon sx={{ fontSize: '11px', color: 'grey.400' }} />
</IconButton>
</Box>
);
}

function CopyHex({ value }: { value: Hex }) {
return (
<Box display="flex" alignItems="center" gap={0.5}>
<Box>{formatHex(value)}</Box>
<IconButton onClick={() => void navigator.clipboard.writeText(value)} size="small">
<ContentCopyIcon sx={{ fontSize: '11px', color: 'grey.400' }} />
</IconButton>
</Box>
);
}

export function DecodeCall({ target, data }: { target: Address; data: Hex }) {
const contracts = useContext(ABIContext);
const { address } = useEtherscanLink();

const contract = contracts[getAddress(target)];
if (!contract) {
return (
<Box display="flex" flexDirection="column">
<Argument name="target">
<Link href={address(target)} target="_blank" rel="noopener noreferrer">
{formatWallet(target)}
</Link>
</Argument>
<Argument name="data">
<CopyHex value={data} />
</Argument>
</Box>
);
}

return <FunctionCall contract={contract.name} abi={contract.abi} data={data} />;
}

function FunctionCall({ contract, abi, data }: { contract: string; abi: Abi; data: Hex }) {
const { address } = useEtherscanLink();
const { functionName, args } = decodeFunctionData({ abi, data });
Expand Down Expand Up @@ -110,19 +164,16 @@ function FunctionCall({ contract, abi, data }: { contract: string; abi: Abi; dat
<ButtonBase
disableRipple
sx={{ fontSize: 14, fontWeight: 500, fontFamily: 'fontFamilyMonospaced' }}
onClick={() =>
download(
input.name ?? 'data',
JSON.stringify(arg, (_, value) => (typeof value === 'bigint' ? String(value) : value), 2),
)
}
onClick={() => download(input.name ?? 'data', stringifyTuple(arg))}
>
{t('Download')}
</ButtonBase>
) : input.type === 'address' ? (
<Link href={address(arg as Address)} target="_blank" rel="noopener noreferrer">
) : input.type === 'address' && isAddress(String(arg)) ? (
<Link href={address(String(arg) as Address)} target="_blank" rel="noopener noreferrer">
{formatWallet(arg as Address)}
</Link>
) : isHex(arg) ? (
<CopyHex value={arg} />
) : (
String(arg)
)}
Expand All @@ -143,7 +194,7 @@ function Bold({ children }: PropsWithChildren) {

function Argument({ name, children }: PropsWithChildren<{ name: string }>) {
return (
<Box display="flex" gap={1}>
<Box display="flex" gap={1} alignItems="center">
<Box fontFamily="fontFamilyMonospaced" color="grey.700" minWidth={64} fontSize={14}>
{name}:
</Box>
Expand Down
173 changes: 162 additions & 11 deletions components/RiskFeed/Events/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,25 @@ import {
import ExpandMoreIcon from '@mui/icons-material/ExpandMoreRounded';
import { useTranslation } from 'react-i18next';

import { type SafeResponse, type Transaction, useTransaction } from '../api';
import Decode from '../Decode';
import { type SafeResponse, type Transaction, useTransaction, type Call } from '../api';
import Decode, { DecodeCall } from '../Decode';

import { formatTx, formatWallet } from 'utils/utils';
import parseTimestamp from 'utils/parseTimestamp';
import useEtherscanLink from 'hooks/useEtherscanLink';
import Link from 'next/link';
import Pill from 'components/common/Pill';
import { getAddress } from 'viem';

type Props = {
title: string;
empty: string;
isLoading: boolean;
data?: SafeResponse;
calls?: Call[];
};

export default function Events({ title, empty, data, isLoading }: Props) {
export default function Events({ title, empty, data, calls, isLoading }: Props) {
return (
<Box>
<Typography
Expand All @@ -41,24 +43,125 @@ export default function Events({ title, empty, data, isLoading }: Props) {
{title}
</Typography>
<Box mt={6}>
{isLoading || data === undefined ? (
{isLoading || data === undefined || calls === undefined ? (
<>
<Skeleton variant="rectangular" height={48} sx={{ borderRadius: 2 }} />
</>
) : data.count === 0 ? (
) : data.count === 0 && calls.length === 0 ? (
<Typography textAlign="center" color="grey.400">
{empty}
</Typography>
) : (
data.results.flatMap((tx) =>
tx.type === 'TRANSACTION' ? [<Event key={tx.transaction.id} tx={tx.transaction} />] : [],
merge(data.results, calls).flatMap((e) =>
e.type === 'transaction'
? [<Event key={e.data.id} tx={e.data} />]
: e.type === 'call'
? [<EventCall key={e.data.id} call={e.data} />]
: [],
)
)}
</Box>
</Box>
);
}

function merge(
safe: SafeResponse['results'],
calls: Call[],
): ({ type: 'transaction'; data: Transaction } | { type: 'call'; data: Call })[] {
const transactions = safe.flatMap((tx) => (tx.type === 'TRANSACTION' ? [tx.transaction] : []));
return [
...transactions.map((tx) => ({ type: 'transaction', data: tx }) as const),
...calls.map((c) => ({ type: 'call', data: c }) as const),
].sort((x, y) => {
if (x.type === 'call' && y.type === 'call') {
if (x.data.executedAt && y.data.executedAt) {
return y.data.executedAt - x.data.executedAt;
}
return y.data.scheduledAt - x.data.scheduledAt;
}
if (x.type === 'call' && y.type === 'transaction') {
if (x.data.executedAt) {
return y.data.timestamp / 1000 - x.data.executedAt;
}
return y.data.timestamp / 1000 - x.data.scheduledAt;
}
if (x.type === 'transaction' && y.type === 'call') {
if (y.data.executedAt) {
return y.data.executedAt - x.data.timestamp / 1000;
}
return y.data.scheduledAt - x.data.timestamp / 1000;
}
if (x.type === 'transaction' && y.type === 'transaction') {
return y.data.timestamp - x.data.timestamp;
}

return 0;
});
}

function EventCall({ call }: { call: Call }) {
const { t } = useTranslation();
return (
<Accordion
disableGutters
sx={{
'&:before': { backgroundColor: 'transparent' },
'&:first-of-type': { borderTopLeftRadius: '8px', borderTopRightRadius: '8px' },
'&:last-of-type': { borderBottomLeftRadius: '8px', borderBottomRightRadius: '8px', borderBottom: 0 },
bgcolor: ({ palette }) => (palette.mode === 'dark' ? 'grey.100' : 'white'),
borderBottom: '1px solid',
borderColor: 'grey.300',
}}
>
<AccordionSummary
sx={{
'&:hover': { backgroundColor: ({ palette }) => (palette.mode === 'dark' ? '#ffffff0b' : '#F0F1F2') },
height: 90,
p: 3,
'& .MuiAccordionSummary-content': { m: 0, mr: 3, justifyContent: 'space-between' },
}}
expandIcon={<ExpandMoreIcon sx={{ fontSize: 16, color: 'grey.900' }} />}
aria-controls={`${call.id}_content`}
id={`${call.id}_header`}
>
<Box>
<Box display="flex" alignItems="center" gap={0.5} color="grey.900">
<Typography component="span" fontSize={14} fontWeight={500} fontFamily="fontFamilyMonospaced">
</Typography>
<Typography component="span" variant="h6">
{t('Event')}:
</Typography>
<Typography
component="span"
variant="h6"
fontWeight={500}
maxWidth={{ xs: 160, sm: 'min-content' }}
sx={{
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
overflowX: 'hidden',
}}
>
{call.executedAt ? t('Call Executed') : t('Call Scheduled')}
</Typography>
</Box>
</Box>
<Box display="flex" flexDirection="column" alignItems="flex-end">
<Typography component="div" variant="h6" textAlign="right" mb={0.2} whiteSpace="nowrap">
{call.operations.length} {call.operations.length === 1 ? t('Action') : t('Actions')}
</Typography>
{call.executedAt !== null && <Pill text={t('Executed')} />}
</Box>
</AccordionSummary>
<AccordionDetails sx={{ p: 0 }}>
<EventSummaryCall call={call} />
</AccordionDetails>
</Accordion>
);
}

type EventProps = {
tx: Transaction;
};
Expand Down Expand Up @@ -116,9 +219,6 @@ function Event({ tx }: EventProps) {
>
<Box>
<Box display="flex" alignItems="center" gap={0.5} color="grey.900">
<Typography component="span" fontSize={14} fontWeight={500} fontFamily="fontFamilyMonospaced">
{String(tx?.executionInfo?.nonce ?? 0).padStart(2, '0')}
</Typography>
<Typography component="span" fontSize={14} fontWeight={500} fontFamily="fontFamilyMonospaced">
</Typography>
Expand Down Expand Up @@ -288,7 +388,7 @@ function EventSummary({ tx }: EventProps) {
</Row>
<Row title={t('Executor')}>
<Value>
<Link href={data.detailedExecutionInfo.executor.value} target="_blank" rel="noopener noreferrer">
<Link href={address(data.detailedExecutionInfo.executor.value)} target="_blank" rel="noopener noreferrer">
{format(data.detailedExecutionInfo.executor.value)}
</Link>
</Value>
Expand All @@ -307,3 +407,54 @@ function EventSummary({ tx }: EventProps) {
</Box>
);
}

function EventSummaryCall({ call }: { call: Call }) {
const { t } = useTranslation();

const { breakpoints } = useTheme();
const isMobile = useMediaQuery(breakpoints.down('sm'));

const { address } = useEtherscanLink();

const format = (value: string) => {
const addr = getAddress(value);
return isMobile ? formatWallet(addr) : addr;
};

return (
<Box display="flex" flexDirection="column">
<Row title={t('ID')}>
<Value>{call.id}</Value>
</Row>
<Row title={t('Scheduler')}>
<Value>
<Link href={address(call.scheduler)} target="_blank" rel="noopener noreferrer">
{format(call.scheduler)}
</Link>
</Value>
</Row>
<Row title={t('Scheduled At')}>
<Value>{parseTimestamp(call.scheduledAt, 'YYYY-MM-DD HH:mm:ss')}</Value>
</Row>
{call.executedAt && call.executor && (
<>
<Row title={t('Executor')}>
<Value>
<Link href={address(call.executor)} target="_blank" rel="noopener noreferrer">
{format(call.executor)}
</Link>
</Value>
</Row>
<Row title={t('Executed At')}>
<Value>{parseTimestamp(call.executedAt, 'YYYY-MM-DD HH:mm:ss')}</Value>
</Row>
</>
)}
{call.operations.map((operation, i) => (
<Row key={operation.index} title={call.operations.length === 1 ? t('Action') : `${t('Action')} #${i + 1}`}>
<DecodeCall target={operation.target} data={operation.data} />
</Row>
))}
</Box>
);
}
Loading