Skip to content
2 changes: 1 addition & 1 deletion packages/next-common/components/data/common/pageHeader.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export default function PageHeader({ href = "" }) {
<div className="w-full py-6 flex items-center justify-center">
<TitleContainer className="!text20Bold">
{title}
<PolkadotWikiLink href={href} />
{href && <PolkadotWikiLink href={href} />}
</TitleContainer>
</div>
);
Expand Down
8 changes: 8 additions & 0 deletions packages/next-common/components/data/context/tabs.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ function generateTabs() {
});
}

if (modules?.recovery) {
TABS.push({
tabId: "/recovery",
tabTitle: "Recovery",
pageTitle: "Recovery Explorer",
});
}

return TABS;
}

Expand Down
9 changes: 9 additions & 0 deletions packages/next-common/components/data/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -27,3 +28,11 @@ export function DataMultisig() {
</DataPageWithLayout>
);
}

export function DataRecovery() {
return (
<DataPageWithLayout>
<RecoveryExplorer />
</DataPageWithLayout>
);
}
Original file line number Diff line number Diff line change
@@ -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 };
}
Original file line number Diff line number Diff line change
@@ -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 };
}
85 changes: 85 additions & 0 deletions packages/next-common/components/data/recovery/index.jsx
Original file line number Diff line number Diff line change
@@ -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: (
<TabLabel
label="Friend Groups"
count={
friendGroupsLoading ? <Loading size={14} /> : friendGroupsTotal
}
isActive={activeTab === "friendGroups"}
/>
),
},
{
value: "attempts",
label: (
<TabLabel
label="Recovery Attempts"
count={attemptsLoading ? <Loading size={14} /> : attemptsTotal}
isActive={activeTab === "attempts"}
/>
),
},
];

return (
<>
<PageHeader />
<div className="flex w-full mb-4">
<TabsList
tabs={tabs}
activeTabValue={activeTab}
onTabClick={(tab) => setActiveTab(tab.value)}
/>
</div>
{activeTab === "friendGroups" ? (
<RecoveryFriendGroupsTable
data={friendGroupsData}
loading={friendGroupsLoading}
/>
) : (
<RecoveryAttemptsTable
data={attemptsData}
loading={attemptsLoading}
friendGroups={friendGroupsData}
/>
)}
</>
);
}
Loading
Loading