diff --git a/app/address/[address]/layout.tsx b/app/address/[address]/layout.tsx index bfb23cd3..13656727 100644 --- a/app/address/[address]/layout.tsx +++ b/app/address/[address]/layout.tsx @@ -24,6 +24,7 @@ import { AccountsProvider, isTokenProgramData, TokenProgramData, + UpgradeableLoaderAccountData, useAccountInfo, useFetchAccountInfo, useMintAccountInfo, @@ -49,6 +50,7 @@ import { Address } from 'web3js-experimental'; import { CompressedNftAccountHeader, CompressedNftCard } from '@/app/components/account/CompressedNftCard'; import { useCompressedNft, useMetadataJsonLink } from '@/app/providers/compressed-nft'; +import { useSquadsMultisigLookup } from '@/app/providers/squadsMultisig'; import { FullTokenInfo, getFullTokenInfo } from '@/app/utils/token-info'; import { MintAccountInfo } from '@/app/validators/accounts/token'; @@ -460,7 +462,7 @@ function DetailsSections({ } const account = info.data; - const tabComponents = getTabs(pubkey, account).concat(getAnchorTabs(pubkey, account)); + const tabComponents = getTabs(pubkey, account).concat(getCustomLinkedTabs(pubkey, account)); if (tab && tabComponents.filter(tabComponent => tabComponent.tab.slug === tab).length === 0) { redirect(`/address/${address}`); @@ -561,7 +563,8 @@ export type MoreTabs = | 'entries' | 'concurrent-merkle-tree' | 'compression' - | 'verified-build'; + | 'verified-build' + | 'program-multisig'; function MoreSection({ children, tabs }: { children: React.ReactNode; tabs: (JSX.Element | null)[] }) { return ( @@ -694,8 +697,26 @@ function Tab({ address, path, title }: { address: string; path: string; title: s ); } -function getAnchorTabs(pubkey: PublicKey, account: Account) { +function getCustomLinkedTabs(pubkey: PublicKey, account: Account) { const tabComponents = []; + const programMultisigTab: Tab = { + path: 'program-multisig', + slug: 'program-multisig', + title: 'Program Multisig', + }; + tabComponents.push({ + component: ( + }> + + + ), + tab: programMultisigTab, + }); + const anchorProgramTab: Tab = { path: 'anchor-program', slug: 'anchor-program', @@ -786,3 +807,31 @@ function CompressedNftLink({ tab, address, pubkey }: { tab: Tab; address: string ); } + +// Checks that a program multisig exists at the given address and returns a link to the tab +function ProgramMultisigLink({ + tab, + address, + authority, +}: { + tab: Tab; + address: string; + authority: PublicKey | null | undefined; +}) { + const { cluster } = useCluster(); + const { data: squadMapInfo, error } = useSquadsMultisigLookup(authority, cluster); + const tabPath = useClusterPath({ pathname: `/address/${address}/${tab.path}` }); + const selectedLayoutSegment = useSelectedLayoutSegment(); + const isActive = selectedLayoutSegment === tab.path; + if (!squadMapInfo || error || !squadMapInfo.isSquad) { + return null; + } + + return ( +
  • + + {tab.title} + +
  • + ); +} diff --git a/app/address/[address]/program-multisig/page-client.tsx b/app/address/[address]/program-multisig/page-client.tsx new file mode 100644 index 00000000..8e26f100 --- /dev/null +++ b/app/address/[address]/program-multisig/page-client.tsx @@ -0,0 +1,27 @@ +'use client'; + +import { ParsedAccountRenderer } from '@components/account/ParsedAccountRenderer'; +import React from 'react'; + +import { ProgramMultisigCard } from '@/app/components/account/ProgramMultisigCard'; + +type Props = Readonly<{ + params: { + address: string; + }; +}>; + +function ProgramMultisigCardRenderer({ + account, + onNotFound, +}: React.ComponentProps['renderComponent']>) { + const parsedData = account?.data?.parsed; + if (!parsedData || parsedData?.program !== 'bpf-upgradeable-loader') { + return onNotFound(); + } + return ; +} + +export default function ProgramMultisigPageClient({ params: { address } }: Props) { + return ; +} diff --git a/app/address/[address]/program-multisig/page.tsx b/app/address/[address]/program-multisig/page.tsx new file mode 100644 index 00000000..a7af2418 --- /dev/null +++ b/app/address/[address]/program-multisig/page.tsx @@ -0,0 +1,21 @@ +import getReadableTitleFromAddress, { AddressPageMetadataProps } from '@utils/get-readable-title-from-address'; +import { Metadata } from 'next/types'; + +import ProgramMultisigPageClient from './page-client'; + +export async function generateMetadata(props: AddressPageMetadataProps): Promise { + return { + description: `Multisig information for the upgrade authority of the program with address ${props.params.address} on Solana`, + title: `Upgrade Authority Multisig | ${await getReadableTitleFromAddress(props)} | Solana`, + }; +} + +type Props = Readonly<{ + params: { + address: string; + }; +}>; + +export default function ProgramMultisigPage(props: Props) { + return ; +} diff --git a/app/address/[address]/verified-build/page-client.tsx b/app/address/[address]/verified-build/page-client.tsx index 89ab035d..cd09a5c0 100644 --- a/app/address/[address]/verified-build/page-client.tsx +++ b/app/address/[address]/verified-build/page-client.tsx @@ -22,6 +22,6 @@ function VerifiedBuildCardRenderer({ return ; } -export default function SecurityPageClient({ params: { address } }: Props) { +export default function VerifiedBuildPageClient({ params: { address } }: Props) { return ; } diff --git a/app/components/account/ProgramMultisigCard.tsx b/app/components/account/ProgramMultisigCard.tsx new file mode 100644 index 00000000..bcf7c0b1 --- /dev/null +++ b/app/components/account/ProgramMultisigCard.tsx @@ -0,0 +1,97 @@ +import { PublicKey } from '@solana/web3.js'; +import { Suspense } from 'react'; + +import { UpgradeableLoaderAccountData } from '@/app/providers/accounts'; +import { useAnchorProgram } from '@/app/providers/anchor'; +import { useCluster } from '@/app/providers/cluster'; +import { + SQUADS_V3_ADDRESS, + SQUADS_V4_ADDRESS, + useSquadsMultisig, + useSquadsMultisigLookup, +} from '@/app/providers/squadsMultisig'; + +import { Address } from '../common/Address'; +import { LoadingCard } from '../common/LoadingCard'; +import { TableCardBody } from '../common/TableCardBody'; + +export function ProgramMultisigCard({ data }: { data: UpgradeableLoaderAccountData }) { + return ( + }> + + + ); +} + +function ProgramMultisigCardInner({ programAuthority }: { programAuthority: PublicKey | null | undefined }) { + const { cluster, url } = useCluster(); + const { data: squadMapInfo } = useSquadsMultisigLookup(programAuthority, cluster); + const anchorProgram = useAnchorProgram(squadMapInfo?.version === 'v3' ? SQUADS_V3_ADDRESS : SQUADS_V4_ADDRESS, url); + const { data: squadInfo } = useSquadsMultisig( + anchorProgram.program, + squadMapInfo?.multisig, + cluster, + squadMapInfo?.version + ); + + let members: PublicKey[]; + if (squadInfo !== undefined && squadInfo?.version === 'v4') { + members = squadInfo.multisig.members.map(obj => obj.key) ?? []; + } else { + members = squadInfo?.multisig.keys ?? []; + } + + return ( +
    +
    +

    + Upgrade Authority Multisig Information +

    +
    + + + Multisig Program + {squadMapInfo?.version === 'v4' ? 'Squads V4' : 'Squads V3'} + + + Multisig Program Id + +
    + + + + Multisig Account + + {squadMapInfo?.isSquad ? ( +
    + ) : null} + + + + Multisig Approval Threshold + + {squadInfo?.multisig.threshold} + {' of '} + {squadInfo?.version === 'v4' + ? squadInfo?.multisig.members.length + : squadInfo?.multisig.keys.length} + + + {members.map((member, idx) => ( + + Multisig Member {idx + 1} + +
    + + + ))} + +
    + ); +} diff --git a/app/components/account/UpgradeableLoaderAccountSection.tsx b/app/components/account/UpgradeableLoaderAccountSection.tsx index 03623d0f..5c4b2af5 100644 --- a/app/components/account/UpgradeableLoaderAccountSection.tsx +++ b/app/components/account/UpgradeableLoaderAccountSection.tsx @@ -8,6 +8,7 @@ import { SolBalance } from '@components/common/SolBalance'; import { TableCardBody } from '@components/common/TableCardBody'; import { Account, useFetchAccountInfo } from '@providers/accounts'; import { useCluster } from '@providers/cluster'; +import { PublicKey } from '@solana/web3.js'; import { addressLabel } from '@utils/tx'; import { ProgramAccountInfo, @@ -19,6 +20,10 @@ import Link from 'next/link'; import React from 'react'; import { ExternalLink, RefreshCw } from 'react-feather'; +import { useSquadsMultisigLookup } from '@/app/providers/squadsMultisig'; +import { Cluster } from '@/app/utils/cluster'; +import { useClusterPath } from '@/app/utils/url'; + import { VerifiedProgramBadge } from '../common/VerifiedProgramBadge'; export function UpgradeableLoaderAccountSection({ @@ -63,7 +68,10 @@ export function UpgradeableProgramSection({ }) { const refresh = useFetchAccountInfo(); const { cluster } = useCluster(); + const { data: squadMapInfo } = useSquadsMultisigLookup(programData?.authority, cluster); + const label = addressLabel(account.pubkey.toBase58(), cluster); + return (
    @@ -134,12 +142,17 @@ export function UpgradeableProgramSection({ {programData.authority !== null && ( - - Upgrade Authority - -
    - - + <> + + Upgrade Authority + + {cluster == Cluster.MainnetBeta && squadMapInfo?.isSquad ? ( + + ) : null} +
    + + + )} )} @@ -148,6 +161,17 @@ export function UpgradeableProgramSection({ ); } +function MultisigBadge({ pubkey }: { pubkey: PublicKey }) { + const programMultisigTabPath = useClusterPath({ pathname: `/address/${pubkey.toBase58()}/program-multisig` }); + return ( +

    + + Program Multisig + +

    + ); +} + function SecurityLabel() { return ( diff --git a/app/providers/squadsMultisig.tsx b/app/providers/squadsMultisig.tsx new file mode 100644 index 00000000..724f07cf --- /dev/null +++ b/app/providers/squadsMultisig.tsx @@ -0,0 +1,69 @@ +import { Program } from '@coral-xyz/anchor'; +import { PublicKey } from '@solana/web3.js'; +import { Cluster } from '@utils/cluster'; +import useSWRImmutable from 'swr/immutable'; + +export const SQUADS_V3_ADDRESS = 'SMPLecH534NA9acpos4G6x7uf3LWbCAwZQE9e8ZekMu'; +export const SQUADS_V4_ADDRESS = 'SQDS4ep65T869zMMBKyuUq6aD6EgTu8psMjkvj52pCf'; + +export type SquadsMultisigVersion = 'v3' | 'v4'; +export type SquadsMultisigMapInfo = { + isSquad: boolean; + version: SquadsMultisigVersion; + multisig: string; +}; +export type MinimalMultisigInfo = + | { version: 'v4'; multisig: { threshold: number; members: Array<{ key: PublicKey }> } } + | { version: 'v3'; multisig: { threshold: number; keys: Array } }; + +const SQUADS_MAP_URL = 'https://4fnetmviidiqkjzenwxe66vgoa0soerr.lambda-url.us-east-1.on.aws/isSquadV2'; + +// Squads Multisig reverse map info is only available on mainnet +export function useSquadsMultisigLookup(programAuthority: PublicKey | null | undefined, cluster: Cluster) { + return useSWRImmutable( + ['squadsReverseMap', programAuthority?.toString(), cluster], + async ([_prefix, programIdString, cluster]: [string, string | undefined, Cluster]) => { + if (cluster !== Cluster.MainnetBeta || !programIdString) { + return null; + } + const response = await fetch(`${SQUADS_MAP_URL}/${programIdString}`); + const data = await response.json(); + return 'error' in data ? null : (data as SquadsMultisigMapInfo); + }, + { suspense: true } + ); +} + +export function useSquadsMultisig( + anchorProgram: Program | null | undefined, + multisig: string | undefined, + cluster: Cluster, + version: SquadsMultisigVersion | undefined +) { + return useSWRImmutable( + ['squadsMultisig', multisig, cluster], + async ([_prefix, multisig, cluster]: [string, string | undefined, Cluster]) => { + if (cluster !== Cluster.MainnetBeta || !multisig || !version) { + return null; + } + if (version === 'v4') { + const multisigInfo = await (anchorProgram?.account as unknown as any).multisig.fetch( + multisig, + 'confirmed' + ); + return { + multisig: multisigInfo, + version, + }; + } else if (version === 'v3') { + const multisigInfo = await (anchorProgram?.account as unknown as any).ms.fetch(multisig, 'confirmed'); + return { + multisig: multisigInfo, + version, + }; + } else { + return null; + } + } + ); +} diff --git a/package.json b/package.json index 2b186482..a699544a 100644 --- a/package.json +++ b/package.json @@ -104,5 +104,6 @@ "node-fetch": "^2.6.9", "uuid": "^9.0.0" } - } + }, + "packageManager": "pnpm@9.10.0+sha512.73a29afa36a0d092ece5271de5177ecbf8318d454ecd701343131b8ebc0c1a91c487da46ab77c8e596d6acf1461e3594ced4becedf8921b074fbd8653ed7051c" }