diff --git a/packages/next-common/components/data/common/pageHeader.jsx b/packages/next-common/components/data/common/pageHeader.jsx index 10a4ce4121..e38f66daa8 100644 --- a/packages/next-common/components/data/common/pageHeader.jsx +++ b/packages/next-common/components/data/common/pageHeader.jsx @@ -36,7 +36,7 @@ export default function PageHeader({ href = "" }) {
{title} - + {href && }
); diff --git a/packages/next-common/components/data/context/tabs.js b/packages/next-common/components/data/context/tabs.js index 0bc88957a7..b7c7d177d4 100644 --- a/packages/next-common/components/data/context/tabs.js +++ b/packages/next-common/components/data/context/tabs.js @@ -25,6 +25,14 @@ function generateTabs() { }); } + if (modules?.recovery) { + TABS.push({ + tabId: "/recovery", + tabTitle: "Recovery", + pageTitle: "Recovery Explorer", + }); + } + return TABS; } diff --git a/packages/next-common/components/data/index.jsx b/packages/next-common/components/data/index.jsx index 6a42bce4f1..3fba63ac09 100644 --- a/packages/next-common/components/data/index.jsx +++ b/packages/next-common/components/data/index.jsx @@ -2,6 +2,7 @@ import DataBaseLayout from "./common/baseLayout"; import CommonTabs from "./common/tabs"; import ProxyExplorer from "./proxies"; import MultisigExplorer from "./multisig"; +import RecoveryExplorer from "./recovery"; function DataPageWithLayout({ children }) { return ( @@ -27,3 +28,11 @@ export function DataMultisig() { ); } + +export function DataRecovery() { + return ( + + + + ); +} diff --git a/packages/next-common/components/data/recovery/hooks/useQueryAllRecoveryAttempts.js b/packages/next-common/components/data/recovery/hooks/useQueryAllRecoveryAttempts.js new file mode 100644 index 0000000000..650f4ee07b --- /dev/null +++ b/packages/next-common/components/data/recovery/hooks/useQueryAllRecoveryAttempts.js @@ -0,0 +1,111 @@ +import { useContextApi } from "next-common/context/api"; +import { useEffect, useState } from "react"; + +function bitfieldToIndices(bitfield) { + const indices = []; + let bitIndex = 0; + for (const byte of bitfield) { + if (typeof byte === "number") { + for (let b = 0; b < 8; b++) { + if (byte & (1 << b)) { + indices.push(bitIndex + b); + } + } + } + bitIndex += 8; + } + return indices; +} + +export default function useQueryAllRecoveryAttempts() { + const api = useContextApi(); + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!api) { + return; + } + + if (!api?.query.recovery?.attempt) { + setLoading(false); + setData([]); + return; + } + + let cancelled = false; + setLoading(true); + + api.query.recovery.attempt + .entries() + .then(async (entries) => { + if (cancelled) return; + + // Build a map of (lostAccount) -> friendGroups to resolve bitfield indices to addresses + const lostAccounts = [ + ...new Set(entries.map(([key]) => key.args?.[0]?.toString())), + ]; + const friendGroupsMap = {}; + + await Promise.all( + lostAccounts.map(async (account) => { + try { + const value = await api.query.recovery.friendGroups(account); + const json = value.toJSON(); + friendGroupsMap[account] = json?.[0] || []; + } catch { + friendGroupsMap[account] = []; + } + }), + ); + + const result = entries.map(([storageKey, value]) => { + const lostAccount = storageKey.args?.[0]?.toString(); + const friendGroupIndex = storageKey.args?.[1]?.toNumber(); + + const json = value.toJSON(); + const attempt = json?.[0] || {}; + + const approvalsBitfield = attempt.approvals || []; + const approvedIndices = bitfieldToIndices(approvalsBitfield); + const approvalsCount = approvedIndices.length; + + // Resolve approved indices to actual friend addresses + const friendGroup = + (friendGroupsMap[lostAccount] || [])[friendGroupIndex] || {}; + const friends = friendGroup.friends || []; + const approvedAddresses = approvedIndices + .filter((i) => i < friends.length) + .map((i) => friends[i]); + + return { + lostAccount, + friendGroupIndex, + initiator: attempt.initiator || "", + initBlock: parseInt(attempt.initBlock) || 0, + lastApprovalBlock: parseInt(attempt.lastApprovalBlock) || 0, + approvalsCount, + approvedAddresses, + }; + }); + + if (!cancelled) { + setData(result); + setLoading(false); + } + }) + .catch((error) => { + console.error("Failed to query recovery attempts", error); + if (!cancelled) { + setData([]); + setLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [api]); + + return { data, loading }; +} diff --git a/packages/next-common/components/data/recovery/hooks/useQueryAllRecoveryData.js b/packages/next-common/components/data/recovery/hooks/useQueryAllRecoveryData.js new file mode 100644 index 0000000000..df56c3d169 --- /dev/null +++ b/packages/next-common/components/data/recovery/hooks/useQueryAllRecoveryData.js @@ -0,0 +1,89 @@ +import { useContextApi } from "next-common/context/api"; +import { useEffect, useState } from "react"; + +export function flattenRecoveryData(data) { + if (!data || data.length === 0) { + return []; + } + + const rows = []; + for (const entry of data) { + for (const group of entry.friendGroups) { + rows.push({ + account: entry.account, + index: group.index, + inheritancePriority: group.inheritancePriority, + friends: group.friends, + friendsNeeded: group.friendsNeeded, + inheritor: group.inheritor, + inheritanceDelay: group.inheritanceDelay, + cancelDelay: group.cancelDelay, + }); + } + } + return rows; +} + +export default function useQueryAllRecoveryData() { + const api = useContextApi(); + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!api) { + return; + } + + if (!api?.query.recovery?.friendGroups) { + setLoading(false); + setData([]); + return; + } + + let cancelled = false; + setLoading(true); + + api.query.recovery.friendGroups + .entries() + .then((entries) => { + if (cancelled) return; + + const result = entries.map(([storageKey, value]) => { + const account = storageKey.args?.[0]?.toString(); + const jsonValue = value.toJSON(); + const friendGroupVec = Array.isArray(jsonValue?.[0]) + ? jsonValue[0] + : []; + + return { + account, + friendGroups: friendGroupVec.map((group, index) => ({ + index, + friends: group.friends || [], + friendsNeeded: parseInt(group.friendsNeeded) || 0, + inheritor: group.inheritor || "", + inheritancePriority: parseInt(group.inheritancePriority) || 0, + inheritanceDelay: parseInt(group.inheritanceDelay) || 0, + cancelDelay: parseInt(group.cancelDelay) || 0, + })), + }; + }); + + setData(result); + setLoading(false); + }) + .catch((error) => { + console.error("Failed to query recovery friend groups", error); + if (!cancelled) { + setData([]); + setLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [api]); + + return { data, loading }; +} diff --git a/packages/next-common/components/data/recovery/index.jsx b/packages/next-common/components/data/recovery/index.jsx new file mode 100644 index 0000000000..a21f6b6416 --- /dev/null +++ b/packages/next-common/components/data/recovery/index.jsx @@ -0,0 +1,85 @@ +import { useMemo, useState } from "react"; +import { useRouter } from "next/router"; +import TabsList from "next-common/components/tabs/list"; +import { TabLabel } from "next-common/components/assethubMigrationAssets/tabs"; +import Loading from "next-common/components/loading"; +import PageHeader from "../common/pageHeader"; +import RecoveryFriendGroupsTable from "./table"; +import RecoveryAttemptsTable from "./table/attempts"; +import useQueryAllRecoveryData, { + flattenRecoveryData, +} from "./hooks/useQueryAllRecoveryData"; +import useQueryAllRecoveryAttempts from "./hooks/useQueryAllRecoveryAttempts"; +import { searchAddress } from "./table"; + +export default function RecoveryExplorer() { + const [activeTab, setActiveTab] = useState("friendGroups"); + const router = useRouter(); + const search = router.query.search || ""; + + const { data: friendGroupsData, loading: friendGroupsLoading } = + useQueryAllRecoveryData(); + const { data: attemptsData, loading: attemptsLoading } = + useQueryAllRecoveryAttempts(); + + const flattened = useMemo( + () => flattenRecoveryData(friendGroupsData), + [friendGroupsData], + ); + const filteredFlattened = useMemo( + () => searchAddress(flattened, search), + [flattened, search], + ); + const friendGroupsTotal = filteredFlattened.length; + const attemptsTotal = attemptsData?.length || 0; + + const tabs = [ + { + value: "friendGroups", + label: ( + : friendGroupsTotal + } + isActive={activeTab === "friendGroups"} + /> + ), + }, + { + value: "attempts", + label: ( + : attemptsTotal} + isActive={activeTab === "attempts"} + /> + ), + }, + ]; + + return ( + <> + +
+ setActiveTab(tab.value)} + /> +
+ {activeTab === "friendGroups" ? ( + + ) : ( + + )} + + ); +} diff --git a/packages/next-common/components/data/recovery/table/attempts.jsx b/packages/next-common/components/data/recovery/table/attempts.jsx new file mode 100644 index 0000000000..daf2cc82d7 --- /dev/null +++ b/packages/next-common/components/data/recovery/table/attempts.jsx @@ -0,0 +1,83 @@ +import { desktopColumns, mobileColumns } from "./attemptsColumns"; +import usePaginationComponent from "next-common/components/pagination/usePaginationComponent"; +import { defaultPageSize } from "next-common/utils/constants"; +import { useEffect, useState } from "react"; +import { SecondaryCard } from "next-common/components/styled/containers/secondaryCard"; +import { MapDataList } from "next-common/components/dataList"; +import ScrollerX from "next-common/components/styled/containers/scrollerX"; +import { useNavCollapsed } from "next-common/context/nav"; +import { cn } from "next-common/utils"; +import { isNil } from "lodash-es"; + +function enhanceAttemptWithFriendGroup(attempt, friendGroups) { + const fgList = friendGroups?.find((fg) => fg.account === attempt.lostAccount); + const fgGroup = fgList?.friendGroups?.[attempt.friendGroupIndex]; + const threshold = fgGroup?.friendsNeeded || 0; + const friends = fgGroup?.friends || []; + return { ...attempt, threshold, friends }; +} + +export default function RecoveryAttemptsTable({ + data, + loading: isLoading, + friendGroups = [], +}) { + const [navCollapsed] = useNavCollapsed(); + const [dataList, setDataList] = useState([]); + const [totalCount, setTotalCount] = useState(0); + const [loading, setLoading] = useState(true); + + const { page, component: pageComponent } = usePaginationComponent( + totalCount, + defaultPageSize, + ); + + const total = data?.length || 0; + + useEffect(() => { + setLoading(true); + }, [page]); + + useEffect(() => { + if (isLoading || isNil(data)) { + return; + } + + // Enhance attempts with threshold/friends from friendGroups + const enhanced = (data || []).map((attempt) => + enhanceAttemptWithFriendGroup(attempt, friendGroups), + ); + + setTotalCount(total); + const startIndex = (page - 1) * defaultPageSize; + const endIndex = startIndex + defaultPageSize; + setDataList(enhanced?.slice(startIndex, endIndex)); + setLoading(false); + }, [data, isLoading, page, total, friendGroups]); + + return ( + + + + + + + {total > 0 && pageComponent} + + ); +} diff --git a/packages/next-common/components/data/recovery/table/attemptsColumns.jsx b/packages/next-common/components/data/recovery/table/attemptsColumns.jsx new file mode 100644 index 0000000000..f0c703390e --- /dev/null +++ b/packages/next-common/components/data/recovery/table/attemptsColumns.jsx @@ -0,0 +1,156 @@ +import AddressUser from "next-common/components/user/addressUser"; +import Tooltip from "next-common/components/tooltip"; +import { AddressesTooltip } from "next-common/components/multisigs/fields"; +import { useRelayChainApi } from "next-common/context/relayChain"; +import { useChainSettings } from "next-common/context/chain"; +import { estimateBlocksTime } from "next-common/utils"; +import useCall from "next-common/utils/hooks/useCall"; +import { isNil } from "lodash-es"; + +function BlockNumberWithTooltip({ height }) { + const api = useRelayChainApi(); + const { blockTime } = useChainSettings(); + const { value: currentNumber } = useCall(api?.query?.system?.number, []); + const currentHeight = currentNumber?.toNumber(); + + if (isNil(height) || isNil(currentHeight)) { + return ( + + #{height?.toLocaleString() || 0} + + ); + } + + const diff = Math.max(0, currentHeight - height); + const estimatedTime = diff > 0 ? estimateBlocksTime(diff, blockTime) : null; + + return ( + + + #{height?.toLocaleString() || 0} + + + ); +} + +export const desktopColumns = [ + { + name: "Lost Account", + className: "min-w-[200px] text-left", + render: (item) => ( + + ), + }, + { + name: "Group Index", + className: "w-[120px] text-left", + render: (item) => ( + + } + > + + #{item.friendGroupIndex} + + + ), + }, + { + name: "Initiator", + className: "min-w-[200px] text-left", + render: (item) => ( + + ), + }, + { + name: "Init Block", + className: "w-[180px] text-left", + render: (item) => , + }, + { + name: "Last Approval Block", + className: "w-[200px] text-left", + render: (item) => ( + + ), + }, + { + name: "Threshold / Approvals", + className: "w-[160px] text-right", + render: (item) => ( + + } + > + + {item.approvalsCount} / + {item.threshold} + + + ), + }, +]; + +export const mobileColumns = [ + { + name: "Lost Account", + className: "text-left", + render: (item) => , + }, + { + name: "Group Index", + className: "text-right", + render: (item) => ( + + } + > + + #{item.friendGroupIndex} + + + ), + }, + { + name: "Initiator", + className: "text-left", + render: (item) => , + }, + { + name: "Init Block", + className: "text-right", + render: (item) => , + }, + { + name: "Last Approval Block", + className: "text-right", + render: (item) => ( + + ), + }, + { + name: "Threshold / Approvals", + className: "text-right", + render: (item) => ( + + } + > + + {item.approvalsCount} / + {item.threshold} + + + ), + }, +]; diff --git a/packages/next-common/components/data/recovery/table/columns.jsx b/packages/next-common/components/data/recovery/table/columns.jsx new file mode 100644 index 0000000000..0b5e683918 --- /dev/null +++ b/packages/next-common/components/data/recovery/table/columns.jsx @@ -0,0 +1,149 @@ +import AddressUser from "next-common/components/user/addressUser"; +import Tooltip from "next-common/components/tooltip"; +import { AddressesTooltip } from "next-common/components/multisigs/fields"; +import { useEstimateBlocksTime } from "next-common/utils/hooks"; +import { isNil } from "lodash-es"; + +function FriendsCount({ friends = [] }) { + if (isNil(friends)) { + return null; + } + + return ( + } + > + + {friends?.length || 0} + + + ); +} + +function DelayBlock({ blocks }) { + const estimatedTime = useEstimateBlocksTime(blocks); + + if (isNil(blocks)) { + return null; + } + + return ( + + + {blocks?.toLocaleString() || 0} + + + ); +} + +export const desktopColumns = [ + { + name: "Account", + className: "min-w-[200px] text-left", + render: (item) => ( + + ), + }, + { + name: "Group Index", + className: "w-[120px] text-left", + render: (item) => ( + #{item.index} + ), + }, + { + name: "Priority", + className: "w-[100px] text-left", + render: (item) => ( + + {item.inheritancePriority} + + ), + }, + { + name: "Friends", + className: "w-[100px] text-left", + render: (item) => , + }, + { + name: "Threshold", + className: "w-[120px] text-left", + render: (item) => ( + + {item.friendsNeeded} + + ), + }, + { + name: "Inheritor", + className: "min-w-[160px] text-left", + render: (item) => ( + + ), + }, + { + name: "Inheritance Delay", + className: "w-[180px] text-left", + render: (item) => , + }, + { + name: "Cancel Delay", + className: "w-[160px] text-right", + render: (item) => , + }, +]; + +export const mobileColumns = [ + { + name: "Account", + className: "text-left", + render: (item) => , + }, + { + name: "Group Index", + className: "text-right", + render: (item) => ( + #{item.index} + ), + }, + + { + name: "Priority", + className: "text-right", + render: (item) => ( + + {item.inheritancePriority} + + ), + }, + { + name: "Friends", + className: "text-left", + render: (item) => , + }, + + { + name: "Threshold", + className: "text-right", + render: (item) => ( + + {item.friendsNeeded} + + ), + }, + { + name: "Inheritor", + className: "text-right", + render: (item) => , + }, + { + name: "Inheritance Delay", + className: "text-right", + render: (item) => , + }, + { + name: "Cancel Delay", + className: "text-right", + render: (item) => , + }, +]; diff --git a/packages/next-common/components/data/recovery/table/index.jsx b/packages/next-common/components/data/recovery/table/index.jsx new file mode 100644 index 0000000000..d2e4981591 --- /dev/null +++ b/packages/next-common/components/data/recovery/table/index.jsx @@ -0,0 +1,109 @@ +import { desktopColumns, mobileColumns } from "./columns"; +import usePaginationComponent from "next-common/components/pagination/usePaginationComponent"; +import { defaultPageSize } from "next-common/utils/constants"; +import { useEffect, useMemo, useState } from "react"; +import { SecondaryCard } from "next-common/components/styled/containers/secondaryCard"; +import { MapDataList } from "next-common/components/dataList"; +import ScrollerX from "next-common/components/styled/containers/scrollerX"; +import { useRouter } from "next/router"; +import useSearchComponent from "../../common/useSearchComponent"; +import { useNavCollapsed } from "next-common/context/nav"; +import { cn } from "next-common/utils"; +import { isNil } from "lodash-es"; +import { addRouterQuery } from "next-common/utils/router"; +import { flattenRecoveryData } from "../../recovery/hooks/useQueryAllRecoveryData"; + +export function searchAddress(list, keyword) { + if (!keyword) { + return list; + } + + const lowerSearch = keyword.toLowerCase(); + return list.filter((row) => row.account?.toLowerCase().includes(lowerSearch)); +} + +export default function RecoveryFriendGroupsTable({ + data: rawData, + loading: isLoading, +}) { + const [navCollapsed] = useNavCollapsed(); + const router = useRouter(); + const [dataList, setDataList] = useState([]); + const { search = "", component: SearchBoxComponent } = useSearchComponent({ + placeholder: "Search by account address", + className: "my-0 ml-2 flex-1 max-sm:w-full max-sm:ml-6", + size: "small", + }); + const [totalCount, setTotalCount] = useState(0); + const [loading, setLoading] = useState(true); + + const { page, component: pageComponent } = usePaginationComponent( + totalCount, + defaultPageSize, + ); + + const flattenedData = useMemo(() => flattenRecoveryData(rawData), [rawData]); + const filteredData = useMemo( + () => searchAddress(flattenedData, search), + [flattenedData, search], + ); + + const total = filteredData?.length || 0; + + useEffect(() => { + setLoading(true); + }, [page]); + + useEffect(() => { + if (isLoading || isNil(rawData)) { + return; + } + + setTotalCount(total); + const startIndex = (page - 1) * defaultPageSize; + const endIndex = startIndex + defaultPageSize; + setDataList(filteredData?.slice(startIndex, endIndex)); + setLoading(false); + }, [rawData, isLoading, page, total, filteredData]); + + useEffect(() => { + addRouterQuery(router, "page", 1); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [router.query.search]); + + useEffect(() => { + addRouterQuery(router, "page", page); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [page]); + + return ( +
+
+ {SearchBoxComponent} +
+ + + + + + + {total > 0 && pageComponent} + +
+ ); +} diff --git a/packages/next-common/components/multisigs/fields.js b/packages/next-common/components/multisigs/fields.js index 34ce13ee0b..2115b723f3 100644 --- a/packages/next-common/components/multisigs/fields.js +++ b/packages/next-common/components/multisigs/fields.js @@ -40,7 +40,7 @@ export function When({ height, index }) { return ( {height.toLocaleString()}-{index} @@ -99,14 +99,14 @@ export function AddressesTooltip({ } return ( -
    +
      {(addresses || []).map((address, index) => (
    • ))} @@ -116,7 +116,7 @@ export function AddressesTooltip({ export function Approving({ approvals, threshold }) { return ( -
      +
      }> {approvals?.length || 0} diff --git a/packages/next-common/utils/consts/menu/data.js b/packages/next-common/utils/consts/menu/data.js index bec6b36812..513038f54d 100644 --- a/packages/next-common/utils/consts/menu/data.js +++ b/packages/next-common/utils/consts/menu/data.js @@ -1,12 +1,11 @@ import { MenuData } from "@osn/icons/subsquare"; +import getChainSettings from "../settings"; +import { CHAIN } from "next-common/utils/constants"; -const Data = { - name: "Data", - value: "data", - pathname: "/proxies", - icon: , - extraMatchNavMenuActivePathnames: ["/proxies", "/multisigs"], - children: [ +function getDataMenu() { + const { modules } = getChainSettings(CHAIN); + + const children = [ { name: "Proxies", value: "proxies", @@ -17,7 +16,24 @@ const Data = { value: "multisig", pathname: "/multisigs", }, - ], -}; + ]; + + if (modules?.recovery) { + children.push({ + name: "Recovery", + value: "recovery", + pathname: "/recovery", + }); + } + + return { + name: "Data", + value: "data", + pathname: "/proxies", + icon: , + extraMatchNavMenuActivePathnames: children.map((c) => c.pathname), + children, + }; +} -export default Data; +export default getDataMenu; diff --git a/packages/next-common/utils/consts/menu/index.js b/packages/next-common/utils/consts/menu/index.js index 9620609dae..e1cf81ebc7 100644 --- a/packages/next-common/utils/consts/menu/index.js +++ b/packages/next-common/utils/consts/menu/index.js @@ -21,7 +21,7 @@ import { coretimeMenu } from "./coretime"; import { getPeopleMenu } from "./people"; import { stakingMenu } from "./staking"; import whitelist from "./whitelist"; -import Data from "./data"; +import getDataMenu from "./data"; import vesting from "./vesting"; import getAdvancedMenu from "next-common/utils/consts/menu/advanced"; import { NAV_MENU_TYPE } from "next-common/utils/constants"; @@ -69,7 +69,7 @@ export function getHomeMenu({ modules?.vesting && vesting, modules?.scheduler && scheduler, modules?.whitelist && whitelist, - (modules?.proxy || hasMultisig) && Data, + (modules?.proxy || hasMultisig) && getDataMenu(), calendarMenu, votingSpace && votingMenu, navigationMenu, diff --git a/packages/next-common/utils/consts/settings/common/modules.js b/packages/next-common/utils/consts/settings/common/modules.js index 52aa39633b..ec01b85207 100644 --- a/packages/next-common/utils/consts/settings/common/modules.js +++ b/packages/next-common/utils/consts/settings/common/modules.js @@ -45,6 +45,7 @@ const base = { provider: "server", }, vesting: false, + recovery: false, }; /** diff --git a/packages/next-common/utils/consts/settings/westend/index.js b/packages/next-common/utils/consts/settings/westend/index.js index 0394764a47..0ddd7eb7a2 100644 --- a/packages/next-common/utils/consts/settings/westend/index.js +++ b/packages/next-common/utils/consts/settings/westend/index.js @@ -47,6 +47,7 @@ const westend = { vesting: true, staking: true, scheduler: true, + recovery: true, treasury: { spends: true, proposals: true, diff --git a/packages/next/pages/recovery/index.jsx b/packages/next/pages/recovery/index.jsx new file mode 100644 index 0000000000..9ae958a185 --- /dev/null +++ b/packages/next/pages/recovery/index.jsx @@ -0,0 +1,16 @@ +import { getServerSidePropsWithTracks } from "next-common/services/serverSide/serverSidePropsWithTracks"; +import { DataRecovery } from "next-common/components/data"; +import DataTabsProvider from "next-common/components/data/context/tabs"; +import { RelayChainApiProvider } from "next-common/context/relayChain"; + +export default function RecoveryPage() { + return ( + + + + + + ); +} + +export const getServerSideProps = getServerSidePropsWithTracks;