Skip to content

Commit

Permalink
✨ activity: Add timelock controller events
Browse files Browse the repository at this point in the history
  • Loading branch information
jgalat committed Oct 26, 2023
1 parent 55233b8 commit bbf30b9
Show file tree
Hide file tree
Showing 13 changed files with 276 additions and 33 deletions.
25 changes: 24 additions & 1 deletion components/RiskFeed/Decode/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { type PropsWithChildren, createContext, useContext } from 'react';
import { decodeFunctionData, type Abi, type Address, isHex, isAddress, Hex } from 'viem';
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';
Expand Down Expand Up @@ -106,6 +106,29 @@ function CopyHex({ value }: { value: Hex }) {
);
}

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
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>
);
}
14 changes: 9 additions & 5 deletions components/RiskFeed/Feed/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React from 'react';
import { Address } from 'viem';
import { Box, Divider } from '@mui/material';
import { useTranslation } from 'react-i18next';
import { useHistory, useQueued } from '../api';
import { useHistory, useQueued, useTimelockControllerEvents } from '../api';
import { ABIContext, type Contracts } from '../Decode';
import Events from '../Events';

Expand All @@ -16,21 +16,25 @@ export default React.memo(function Feed({ contracts, multisig }: Props) {
const { data: queued, isLoading: queuedIsLoading } = useQueued(multisig);
const { data: history, isLoading: historyIsLoading } = useHistory(multisig);

const { data: calls, isLoading: callsLoading } = useTimelockControllerEvents();

return (
<ABIContext.Provider value={contracts}>
<Box display="flex" flexDirection="column" gap={6}>
<Events
title={t('Queued Transactions')}
title={t('Scheduled Transactions')}
empty={t('No transactions queued at the moment.')}
data={queued}
isLoading={queuedIsLoading}
calls={calls?.scheduled}
isLoading={queuedIsLoading || callsLoading}
/>
<Divider />
<Events
title={t('Past Transactions')}
title={t('Executed Transactions')}
empty={t('No transactions executed at the moment.')}
data={history}
isLoading={historyIsLoading}
calls={calls?.executed}
isLoading={historyIsLoading || callsLoading}
/>
</Box>
</ABIContext.Provider>
Expand Down
39 changes: 39 additions & 0 deletions components/RiskFeed/api/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { Address, Hex } from 'viem';

import { defaultChain } from 'utils/client';
import useAsyncLoad from 'hooks/useAsyncLoad';
import useGraphClient from 'hooks/useGraphClient';
import { getTimelockControllerCalls } from 'queries/getTimelockControllerCalls';

export type SafeResponse = {
count: number;
Expand Down Expand Up @@ -137,3 +139,40 @@ export function useHistory(addr: Address) {
export function useTransaction(id: TxID) {
return useAsyncLoad(() => transaction(id));
}

export type Call = {
id: Hex;
operations: {
index: number;
target: Address;
data: Hex;
}[];
scheduler: Address;
scheduledAt: number;
executor: Address | null;
executedAt: number | null;
canceller: Address | null;
cancelledAt: number | null;
};

export function useTimelockControllerEvents() {
const request = useGraphClient();
return useAsyncLoad(async () => {
const response = await request<{ timelockControllerCalls: Call[] }>(getTimelockControllerCalls());
if (!response) return;
return response.timelockControllerCalls.reduce(
(state, call) => {
if (call.cancelledAt) {
state.cancelled.push(call);
} else if (call.executedAt) {
state.executed.push(call);
} else {
state.scheduled.push(call);
}

return state;
},
{ scheduled: [] as Call[], executed: [] as Call[], cancelled: [] as Call[] },
);
});
}
18 changes: 12 additions & 6 deletions config/networkData.json
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
{
"1": {
"etherscan": "https://etherscan.io",
"subgraph": "https://gateway.thegraph.com/api/3bd03f49a36caaa5ed4efc5a27c5425d/subgraphs/id/As6Xz6GCvbW8B9Xb7Rx2LqQeJcL3FcUyD8Tk95L8rG5d",
"sablierSubgraph": "https://api.thegraph.com/subgraphs/name/sablier-labs/sablier-v2"
"subgraph": {
"exactly": "https://gateway.thegraph.com/api/3bd03f49a36caaa5ed4efc5a27c5425d/subgraphs/id/As6Xz6GCvbW8B9Xb7Rx2LqQeJcL3FcUyD8Tk95L8rG5d",
"sablier": "https://api.thegraph.com/subgraphs/name/sablier-labs/sablier-v2"
}
},
"5": {
"etherscan": "https://goerli.etherscan.io",
"subgraph": "https://api.thegraph.com/subgraphs/name/exactly/goerli",
"sablierSubgraph": "https://api.thegraph.com/subgraphs/name/sablier-labs/sablier-v2-goerli"
"subgraph": {
"exactly": "https://api.thegraph.com/subgraphs/name/exactly/goerli",
"sablier": "https://api.thegraph.com/subgraphs/name/sablier-labs/sablier-v2-goerli"
}
},
"10": {
"etherscan": "https://optimistic.etherscan.io",
"subgraph": "https://api.thegraph.com/subgraphs/name/exactly/optimism",
"sablierSubgraph": "https://api.thegraph.com/subgraphs/name/sablier-labs/sablier-v2-optimism"
"subgraph": {
"exactly": "https://api.thegraph.com/subgraphs/name/exactly/optimism",
"sablier": "https://api.thegraph.com/subgraphs/name/sablier-labs/sablier-v2-optimism"
}
}
}
2 changes: 1 addition & 1 deletion hooks/useEscrowedEXA.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export function useUpdateStreams() {
setLoading(true);
const data = await request<{ streams: Stream[] }>(
getStreams(EXA.address.toLowerCase(), walletAddress || zeroAddress, esEXA.address.toLowerCase(), false),
true,
'sablier',
);
if (!data) return;
const filteredStreams = data.streams.filter((stream: Stream) => {
Expand Down
Loading

1 comment on commit bbf30b9

@vercel
Copy link

@vercel vercel bot commented on bbf30b9 Oct 26, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

app – ./

app.exact.ly
exactly-development.vercel.app
exactly.app
app-git-main.exactly.app
app.exactly.app

Please sign in to comment.