diff --git a/components/RiskFeed/Decode/index.tsx b/components/RiskFeed/Decode/index.tsx new file mode 100644 index 000000000..c20a9cfbd --- /dev/null +++ b/components/RiskFeed/Decode/index.tsx @@ -0,0 +1,173 @@ +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 { useTranslation } from 'react-i18next'; +import Link from 'next/link'; + +import { SafeTransaction } from '../api'; +import useEtherscanLink from 'hooks/useEtherscanLink'; +import { formatWallet } from 'utils/utils'; + +export type Contracts = Record; + +export const ABIContext = createContext({}); + +type Props = { + to: Address; + data: SafeTransaction['txData']['dataDecoded']; +}; + +export default function Decode({ to, data }: Props) { + const contracts = useContext(ABIContext); + const { address } = useEtherscanLink(); + const { t } = useTranslation(); + + if (data === null) { + return {t('Unable to decode')}; + } + + const contract = contracts[to]; + if (!contract || contract.name !== 'TimelockController') { + return ( + + {data.parameters.map((p) => ( + + {isAddress(p.value) ? ( + + {formatWallet(p.value)} + + ) : ( + p.value + )} + + ))} + + ); + } + + 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 ( + + + + {formatWallet(target.value)} + + + + + + + ); +} + +function FunctionCall({ contract, abi, data }: { contract: string; abi: Abi; data: Hex }) { + const { address } = useEtherscanLink(); + const { functionName, args } = decodeFunctionData({ abi, data }); + const { t } = useTranslation(); + + const download = (name: string, json: string) => { + const element = document.createElement('a'); + const file = new Blob([json], { type: 'application/json' }); + element.href = URL.createObjectURL(file); + element.download = `${name}.json`; + document.body.appendChild(element); + element.click(); + document.body.removeChild(element); + }; + + const abiFn = abi.find((entry) => entry.type === 'function' && entry.name === functionName); + if (!abiFn || abiFn.type !== 'function' || !args) { + return null; + } + + const inputs = abiFn.inputs || []; + if (inputs.length !== args.length) { + return null; + } + + return ( + + {inputs.map((input, index) => { + const arg = args[index]; + return ( + + {input.type.startsWith('tuple') ? ( + + download( + input.name ?? 'data', + JSON.stringify(arg, (_, value) => (typeof value === 'bigint' ? String(value) : value), 2), + ) + } + > + {t('Download')} + + ) : input.type === 'address' ? ( + + {formatWallet(arg as Address)} + + ) : ( + String(arg) + )} + + ); + })} + + ); +} + +function Bold({ children }: PropsWithChildren) { + return ( + + {children} + + ); +} + +function Argument({ name, children }: PropsWithChildren<{ name: string }>) { + return ( + + + {name}: + + + {children} + + + ); +} + +function FunctionDecode({ name, method, children }: PropsWithChildren<{ name?: string; method: string }>) { + const args = React.Children.count(children); + return ( + + + {name ? `${name}.` : ''} + {method}({args === 0 ? ')' : ''} + + {args > 0 && ( + <> + {children} + ) + + )} + + ); +} diff --git a/components/RiskFeed/Events/index.tsx b/components/RiskFeed/Events/index.tsx new file mode 100644 index 000000000..4f883933f --- /dev/null +++ b/components/RiskFeed/Events/index.tsx @@ -0,0 +1,282 @@ +import React, { PropsWithChildren, useState } from 'react'; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Box, + capitalize, + Skeleton, + Typography, + useMediaQuery, + useTheme, +} from '@mui/material'; +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 { 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'; + +type Props = { + title: string; + empty: string; + isLoading: boolean; + data?: SafeResponse; +}; + +export default function Events({ title, empty, data, isLoading }: Props) { + return ( + + (palette.mode === 'dark' ? 'white' : 'black')} + > + {title} + + + {isLoading || data === undefined ? ( + <> + + + ) : data.count === 0 ? ( + + {empty} + + ) : ( + data.results.flatMap((tx) => + tx.type === 'TRANSACTION' ? [] : [], + ) + )} + + + ); +} + +type EventProps = { + tx: Transaction; +}; + +function Event({ tx }: EventProps) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const actions = tx.txInfo.actionCount ?? 1; + + const method = (methodName: string | null) => { + if (methodName === 'multiSend') { + return t('Multiple transactions'); + } + + if (methodName === 'execute') { + return t('Execution'); + } + + if (methodName === 'schedule') { + return t('Schedule'); + } + + return t('Unknown'); + }; + + return ( + (palette.mode === 'dark' ? 'grey.100' : 'white'), + borderBottom: '1px solid', + borderColor: 'grey.300', + }} + onChange={() => setOpen(true)} + > + (palette.mode === 'dark' ? '#ffffff0b' : '#F0F1F2') }, + height: 90, + p: 3, + '& .MuiAccordionSummary-content': { m: 0, mr: 3, justifyContent: 'space-between' }, + }} + expandIcon={} + aria-controls={`${tx.id}_content`} + id={`${tx.id}_header`} + > + + + + {String(tx.executionInfo.nonce).padStart(2, '0')} + + + • + + + {tx.txInfo.type === 'SettingsChange' ? 'Safe' : 'Protocol'}: + + + {tx.txInfo.type === 'SettingsChange' && tx.txInfo.humanDescription + ? tx.txInfo.humanDescription + : capitalize(method(tx.txInfo.methodName))} + + + + + {t('Approved by')}: + + + {tx.executionInfo.confirmationsSubmitted}/{tx.executionInfo.confirmationsRequired} + + + + + + {actions} {actions === 1 ? t('Action') : t('Actions')} + + {tx.txStatus === 'SUCCESS' && } + + + {open && } + + ); +} + +type TitleProps = PropsWithChildren<{ + title: React.ReactNode; +}>; + +function Row({ title, children }: TitleProps) { + return ( + + + {title} + + {children} + + ); +} + +function Value({ children }: PropsWithChildren) { + return ( + + {children} + + ); +} + +function Skel() { + return ( + + {Array.from({ length: 3 }).map((_, i) => ( + + + + + ))} + + ); +} + +function EventSummary({ tx }: EventProps) { + const { t } = useTranslation(); + const { data, isLoading } = useTransaction(tx.id); + + const { breakpoints } = useTheme(); + const isMobile = useMediaQuery(breakpoints.down('sm')); + + const { tx: transaction, address } = useEtherscanLink(); + + if (isLoading || !data) { + return ; + } + + const isMultiSend = + tx.txInfo.actionCount !== null && + data.txData.dataDecoded && + data.txData.dataDecoded.method === 'multiSend' && + data.txData.dataDecoded.parameters.length === 1 && + !!data.txData.dataDecoded.parameters[0].valueDecoded; + + const format = (value: string) => { + return isMobile ? formatWallet(value) : value; + }; + + return ( + + + + + {formatTx(data.txHash)} + + + + + {parseTimestamp(data.detailedExecutionInfo.submittedAt / 1000, 'YYYY-MM-DD HH:mm:ss')} + + {data.executedAt && ( + + {parseTimestamp(data.executedAt / 1000, 'YYYY-MM-DD HH:mm:ss')} + + )} + + + {data.detailedExecutionInfo.confirmations.map((confirmation) => ( + + + {format(confirmation.signer.value)} + + + ))} + + + + + + {format(data.detailedExecutionInfo.executor.value)} + + + + {isMultiSend ? ( + data.txData.dataDecoded?.parameters?.[0]?.valueDecoded?.map((v, i) => ( + + + + )) + ) : ( + + + + )} + + ); +} diff --git a/components/RiskFeed/Feed/index.tsx b/components/RiskFeed/Feed/index.tsx new file mode 100644 index 000000000..17c8179af --- /dev/null +++ b/components/RiskFeed/Feed/index.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { getAddress } from 'viem'; +import { Box, Divider } from '@mui/material'; +import { useTranslation } from 'react-i18next'; + +import { useHistory, useQueued } from '../api'; +import { ABIContext, type Contracts } from '../Decode'; +import Events from '../Events'; + +const MULTISIG = getAddress('0xC0d6Bc5d052d1e74523AD79dD5A954276c9286D3'); + +type Props = { + contracts: Contracts; +}; + +export default React.memo(function Feed({ contracts }: Props) { + const { t } = useTranslation(); + const { data: queued, isLoading: queuedIsLoading } = useQueued(MULTISIG); + const { data: history, isLoading: historyIsLoading } = useHistory(MULTISIG); + + return ( + + + + + + + + ); +}); diff --git a/components/RiskFeed/api/index.tsx b/components/RiskFeed/api/index.tsx new file mode 100644 index 000000000..094206108 --- /dev/null +++ b/components/RiskFeed/api/index.tsx @@ -0,0 +1,139 @@ +import { Address, Hex } from 'viem'; + +import { defaultChain } from 'utils/client'; +import useAsyncLoad from 'hooks/useAsyncLoad'; + +export type SafeResponse = { + count: number; + next: number | null; + previous: number | null; + results: Result[]; +}; + +type Result = + | { type: 'LABEL' | 'DATE_LABEL' } + | { + type: 'TRANSACTION'; + transaction: Transaction; + }; + +type TxID = `multisig_${Address}_${Hex}`; + +export type Transaction = { + id: TxID; + timestamp: number; + txStatus: 'SUCCESS' | 'AWAITING_EXECUTION'; + txInfo: TxInfo; + executionInfo: ExecutionInfo; +}; + +type TxInfo = { + type: string; + humanDescription: null | string; + to: Wallet; + dataSize: string; + value: string; + methodName: null | 'multiSend' | 'execute' | 'schedule'; + actionCount: null | number; + isCancellation: boolean; +}; + +type ExecutionInfo = { + type: string; + nonce: number; + confirmationsRequired: number; + confirmationsSubmitted: number; +}; + +export type SafeTransaction = { + safeAddress: string; + txId: TxID; + executedAt: null | number; + txStatus: 'SUCCESS' | 'AWAITING_EXECUTION'; + txInfo: TxInfo; + txData: TxData; + txHash: Hex; + detailedExecutionInfo: DetailedExecutionInfo; +}; + +type DetailedExecutionInfo = { + submittedAt: number; + refundReceiver: Wallet; + safeTxHash: Hex; + executor: Wallet; + signers: Wallet[]; + confirmationsRequired: number; + confirmations: Confirmation[]; + trusted: boolean; +}; + +type Confirmation = { + signer: Wallet; + signature: Hex; + submittedAt: number; +}; + +type Wallet = { + value: Address; + name: null | string; + logoUri: null | string; +}; + +type TxData = { + hexData: Hex; + dataDecoded: DataDecoded | null; + to: Wallet; + value: string; + operation: number; + addressInfoIndex: Record; +}; + +type DataDecoded = { + method: string; + parameters: Parameter[]; +}; + +type Parameter = { + name: string; + type: string; + value: string; + valueDecoded?: ValueDecoded[]; +}; + +type ValueDecoded = { + operation: number; + to: Address; + value: string; + data: string; + dataDecoded: DataDecoded; +}; + +const base = `https://safe-client.safe.global/v1/chains/${defaultChain.id}`; + +function safeUrl(addr: Address): string { + return `${base}/safes/${addr}`; +} + +async function queued(addr: Address): Promise { + return await fetch(`${safeUrl(addr)}/transactions/queued`).then((res) => res.json()); +} + +async function history(addr: Address): Promise { + return await fetch(`${safeUrl(addr)}/transactions/history`).then((res) => res.json()); +} + +async function transaction(id: TxID): Promise { + return await fetch(`${base}/transactions/${id}`).then((res) => res.json()); +} + +export function useQueued(addr: Address) { + return useAsyncLoad(() => queued(addr)); +} + +export function useHistory(addr: Address) { + return useAsyncLoad(() => history(addr)); +} + +export function useTransaction(id: TxID) { + return useAsyncLoad(() => transaction(id)); +} diff --git a/components/common/Pill.tsx b/components/common/Pill.tsx new file mode 100644 index 000000000..a7b390ffe --- /dev/null +++ b/components/common/Pill.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Box, Typography } from '@mui/material'; + +type Props = { text: string }; + +const Pill = React.forwardRef(function Pill({ text }: Props, ref) { + return ( + + + {text} + + + ); +}); + +export default Pill; diff --git a/hooks/useAsyncLoad.ts b/hooks/useAsyncLoad.ts new file mode 100644 index 000000000..51eff67f8 --- /dev/null +++ b/hooks/useAsyncLoad.ts @@ -0,0 +1,31 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +export default function useAsyncLoad(fn: () => Promise) { + const funcRef = useRef(fn); + const [data, setData] = useState(); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + funcRef.current = fn; + }, [fn]); + + const refetch = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + setData(await funcRef.current()); + } catch (e) { + if (e instanceof Error) setError(e); + setData(undefined); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + refetch(); + }, [refetch]); + + return { data, error, refetch, isLoading } as const; +} diff --git a/i18n/es/translation.json b/i18n/es/translation.json index dce410729..ff394380a 100644 --- a/i18n/es/translation.json +++ b/i18n/es/translation.json @@ -132,8 +132,6 @@ "fixed": "fija", "variable": "variable", "Approving {{symbol}}": "Aprobando {{symbol}}", - "Transaction rejected": "Transacción rechazada", - "Approve failed, please try again": "Falla en la aprobación, por favor reintentar", "Insufficient balance": "Balance insuficiente", "Go Back": "Retroceder", "Back": "Atrás", @@ -379,5 +377,26 @@ "Provide liquidity": "Proporcionar liquidez", "Net APR": "TNA Neta", "Your Account": "Tu Cuenta", - "Temporary disabled": "Temporalmente deshabilitado" + "Temporary disabled": "Temporalmente deshabilitado", + "Unable to decode": "No se puede decodificar", + "Download": "Descargar", + "Multiple transactions": "Múltiples transacciones", + "Execution": "Ejecución", + "Schedule": "Programar", + "Unknown": "Desconocido", + "Approved by": "Aprobado por", + "Action": "Acción", + "Actions": "Acciones", + "Executed": "Ejecutado", + "Transaction Hash": "Hash de la Transacción", + "Submited At": "Enviado", + "Executed At": "Ejecutado", + "Signers": "Signers", + "Executor": "Ejecutor", + "Queued Transactions": "Transacciones en cola", + "No transactions queued at the moment.": "No hay transacciones en cola en este momento.", + "Past Transactions": "Transacciones pasadas", + "No transactions executed at the moment.": "No hay transacciones ejecutadas en este momento.", + "Protocol Activity Monitor": "Monitor de Actividad del Protocolo", + "We're dedicated to safeguarding your funds, and our Protocol Activity Monitor is key to achieving this goal. It provides real-time insights into the transactions and activities shaping our Protocol, empowering you to stay informed and enhancing your confidence and trust in our platform.": "Estamos dedicados a proteger sus fondos, y nuestro Monitor de Actividad del Protocolo es clave para alcanzar este objetivo. Proporciona una visión en tiempo real de las transacciones y actividades que dan forma a nuestro Protocolo, permitiéndole mantenerse informado y potenciando su confianza en nuestra plataforma." } diff --git a/pages/activity.tsx b/pages/activity.tsx new file mode 100644 index 000000000..a29c4e428 --- /dev/null +++ b/pages/activity.tsx @@ -0,0 +1,80 @@ +import React, { memo } from 'react'; +import { Abi, getAddress } from 'viem'; +import type { GetStaticProps } from 'next'; +import { Box, Typography } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { readdir, readFile } from 'fs/promises'; + +import Feed from 'components/RiskFeed/Feed'; +import { usePageView } from 'hooks/useAnalytics'; +import { goerli, mainnet, optimism } from 'wagmi/chains'; +import { Contracts } from 'components/RiskFeed/Decode'; +import { basename } from 'path'; + +type Props = { + contracts: Contracts; +}; + +const Activity = ({ contracts }: Props) => { + usePageView('/activity', 'Activity'); + const { t } = useTranslation(); + + return ( + + + (palette.mode === 'dark' ? 'white' : 'black')} + > + {t('Protocol Activity Monitor')} + + + {t( + "We're dedicated to safeguarding your funds, and our Protocol Activity Monitor is key to achieving this goal. It provides real-time insights into the transactions and activities shaping our Protocol, empowering you to stay informed and enhancing your confidence and trust in our platform.", + )} + + + + + + + ); +}; + +const ignore = ['.chainId', 'PriceFeed', 'Balancer', 'Uniswap', 'Socket']; + +const networks = { + [mainnet.id]: 'mainnet', + [optimism.id]: optimism.network, + [goerli.id]: goerli.network, +}; + +export const getStaticProps: GetStaticProps = async () => { + const deployments = 'node_modules/@exactly/protocol/deployments'; + const id = Number(process.env.NEXT_PUBLIC_NETWORK); + const network = networks[id as keyof typeof networks]; + + if (!network) { + throw new Error(`unknown network id: ${id}`); + } + + const files = (await readdir(`${deployments}/${network}`)).filter( + (name) => !name.includes('_') && !ignore.some((p) => name.startsWith(p)), + ); + + const parsed = await Promise.all( + files.map(async (file) => { + const buf = await readFile(`${deployments}/${network}/${file}`); + const json = JSON.parse(buf.toString('utf-8')); + return { [getAddress(json.address)]: { abi: json.abi as Abi, name: basename(file, '.json') } }; + }), + ); + + const contracts = parsed.reduce((acc, deploy) => ({ ...acc, ...deploy }), {}); + + return { props: { contracts } }; +}; + +export default memo(Activity); diff --git a/utils/utils.tsx b/utils/utils.tsx index 884b435f6..cabd42263 100644 --- a/utils/utils.tsx +++ b/utils/utils.tsx @@ -1,8 +1,14 @@ +import { Hex } from 'viem'; + export function formatWallet(walletAddress?: string) { if (!walletAddress) return ''; return `${walletAddress.substring(0, 6)}...${walletAddress.substring(38)}`; } +export function formatTx(hash: Hex): string { + return `${hash.substring(0, 6)}...${hash.substring(48)}`; +} + export const toPercentage = (value?: number, fractionDigits = 2): string => { if (value != null) { return value.toLocaleString(undefined, {