diff --git a/apps/router/src/guardian-ui/components/dashboard/tabs/FederationTabsCard.tsx b/apps/router/src/guardian-ui/components/dashboard/tabs/FederationTabsCard.tsx index a7bf40c9..6bded2a0 100644 --- a/apps/router/src/guardian-ui/components/dashboard/tabs/FederationTabsCard.tsx +++ b/apps/router/src/guardian-ui/components/dashboard/tabs/FederationTabsCard.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { Card, CardBody, @@ -9,21 +9,38 @@ import { TabPanels, Tab, TabPanel, + Badge, } from '@chakra-ui/react'; import { githubLight } from '@uiw/codemirror-theme-github'; import { json } from '@codemirror/lang-json'; import CodeMirror from '@uiw/react-codemirror'; import { ClientConfig, + ConsensusMeta, MetaFields, + MetaSubmissions, ModuleKind, + ParsedConsensusMeta, SignedApiAnnouncement, } from '@fedimint/types'; -import { useTranslation } from '@fedimint/utils'; -import { MetaManager } from './meta/MetaManager'; -import { ConsensusMetaFields } from './meta/ViewConsensusMeta'; +import { useTranslation, hexToMeta } from '@fedimint/utils'; +import { MetaManager } from './meta/manager/MetaManager'; import { ApiAnnouncements } from './ApiAnnouncements'; +import { ProposedMetas } from './meta/proposals/ProposedMetas'; +import { ModuleRpc } from '../../../types'; +import { useGuardianAdminApi } from '../../../../context/hooks'; + +export const DEFAULT_META_KEY = 0; +export const POLL_TIMEOUT_MS = 2000; + +type MetaSubmissionMap = { + [key: string]: { + peers: number[]; + meta: MetaFields; + }; +}; + interface FederationTabsCardProps { config: ClientConfig | undefined; ourPeer: { id: number; name: string }; @@ -39,9 +56,99 @@ export const FederationTabsCard: React.FC = ({ const [metaModuleId, setMetaModuleId] = useState( undefined ); - const [consensusMeta, setConsensusMeta] = useState(); - const [editedMetaFields, setEditedMetaFields] = useState([]); + const [consensusMeta, setConsensusMeta] = useState(); const [peers, setPeers] = useState<{ id: number; name: string }[]>([]); + const [metaSubmissions, setMetaSubmissions] = useState({}); + const pendingProposalsCount = Object.keys(metaSubmissions).length; + const [hasVoted, setHasVoted] = useState(false); + const [activeTab, setActiveTab] = useState(0); + const api = useGuardianAdminApi(); + + const pollMetaSubmissions = useCallback(async () => { + if (!metaModuleId) return; + + try { + const submissions = await api.moduleApiCall( + Number(metaModuleId), + ModuleRpc.getSubmissions, + DEFAULT_META_KEY + ); + + const metas: MetaSubmissionMap = {}; + let voted = false; + + Object.entries(submissions).forEach(([peer, hexString]) => { + if (hexString === '7b7d') return; // Filter out empty submissions + const metaObject = hexToMeta(hexString); + const meta = Object.entries(metaObject).filter( + ([, value]) => value !== undefined && value !== '' + ) as [string, string][]; + + const metaKey = JSON.stringify(meta); // Use JSON string as a key to group identical metas + + if (metas[metaKey]) { + metas[metaKey].peers.push(Number(peer)); + } else { + metas[metaKey] = { + peers: [Number(peer)], + meta: meta as MetaFields, + }; + } + + if (Number(peer) === ourPeer.id) { + voted = true; + } + }); + + setMetaSubmissions(metas); + setHasVoted(voted); + } catch (err) { + console.warn('Failed to poll for meta submissions', err); + } + }, [api, metaModuleId, ourPeer.id]); + + useEffect(() => { + const pollSubmissionInterval = setInterval( + pollMetaSubmissions, + POLL_TIMEOUT_MS + ); + return () => { + clearInterval(pollSubmissionInterval); + }; + }, [pollMetaSubmissions]); + + useEffect(() => { + const pollConsensusMeta = setInterval(async () => { + try { + const meta = await api.moduleApiCall( + Number(metaModuleId), + ModuleRpc.getConsensus, + DEFAULT_META_KEY + ); + if (!meta) return; + const parsedConsensusMeta: ParsedConsensusMeta = { + revision: meta.revision, + value: Object.entries(hexToMeta(meta.value)).filter( + ([, value]) => value !== undefined && value !== '' + ) as [string, string][], + }; + // Compare the new meta with the current state + setConsensusMeta((currentMeta) => { + if ( + JSON.stringify(currentMeta) !== JSON.stringify(parsedConsensusMeta) + ) { + return parsedConsensusMeta; + } + return currentMeta; + }); + } catch (err) { + console.warn('Failed to poll for consensus meta', err); + } + }, POLL_TIMEOUT_MS); + return () => { + clearInterval(pollConsensusMeta); + }; + }, [api, metaModuleId]); useEffect(() => { if (config) { @@ -66,14 +173,46 @@ export const FederationTabsCard: React.FC = ({ return config ? ( - + - {t('federation-dashboard.config.manage-meta.label')} - {t('federation-dashboard.config.view-config')} - {t('federation-dashboard.api-announcements.label')} + + {t('federation-dashboard.config.view-meta')} + + + {t('federation-dashboard.config.view-config')} + + + {t('federation-dashboard.api-announcements.label')} + + {pendingProposalsCount > 0 && ( + + + {t( + 'federation-dashboard.config.manage-meta.proposed-meta-label' + )} + + {pendingProposalsCount} + + + + )} @@ -84,11 +223,7 @@ export const FederationTabsCard: React.FC = ({ @@ -108,6 +243,16 @@ export const FederationTabsCard: React.FC = ({ config={config} /> + + + diff --git a/apps/router/src/guardian-ui/components/dashboard/tabs/meta/MetaManager.tsx b/apps/router/src/guardian-ui/components/dashboard/tabs/meta/MetaManager.tsx deleted file mode 100644 index ac7c9b40..00000000 --- a/apps/router/src/guardian-ui/components/dashboard/tabs/meta/MetaManager.tsx +++ /dev/null @@ -1,202 +0,0 @@ -import React, { useCallback } from 'react'; -import { - Flex, - Text, - Button, - ModalHeader, - ModalCloseButton, - ModalBody, - ModalContent, - ModalOverlay, - Modal, - useDisclosure, - ModalFooter, - useTheme, - Divider, -} from '@chakra-ui/react'; -import { fieldsToMeta, metaToHex, useTranslation } from '@fedimint/utils'; -import { MetaFields } from '@fedimint/types'; -import { ViewConsensusMeta, ConsensusMetaFields } from './ViewConsensusMeta'; -import { ProposedMetas } from './ProposedMetas'; -import { EditMetaField } from './EditMetaField'; -import { ModuleRpc } from '../../../../types'; -import { useGuardianAdminApi } from '../../../../../context/hooks'; - -export const DEFAULT_META_KEY = 0; -const POLL_TIMEOUT_MS = 2000; - -const metaArrayToObject = ( - metaArray: [string, string][] -): Record => { - return metaArray.reduce((acc, [key, value]) => { - acc[key] = value; - return acc; - }, {} as Record); -}; - -interface MetaManagerProps { - metaModuleId?: string; - consensusMeta?: ConsensusMetaFields; - setConsensusMeta: (meta: ConsensusMetaFields) => void; - editedMetaFields: MetaFields; - setEditedMetaFields: (fields: MetaFields) => void; - ourPeer: { id: number; name: string }; - peers: { id: number; name: string }[]; -} - -export const MetaManager = React.memo(function MetaManager({ - metaModuleId, - ourPeer, - peers, - consensusMeta, - setConsensusMeta, - editedMetaFields, - setEditedMetaFields, -}: MetaManagerProps): JSX.Element { - const { t } = useTranslation(); - const api = useGuardianAdminApi(); - const { isOpen, onOpen: originalOnOpen, onClose } = useDisclosure(); - const theme = useTheme(); - - const onOpen = useCallback(() => { - if (consensusMeta) { - setEditedMetaFields([...consensusMeta.value]); // Ensure a new array reference - } - originalOnOpen(); - }, [consensusMeta, originalOnOpen, setEditedMetaFields]); - - const proposeMetaEdits = useCallback(() => { - if (metaModuleId === undefined) { - return; - } - - api - .moduleApiCall<{ metaValue: string }[]>( - Number(metaModuleId), - ModuleRpc.submitMeta, - { - key: DEFAULT_META_KEY, - value: metaToHex(fieldsToMeta(editedMetaFields)), - } - ) - .then(() => { - setEditedMetaFields([]); - onClose(); - }) - .catch((error) => { - console.error(error); - alert('Failed to propose meta edits. Please try again.'); - }); - }, [api, metaModuleId, editedMetaFields, onClose, setEditedMetaFields]); - - return metaModuleId ? ( - - - setConsensusMeta(meta) - } - pollTimeout={POLL_TIMEOUT_MS} - /> - - { - setEditedMetaFields([...fields]); - }} - pollTimeout={POLL_TIMEOUT_MS} - onOpen={onOpen} - /> - - - - - - {t('federation-dashboard.config.manage-meta.edit-meta-label')} - - - - - {t('federation-dashboard.config.manage-meta.setup-meta-title')} - - - {t( - 'federation-dashboard.config.manage-meta.setup-meta-description' - )} - - - {t('federation-dashboard.config.manage-meta.propose-updates')} - - - {t('federation-dashboard.config.manage-meta.core-meta-fields')} - - - - - federation_expiry_timestamp:{' '} - {t('federation-dashboard.config.manage-meta.meta-field-expiry')} - - - - federation_name:{' '} - {t('federation-dashboard.config.manage-meta.meta-field-name')} - - - - federation_icon_url:{' '} - {t('federation-dashboard.config.manage-meta.meta-field-icon')} - - - - welcome_message:{' '} - {t( - 'federation-dashboard.config.manage-meta.meta-field-welcome' - )} - - - - vetted_gateways:{' '} - {t( - 'federation-dashboard.config.manage-meta.meta-field-gateways' - )} - - - - {t('federation-dashboard.config.manage-meta.your-own-fields')} - - - - - - - - - - - - ) : ( - {t('federation-dashboard.config.missing-meta-module')} - ); -}); diff --git a/apps/router/src/guardian-ui/components/dashboard/tabs/meta/ProposedMetas.tsx b/apps/router/src/guardian-ui/components/dashboard/tabs/meta/ProposedMetas.tsx deleted file mode 100644 index c4778481..00000000 --- a/apps/router/src/guardian-ui/components/dashboard/tabs/meta/ProposedMetas.tsx +++ /dev/null @@ -1,356 +0,0 @@ -import React, { useState, useEffect, useCallback } from 'react'; -import { - Flex, - Text, - Button, - Card, - CardHeader, - CardBody, - CardFooter, - Icon, - useDisclosure, - Modal, - ModalOverlay, - ModalContent, - ModalHeader, - ModalBody, - ModalFooter, -} from '@chakra-ui/react'; -import { ReactComponent as CheckIcon } from '../../../../assets/svgs/check.svg'; -import { - useTranslation, - hexToMeta, - metaToHex, - fieldsToMeta, -} from '@fedimint/utils'; -import { MetaFields, MetaSubmissions } from '@fedimint/types'; - -import { ModuleRpc } from '../../../../types'; -import { Table, TableColumn } from '@fedimint/ui'; -import { DEFAULT_META_KEY } from './MetaManager'; -import { bftHonest, generateSimpleHash } from '../../../../utils'; -import { useGuardianAdminApi } from '../../../../../context/hooks'; - -type MetaSubmissionMap = { - [key: string]: { - peers: number[]; - meta: MetaFields; - }; -}; - -interface ProposedMetasProps { - ourPeer: { id: number; name: string }; - peers: { id: number; name: string }[]; - metaModuleId: string; - updateEditedMetaFields: (fields: MetaFields) => void; - pollTimeout: number; - onOpen: () => void; - consensusMeta: Record; -} - -type TableKey = 'metaKey' | 'value' | 'effect'; - -export const ProposedMetas = React.memo(function ProposedMetas({ - ourPeer, - peers, - metaModuleId, - pollTimeout, - onOpen, - consensusMeta, -}: ProposedMetasProps): JSX.Element { - const { t } = useTranslation(); - const api = useGuardianAdminApi(); - const { isOpen, onOpen: openModal, onClose } = useDisclosure(); - const [metaSubmissions, setMetaSubmissions] = useState(); - const [hasVoted, setHasVoted] = useState(false); - const [selectedMeta, setSelectedMeta] = useState(null); - - const totalGuardians = peers.length; - const threshold = bftHonest(totalGuardians); - - useEffect(() => { - const pollSubmissionInterval = setInterval(async () => { - try { - const submissions = await api.moduleApiCall( - Number(metaModuleId), - ModuleRpc.getSubmissions, - 0 - ); - - const metas: MetaSubmissionMap = {}; - let voted = false; - - Object.entries(submissions).forEach(([peer, hexString]) => { - if (hexString === '7b7d') return; // Filter out empty submissions - const metaObject = hexToMeta(hexString); - const meta = Object.entries(metaObject).filter( - ([, value]) => value !== undefined && value !== '' - ) as [string, string][]; - - const metaKey = JSON.stringify(meta); // Use JSON string as a key to group identical metas - - if (metas[metaKey]) { - metas[metaKey].peers.push(Number(peer)); - } else { - metas[metaKey] = { - peers: [Number(peer)], - meta: meta as MetaFields, - }; - } - - if (Number(peer) === ourPeer.id) { - voted = true; - } - }); - - setMetaSubmissions(metas); - setHasVoted(voted); - } catch (err) { - console.warn('Failed to poll for meta submissions', err); - } - }, pollTimeout); - return () => { - clearInterval(pollSubmissionInterval); - }; - }, [api, metaModuleId, pollTimeout, ourPeer.id]); - - const handleClear = useCallback(async () => { - try { - await api.moduleApiCall<{ metaValue: string }[]>( - Number(metaModuleId), - ModuleRpc.submitMeta, - { - key: DEFAULT_META_KEY, - value: metaToHex(fieldsToMeta([])), // Empty submission - } - ); - } catch (err) { - console.error('Failed to clear meta edits', err); - } - }, [api, metaModuleId]); - - const handleApprove = useCallback( - async (meta: MetaFields) => { - try { - // Submit the meta fields for the guardian - await api.moduleApiCall<{ metaValue: string }[]>( - Number(metaModuleId), - ModuleRpc.submitMeta, - { - key: DEFAULT_META_KEY, - value: metaToHex(fieldsToMeta(meta)), - } - ); - } catch (err) { - console.error('Failed to submit meta edits', err); - } - }, - [api, metaModuleId] - ); - - const columns: TableColumn[] = [ - { - key: 'metaKey', - heading: t('set-config.meta-fields-key'), - }, - { - key: 'value', - heading: t('set-config.meta-fields-value'), - }, - { - key: 'effect', - heading: t('set-config.meta-fields-effect'), - }, - ]; - - const getEffect = (key: string, value: string): JSX.Element => { - console.log('consensusMeta', consensusMeta); - if (consensusMeta[key] === undefined) { - return ( - - {t('federation-dashboard.config.manage-meta.meta-effect-add')} - - ); - } - if (String(consensusMeta[key]) !== value) { - return ( - - {t('federation-dashboard.config.manage-meta.meta-effect-modify')} - - ); - } else { - return ( - - {t('federation-dashboard.config.manage-meta.meta-effect-unchanged')} - - ); - } - }; - - const handleApproveWithWarning = ( - meta: MetaFields, - currentApprovals: number - ) => { - if (currentApprovals + 1 >= threshold) { - setSelectedMeta(meta); - openModal(); - } else { - handleApprove(meta); - } - }; - - const confirmApproval = async () => { - if (selectedMeta) { - await handleApprove(selectedMeta); - onClose(); - } - }; - - return ( - - {metaSubmissions && Object.keys(metaSubmissions).length > 0 ? ( - - - {t('federation-dashboard.config.manage-meta.proposals')} - - - {t('common.threshold')}: {threshold} / {totalGuardians} - - - ) : null} - {metaSubmissions && - Object.entries(metaSubmissions).map(([key, submission]) => { - const submissionKeys = new Set(submission.meta.map(([key]) => key)); - const rows = [ - ...submission.meta.map(([key, value]) => ({ - key: `${key}-${value}`, - metaKey: {key}, - value: {value}, - effect: getEffect(key, value), - })), - ...Object.entries(consensusMeta) - .filter(([key]) => !submissionKeys.has(key)) - .map(([key, value]) => ({ - key: `${key}-${value}`, - metaKey: {key}, - value: {value}, - effect: {t('common.remove')}, - })), - ]; - - const totalGuardians = peers.length; - const currentApprovals = submission.peers.length; - - return ( - - - - - {`Proposal ID: ${generateSimpleHash(key)}`} - - - - - - - - - - {t('common.approvals')}: ( {currentApprovals} / - {totalGuardians} ) - - {submission.peers.map((peerId) => ( - - - - {ourPeer.id === Number(peerId) - ? t('common.you') - : peers.find((p) => p.id === Number(peerId))?.name} - - - ))} - - {submission.peers.includes(ourPeer.id) ? ( - - ) : ( - - )} - - - ); - })} - {hasVoted ? null : ( - - )} - - - - - - {t('federation-dashboard.config.manage-meta.confirm-modal.title')} - - - - {t( - 'federation-dashboard.config.manage-meta.confirm-modal.description' - )} - - {selectedMeta && ( -
({ - key: `${key}-${value}`, - metaKey: {key}, - value: {value}, - effect: getEffect(key, value), - }))} - /> - )} - - - - - - - - - ); -}); diff --git a/apps/router/src/guardian-ui/components/dashboard/tabs/meta/ViewConsensusMeta.tsx b/apps/router/src/guardian-ui/components/dashboard/tabs/meta/ViewConsensusMeta.tsx deleted file mode 100644 index b0cebe61..00000000 --- a/apps/router/src/guardian-ui/components/dashboard/tabs/meta/ViewConsensusMeta.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import React, { useEffect, useMemo } from 'react'; -import { Flex, Text, useTheme } from '@chakra-ui/react'; -import { useTranslation, hexToMeta, metaToFields } from '@fedimint/utils'; -import { ConsensusMeta, MetaFields } from '@fedimint/types'; -import { ModuleRpc } from '../../../../types'; -import { Table, TableColumn, TableRow } from '@fedimint/ui'; -import { useGuardianAdminApi } from '../../../../../context/hooks'; - -interface ViewConsensusMetaProps { - metaKey: number; - metaModuleId: string; - consensusMeta?: ConsensusMetaFields; - pollTimeout: number; - updateConsensusMeta: (meta: ConsensusMetaFields) => void; -} - -export interface ConsensusMetaFields { - revision: number; - value: MetaFields; -} - -type TableKey = 'metaKey' | 'value'; - -export const ViewConsensusMeta = React.memo(function ConsensusMetaFields({ - consensusMeta, - metaModuleId, - metaKey, - pollTimeout, - updateConsensusMeta, -}: ViewConsensusMetaProps): JSX.Element { - const { t } = useTranslation(); - const api = useGuardianAdminApi(); - const theme = useTheme(); - - useEffect(() => { - const pollConsensusMeta = setInterval(async () => { - try { - const meta = await api.moduleApiCall( - Number(metaModuleId), - ModuleRpc.getConsensus, - metaKey - ); - if (!meta) return; - const consensusMeta: ConsensusMetaFields = { - revision: meta.revision, - value: metaToFields(hexToMeta(meta.value)), - }; - updateConsensusMeta(consensusMeta); - } catch (err) { - console.warn('Failed to poll for consensus meta', err); - } - }, pollTimeout); - return () => { - clearInterval(pollConsensusMeta); - }; - }, [api, metaModuleId, metaKey, pollTimeout, updateConsensusMeta]); - - const columns: TableColumn[] = useMemo( - () => [ - { - key: 'metaKey', - heading: t('set-config.meta-fields-key'), - }, - { - key: 'value', - heading: t('set-config.meta-fields-value'), - }, - ], - [t] - ); - - const rows: TableRow[] = useMemo(() => { - if (!consensusMeta) return [] as TableRow[]; - return consensusMeta?.value.map(([key, value]) => { - return { - key: `${key}-${value}`, - metaKey: {key}, - value: {value}, - }; - }); - }, [consensusMeta]); - - return ( - - {consensusMeta ? ( - <> - - - {t( - 'federation-dashboard.config.manage-meta.consensus-meta-label' - )} - - - {t('federation-dashboard.config.manage-meta.revision')}:{' '} - {consensusMeta?.revision} - - -
- - ) : ( - - - {t('federation-dashboard.config.manage-meta.setup-meta-title')} - - - {t( - 'federation-dashboard.config.manage-meta.setup-meta-description' - )} - - - {t('federation-dashboard.config.manage-meta.propose-updates')} - - - {t('federation-dashboard.config.manage-meta.core-meta-fields')} - - - - - federation_expiry_timestamp:{' '} - {t('federation-dashboard.config.manage-meta.meta-field-expiry')} - - - - federation_name:{' '} - {t('federation-dashboard.config.manage-meta.meta-field-name')} - - - - federation_icon_url:{' '} - {t('federation-dashboard.config.manage-meta.meta-field-icon')} - - - - welcome_message:{' '} - {t('federation-dashboard.config.manage-meta.meta-field-welcome')} - - - - vetted_gateways:{' '} - {t('federation-dashboard.config.manage-meta.meta-field-gateways')} - - - {t('federation-dashboard.config.manage-meta.your-own-fields')} - - - - )} - - ); -}); diff --git a/apps/router/src/guardian-ui/components/dashboard/tabs/meta/EditMetaField.tsx b/apps/router/src/guardian-ui/components/dashboard/tabs/meta/manager/EditMetaField.tsx similarity index 100% rename from apps/router/src/guardian-ui/components/dashboard/tabs/meta/EditMetaField.tsx rename to apps/router/src/guardian-ui/components/dashboard/tabs/meta/manager/EditMetaField.tsx diff --git a/apps/router/src/guardian-ui/components/dashboard/tabs/meta/manager/IconPreview.tsx b/apps/router/src/guardian-ui/components/dashboard/tabs/meta/manager/IconPreview.tsx new file mode 100644 index 00000000..ad8be9ba --- /dev/null +++ b/apps/router/src/guardian-ui/components/dashboard/tabs/meta/manager/IconPreview.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { Flex, Image } from '@chakra-ui/react'; + +interface IconPreviewProps { + iconPreview: string | null; +} + +export const IconPreview: React.FC = ({ iconPreview }) => ( + + {iconPreview ? ( + + ) : ( + + ? + + )} + +); diff --git a/apps/router/src/guardian-ui/components/dashboard/tabs/meta/manager/MetaInput.tsx b/apps/router/src/guardian-ui/components/dashboard/tabs/meta/manager/MetaInput.tsx new file mode 100644 index 00000000..9ad3a21e --- /dev/null +++ b/apps/router/src/guardian-ui/components/dashboard/tabs/meta/manager/MetaInput.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { Flex, FormLabel, Input } from '@chakra-ui/react'; +import { snakeToTitleCase } from '@fedimint/utils'; +import { IconPreview } from './IconPreview'; + +interface MetaInputProps { + metaKey: string; + value: string; + onChange: (key: string, value: string) => void; + isIconValid: boolean; + iconPreview: string | null; +} + +export const MetaInput: React.FC = ({ + metaKey, + value, + onChange, + isIconValid, + iconPreview, +}) => ( + + + + {snakeToTitleCase(metaKey)} + + + onChange(metaKey, e.target.value)} + borderColor={ + (['federation_name', 'welcome_message'].includes(metaKey) && + value.trim() === '') || + (metaKey === 'federation_icon_url' && !isIconValid) + ? 'yellow.400' + : 'inherit' + } + /> + {metaKey === 'federation_icon_url' && ( + + )} + + + +); diff --git a/apps/router/src/guardian-ui/components/dashboard/tabs/meta/manager/MetaManager.tsx b/apps/router/src/guardian-ui/components/dashboard/tabs/meta/manager/MetaManager.tsx new file mode 100644 index 00000000..b3cdbf88 --- /dev/null +++ b/apps/router/src/guardian-ui/components/dashboard/tabs/meta/manager/MetaManager.tsx @@ -0,0 +1,242 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { + Box, + Button, + Divider, + Flex, + FormLabel, + Input, + Link, + Text, +} from '@chakra-ui/react'; +import { fieldsToMeta, metaToHex, useTranslation } from '@fedimint/utils'; +import { ParsedConsensusMeta } from '@fedimint/types'; +import { ModuleRpc } from '../../../../../types'; +import { DEFAULT_META_KEY } from '../../FederationTabsCard'; +import { useGuardianAdminApi } from '../../../../../../context/hooks'; +import { RequiredMeta } from './RequiredMeta'; + +const metaArrayToObject = ( + metaArray: [string, string][] +): Record => { + return metaArray.reduce((acc, [key, value]) => { + acc[key] = value; + return acc; + }, {} as Record); +}; + +interface MetaManagerProps { + metaModuleId?: string; + consensusMeta?: ParsedConsensusMeta; + setActiveTab: (tab: number) => void; +} + +export const MetaManager = React.memo(function MetaManager({ + metaModuleId, + consensusMeta, + setActiveTab, +}: MetaManagerProps): JSX.Element { + const { t } = useTranslation(); + const api = useGuardianAdminApi(); + const [requiredMeta, setRequiredMeta] = useState>({ + federation_name: '', + welcome_message: '', + popup_end_timestamp: '', + federation_icon_url: '', + }); + const [isRequiredMetaValid, setIsRequiredMetaValid] = useState(true); + const [optionalMeta, setOptionalMeta] = useState>({}); + + useEffect(() => { + if (consensusMeta?.value) { + const metaObj = metaArrayToObject(consensusMeta.value); + const { + federation_name, + welcome_message, + popup_end_timestamp, + federation_icon_url, + ...rest + } = metaObj; + setRequiredMeta({ + federation_name, + welcome_message, + popup_end_timestamp, + federation_icon_url, + }); + setOptionalMeta(rest); + } + }, [consensusMeta]); + + const isAnyRequiredFieldEmpty = useCallback(() => { + // Popup end timestamp is optional but placed in required for simplicity + return ['federation_name', 'welcome_message', 'federation_icon_url'].some( + (key) => requiredMeta[key].trim() === '' + ); + }, [requiredMeta]); + + const isMetaUnchanged = useCallback(() => { + if (!consensusMeta?.value) return false; + const consensusMetaObj = metaArrayToObject(consensusMeta.value); + + // Check if all current fields (required and optional) match the consensus meta + const allCurrentFields = { ...requiredMeta, ...optionalMeta }; + const currentUnchanged = Object.entries(allCurrentFields).every( + ([key, value]) => consensusMetaObj[key] === value + ); + + // Check if any fields from consensus meta are missing in the current fields + const noFieldsRemoved = Object.keys(consensusMetaObj).every( + (key) => key in allCurrentFields + ); + + return currentUnchanged && noFieldsRemoved; + }, [requiredMeta, optionalMeta, consensusMeta]); + + const canSubmit = useCallback(() => { + return ( + !isAnyRequiredFieldEmpty() && isRequiredMetaValid && !isMetaUnchanged() + ); + }, [isAnyRequiredFieldEmpty, isRequiredMetaValid, isMetaUnchanged]); + + const resetToConsensus = useCallback(() => { + if (consensusMeta?.value) { + const metaObj = metaArrayToObject(consensusMeta.value); + const { + federation_name, + welcome_message, + popup_end_timestamp, + federation_icon_url, + ...rest + } = metaObj; + setRequiredMeta({ + federation_name, + welcome_message, + popup_end_timestamp, + federation_icon_url, + }); + setOptionalMeta(rest); + } + }, [consensusMeta]); + + const handleOptionalMetaChange = (key: string, value: string) => { + setOptionalMeta((prev) => ({ ...prev, [key]: value })); + }; + + const addCustomField = () => { + const timestamp = Date.now(); + const newKey = `custom_field_${timestamp}`; + setOptionalMeta((prev) => ({ ...prev, [newKey]: '' })); + }; + + const removeCustomField = (key: string) => { + setOptionalMeta((prev) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { [key]: _, ...rest } = prev; + return rest; + }); + }; + + const proposeMetaEdits = useCallback(() => { + if (metaModuleId === undefined || isAnyRequiredFieldEmpty()) return; + const updatedMetaArray = Object.entries({ + ...requiredMeta, + ...optionalMeta, + }).filter(([key, value]) => key !== 'popup_end_timestamp' || value !== ''); + api + .moduleApiCall<{ metaValue: string }[]>( + Number(metaModuleId), + ModuleRpc.submitMeta, + { + key: DEFAULT_META_KEY, + value: metaToHex(fieldsToMeta(updatedMetaArray)), + } + ) + .then(() => { + setActiveTab(3); + }) + .catch((error) => { + console.error(error); + alert('Failed to propose meta edits. Please try again.'); + }); + }, [ + api, + metaModuleId, + requiredMeta, + optionalMeta, + isAnyRequiredFieldEmpty, + setActiveTab, + ]); + + return ( + + + {t('federation-dashboard.config.manage-meta.header')} + + + + {t('federation-dashboard.config.manage-meta.description')} + + + {t('federation-dashboard.config.manage-meta.learn-more')} + + + + + + + {Object.entries(optionalMeta).map(([key, value]) => ( + + + + {key} + + + + handleOptionalMetaChange(key, e.target.value)} + /> + + ))} + + + + {consensusMeta?.value && !isMetaUnchanged() && ( + + )} + + + + ); +}); diff --git a/apps/router/src/guardian-ui/components/dashboard/tabs/meta/manager/ProposeMetaModal.tsx b/apps/router/src/guardian-ui/components/dashboard/tabs/meta/manager/ProposeMetaModal.tsx new file mode 100644 index 00000000..cd37af89 --- /dev/null +++ b/apps/router/src/guardian-ui/components/dashboard/tabs/meta/manager/ProposeMetaModal.tsx @@ -0,0 +1,105 @@ +import React from 'react'; +import { + Text, + Button, + ModalHeader, + ModalCloseButton, + ModalBody, + ModalContent, + ModalOverlay, + Modal, + ModalFooter, + Flex, + Divider, +} from '@chakra-ui/react'; +import { useTranslation } from '@fedimint/utils'; +import { MetaFields } from '@fedimint/types'; +import { EditMetaField } from './EditMetaField'; + +interface ProposeMetaModalProps { + isOpen: boolean; + onClose: () => void; + editedMetaFields: MetaFields; + setEditedMetaFields: (fields: MetaFields) => void; + proposeMetaEdits: () => void; +} + +export const ProposeMetaModal: React.FC = ({ + isOpen, + onClose, + editedMetaFields, + setEditedMetaFields, + proposeMetaEdits, +}) => { + const { t } = useTranslation(); + + return ( + + + + + {t('federation-dashboard.config.manage-meta.edit-meta-label')} + + + + + {t('federation-dashboard.config.manage-meta.setup-meta-title')} + + + {t( + 'federation-dashboard.config.manage-meta.setup-meta-description' + )} + + + {t('federation-dashboard.config.manage-meta.propose-updates')} + + + {t('federation-dashboard.config.manage-meta.core-meta-fields')} + + + + - federation_expiry_timestamp:{' '} + {t('federation-dashboard.config.manage-meta.meta-field-expiry')} + + + - federation_name:{' '} + {t('federation-dashboard.config.manage-meta.meta-field-name')} + + + - federation_icon_url:{' '} + {t('federation-dashboard.config.manage-meta.meta-field-icon')} + + + - welcome_message:{' '} + {t('federation-dashboard.config.manage-meta.meta-field-welcome')} + + + - vetted_gateways:{' '} + {t('federation-dashboard.config.manage-meta.meta-field-gateways')} + + + + {t('federation-dashboard.config.manage-meta.your-own-fields')} + + + + + + + + + + + ); +}; diff --git a/apps/router/src/guardian-ui/components/dashboard/tabs/meta/manager/RequiredMeta.tsx b/apps/router/src/guardian-ui/components/dashboard/tabs/meta/manager/RequiredMeta.tsx new file mode 100644 index 00000000..d0d6472b --- /dev/null +++ b/apps/router/src/guardian-ui/components/dashboard/tabs/meta/manager/RequiredMeta.tsx @@ -0,0 +1,81 @@ +import React, { useState, useEffect } from 'react'; +import { Flex } from '@chakra-ui/react'; +import { MetaInput } from './MetaInput'; + +interface RequiredMetaProps { + requiredMeta: Record; + setRequiredMeta: React.Dispatch>>; + isValid: boolean; + setIsValid: React.Dispatch>; +} + +export const RequiredMeta: React.FC = ({ + requiredMeta, + setRequiredMeta, + setIsValid, +}) => { + const [iconPreview, setIconPreview] = useState(null); + const [isIconValid, setIsIconValid] = useState(true); + + useEffect(() => { + let objectURL: string | null = null; + + const validateIcon = async (url: string) => { + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const blob = await response.blob(); + if (!blob.type.startsWith('image/')) { + throw new Error('Invalid image format'); + } + objectURL = URL.createObjectURL(blob); + setIconPreview(objectURL); + setIsIconValid(true); + } catch (error) { + setIconPreview(null); + setIsIconValid(false); + if (error instanceof Error) { + console.error(`Icon validation failed: ${error.message}`); + } + } + }; + + if (requiredMeta.federation_icon_url) { + validateIcon(requiredMeta.federation_icon_url); + } else { + setIconPreview(null); + setIsIconValid(true); + } + + return () => { + if (objectURL) { + URL.revokeObjectURL(objectURL); + } + }; + }, [requiredMeta.federation_icon_url]); + + useEffect(() => { + setIsValid(isIconValid); + }, [isIconValid, setIsValid]); + + const handleChange = (key: string, value: string) => { + setRequiredMeta((prev) => ({ ...prev, [key]: value })); + }; + + return ( + + {Object.entries(requiredMeta).map(([key, value]) => ( + + ))} + + ); +}; diff --git a/apps/router/src/guardian-ui/components/dashboard/tabs/meta/proposals/ConfirmNewMetaModal.tsx b/apps/router/src/guardian-ui/components/dashboard/tabs/meta/proposals/ConfirmNewMetaModal.tsx new file mode 100644 index 00000000..324e642a --- /dev/null +++ b/apps/router/src/guardian-ui/components/dashboard/tabs/meta/proposals/ConfirmNewMetaModal.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { + Modal, + ModalOverlay, + ModalContent, + ModalHeader, + ModalBody, + ModalFooter, + Button, + Text, +} from '@chakra-ui/react'; +import { useTranslation } from '@fedimint/utils'; +import { MetaFields } from '@fedimint/types'; +import { Table, TableColumn } from '@fedimint/ui'; +import { formatJsonValue } from './ProposedMetas'; + +interface ConfirmNewMetaModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + selectedMeta: MetaFields | null; +} + +export const ConfirmNewMetaModal: React.FC = ({ + isOpen, + onClose, + onConfirm, + selectedMeta, +}) => { + const { t } = useTranslation(); + + const columnsWithoutEffect: TableColumn<'metaKey' | 'value'>[] = [ + { + key: 'metaKey', + heading: t('set-config.meta-fields-key'), + }, + { + key: 'value', + heading: t('set-config.meta-fields-value'), + }, + ]; + + return ( + + + + + {t('federation-dashboard.config.manage-meta.confirm-modal.title')} + + + + {t( + 'federation-dashboard.config.manage-meta.confirm-modal.description' + )} + + {selectedMeta && ( +
({ + key: `${key}-${value}`, + metaKey: {key}, + value: formatJsonValue(value), + }))} + /> + )} + + + + + + + + ); +}; diff --git a/apps/router/src/guardian-ui/components/dashboard/tabs/meta/proposals/NoPendingProposals.tsx b/apps/router/src/guardian-ui/components/dashboard/tabs/meta/proposals/NoPendingProposals.tsx new file mode 100644 index 00000000..5bcad3f8 --- /dev/null +++ b/apps/router/src/guardian-ui/components/dashboard/tabs/meta/proposals/NoPendingProposals.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { Box, Button, Flex, Text } from '@chakra-ui/react'; +import { useTranslation } from '@fedimint/utils'; + +interface NoPendingProposalsProps { + setActiveTab: (tab: number) => void; +} + +export const NoPendingProposals: React.FC = ({ + setActiveTab, +}) => { + const { t } = useTranslation(); + + return ( + + + {t( + 'federation-dashboard.config.manage-meta.no-pending-proposals-header' + )} + + + + + {t( + 'federation-dashboard.config.manage-meta.no-pending-proposals-description' + )} + + + + + + ); +}; diff --git a/apps/router/src/guardian-ui/components/dashboard/tabs/meta/proposals/ProposedMetas.tsx b/apps/router/src/guardian-ui/components/dashboard/tabs/meta/proposals/ProposedMetas.tsx new file mode 100644 index 00000000..83fd4dac --- /dev/null +++ b/apps/router/src/guardian-ui/components/dashboard/tabs/meta/proposals/ProposedMetas.tsx @@ -0,0 +1,339 @@ +import React, { useState, useCallback } from 'react'; +import { + Flex, + Text, + Button, + Card, + CardHeader, + CardBody, + CardFooter, + Icon, + useDisclosure, + useBreakpointValue, +} from '@chakra-ui/react'; +import { ReactComponent as CheckIcon } from '../../../../../assets/svgs/check.svg'; +import { useTranslation, metaToHex, fieldsToMeta } from '@fedimint/utils'; +import { MetaFields, ParsedConsensusMeta } from '@fedimint/types'; + +import { ModuleRpc } from '../../../../../types'; +import { Table, TableColumn } from '@fedimint/ui'; +import { + bftHonest, + generateSimpleHash, + isJsonString, +} from '../../../../../utils'; +import { DEFAULT_META_KEY } from '../../FederationTabsCard'; +import { ConfirmNewMetaModal } from './ConfirmNewMetaModal'; +import { useGuardianAdminApi } from '../../../../../../context/hooks'; + +export const formatJsonValue = (value: string): JSX.Element => { + if (isJsonString(value)) { + const parsedJson = JSON.parse(value); + return ( +
+        {JSON.stringify(parsedJson, null, 2)}
+      
+ ); + } + return {value}; +}; + +type MetaSubmissionMap = { + [key: string]: { + peers: number[]; + meta: MetaFields; + }; +}; + +type TableKey = 'metaKey' | 'value' | 'effect'; + +interface ProposedMetasProps { + ourPeer: { id: number; name: string }; + peers: { id: number; name: string }[]; + metaModuleId: string; + consensusMeta?: ParsedConsensusMeta; + metaSubmissions: MetaSubmissionMap; + hasVoted: boolean; +} + +export const ProposedMetas = React.memo(function ProposedMetas({ + ourPeer, + peers, + metaModuleId, + consensusMeta, + metaSubmissions, +}: ProposedMetasProps): JSX.Element { + const { t } = useTranslation(); + const api = useGuardianAdminApi(); + const { isOpen, onOpen: openModal, onClose } = useDisclosure(); + const [selectedMeta, setSelectedMeta] = useState(null); + const isMobile = useBreakpointValue({ base: true, md: false }); + + const totalGuardians = peers.length; + const threshold = bftHonest(totalGuardians); + + const handleClear = useCallback(async () => { + try { + await api.moduleApiCall<{ metaValue: string }[]>( + Number(metaModuleId), + ModuleRpc.submitMeta, + { + key: DEFAULT_META_KEY, + value: metaToHex(fieldsToMeta([])), // Empty submission + } + ); + } catch (err) { + console.error('Failed to clear meta edits', err); + } + }, [api, metaModuleId]); + + const handleApprove = useCallback( + async (meta: MetaFields) => { + try { + // Submit the meta fields for the guardian + await api.moduleApiCall<{ metaValue: string }[]>( + Number(metaModuleId), + ModuleRpc.submitMeta, + { + key: DEFAULT_META_KEY, + value: metaToHex(fieldsToMeta(meta)), + } + ); + } catch (err) { + console.error('Failed to submit meta edits', err); + } + }, + [api, metaModuleId] + ); + + const columnsWithEffect: TableColumn[] = [ + { + key: 'metaKey', + heading: t('set-config.meta-fields-key'), + }, + { + key: 'value', + heading: t('set-config.meta-fields-value'), + }, + { + key: 'effect', + heading: t('set-config.meta-fields-effect'), + }, + ]; + + const isEqual = (a: string, b: string): boolean => { + try { + return JSON.stringify(JSON.parse(a)) === JSON.stringify(JSON.parse(b)); + } catch { + return a === b; + } + }; + + const getEffect = ( + key: string, + value: string, + consensusMeta: ParsedConsensusMeta | undefined + ): JSX.Element => { + if (!consensusMeta?.value) { + return ( + + {t('federation-dashboard.config.manage-meta.meta-effect-add')} + + ); + } + + const consensusValue = consensusMeta.value.find(([k]) => k === key)?.[1]; + + if (consensusValue === undefined) { + return ( + + {t('federation-dashboard.config.manage-meta.meta-effect-add')} + + ); + } else if (!isEqual(consensusValue, value)) { + return ( + + {t('federation-dashboard.config.manage-meta.meta-effect-modify')} + + ); + } else { + return ( + + {t('federation-dashboard.config.manage-meta.meta-effect-unchanged')} + + ); + } + }; + + const handleApproveWithWarning = ( + meta: MetaFields, + currentApprovals: number + ) => { + if (currentApprovals + 1 >= threshold) { + setSelectedMeta(meta); + openModal(); + } else { + handleApprove(meta); + } + }; + + const confirmApproval = async () => { + if (selectedMeta) { + await handleApprove(selectedMeta); + onClose(); + } + }; + + return ( + + {metaSubmissions && Object.keys(metaSubmissions).length > 0 ? ( + + + {t('federation-dashboard.config.manage-meta.proposals')} + + + {t('common.threshold')}: {threshold} / {totalGuardians} + + + ) : null} + {metaSubmissions && + Object.entries(metaSubmissions).map(([key, submission]) => { + const submissionMap = new Map(submission.meta); + const rows = [ + ...submission.meta + .filter(([key, value]) => { + const consensusValue = consensusMeta?.value.find( + ([k]) => k === key + )?.[1]; + return !consensusValue || !isEqual(consensusValue, value); + }) + .map(([key, value]) => ({ + key: `${key}-${value}`, + metaKey: {key}, + value: ( +
+                    {formatJsonValue(value)}
+                  
+ ), + effect: getEffect(key, value, consensusMeta), + })), + ...(consensusMeta + ? consensusMeta.value + .filter(([key]) => !submissionMap.has(key)) + .map(([key, value]) => ({ + key: `${key}-${value}`, + metaKey: {key}, + value: {value}, + effect: {t('common.remove')}, + })) + : []), + ]; + + const totalGuardians = peers.length; + const currentApprovals = submission.peers.length; + + return ( + + + + + {`Proposal ID: ${generateSimpleHash(key)}`} + + {submission.peers.includes(ourPeer.id) ? ( + + ) : ( + + )} + + + + {isMobile ? ( + + {rows.map((row) => ( + + {row.metaKey} + {row.value} + {row.effect} + + ))} + + ) : ( +
+ )} + + + + {t('common.approvals')}: ( {currentApprovals} /{' '} + {totalGuardians} ) + + + {submission.peers.map((peerId) => ( + + + + {ourPeer.id === Number(peerId) + ? t('common.you') + : peers.find((p) => p.id === Number(peerId))?.name} + + + ))} + + + + ); + })} + + + + ); +}); diff --git a/apps/router/src/guardian-ui/components/setup/screens/connectGuardians/ConnectGuardians.tsx b/apps/router/src/guardian-ui/components/setup/screens/connectGuardians/ConnectGuardians.tsx index f948784c..3cac40ad 100644 --- a/apps/router/src/guardian-ui/components/setup/screens/connectGuardians/ConnectGuardians.tsx +++ b/apps/router/src/guardian-ui/components/setup/screens/connectGuardians/ConnectGuardians.tsx @@ -64,7 +64,12 @@ export const ConnectGuardians: React.FC = ({ next }) => { } else if (role === GuardianRole.Host) { content = ( - + {t('connect-guardians.invite-guardians')} { return url; } }; + +export function isJsonString(str: string): boolean { + try { + JSON.parse(str); + return true; + } catch (e) { + return false; + } +} diff --git a/apps/router/src/languages/en.json b/apps/router/src/languages/en.json index a9811b99..ef0212d6 100644 --- a/apps/router/src/languages/en.json +++ b/apps/router/src/languages/en.json @@ -89,28 +89,34 @@ "no-gateways-info-description": "Lightning node operators can connect to your federation to provide Lightning Network interoperability. Once connected, they will appear here." }, "api-announcements": { - "label": "API Announcements", + "label": "API", "guardian": "Guardian", "api-url": "API URL", "revision": "Revision" }, "config": { "label": "Federation Config", - "view-config": "View Config", + "view-config": "Config", + "view-meta": "Meta", "missing-meta-module": "Editing Meta fields is not possible. The Meta module is not available for this federation.", "manage-meta": { + "header": "Your Federation's Metadata", + "description": "Fedimint can supply your users with additional information (metadata) about the Federation, such as a name, welcome message, or image. While this information does not affect the custody of funds, all the Guardians must agree on the metadata.", + "learn-more": "Learn more about Federation Metadata.", "label": "Manage Meta", "cancel-button": "Cancel", "confirm-modal": { "title": "Confirm Approval", "description": "Your approval will reach the threshold to adopt this meta change. Your new meta will look like:" }, - "consensus-meta-label": "Current meta in consensus", + "consensus-meta-label": "Consensus Metadata", "revoke-button": "Revoke", "no-consensus-meta-message": "there are no meta in consensus", "proposed-meta-label": "Meta Proposals", "propose-meta": "Propose Meta", - "propose-new-meta-button": "Propose New Meta", + "propose-new-meta-button": "Propose New Metadata", + "add-custom-field-button": "Add Custom Field", + "reset-to-consensus-button": "Reset to Consensus", "proposal-approved": "You have approved this proposal", "no-submitted-meta-message": "there are no meta edits to review", "edit-meta-label": "Edit meta", diff --git a/packages/types/src/meta.ts b/packages/types/src/meta.ts index 94c6a9ce..0f3f8581 100644 --- a/packages/types/src/meta.ts +++ b/packages/types/src/meta.ts @@ -17,3 +17,9 @@ export interface ConsensusMeta { revision: number; value: string; } + +// Parsed ConsensusMeta +export interface ParsedConsensusMeta { + revision: number; + value: MetaFields; +} diff --git a/packages/utils/src/index.tsx b/packages/utils/src/index.tsx index 36fea8e5..ab37d146 100644 --- a/packages/utils/src/index.tsx +++ b/packages/utils/src/index.tsx @@ -28,3 +28,17 @@ export const sha256Hash = async (input: string): Promise => { .map((b) => b.toString(16).padStart(2, '0')) .join(''); }; + +export const snakeToTitleCase = (str: string): string => { + return str + .split('_') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); +}; + +export const titleToSnakeCase = (str: string): string => { + return str + .split(' ') + .map((word) => word.toLowerCase()) + .join('_'); +};