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, {