Преглед на файлове

[xc-admin] add proposals page (#570)

* add proposals page

* address feedback

* show program id for unknown ix

* unwrap wormhole ixs

* fix error

* address feedback

* address comments
Daniel Chew преди 2 години
родител
ревизия
a478d9f8cc

+ 16 - 0
governance/xc_admin/packages/xc_admin_common/src/cluster.ts

@@ -21,3 +21,19 @@ export function getMultisigCluster(cluster: PythCluster): Cluster | "localnet" {
       return cluster;
   }
 }
+
+/**
+ * For cluster that are governed remotely (ex : Pythnet from Mainnet) return the network of the remote cluster
+ */
+export function getRemoteCluster(
+  cluster: PythCluster
+): PythCluster | "localnet" {
+  switch (cluster) {
+    case "devnet":
+      return "pythtest";
+    case "mainnet-beta":
+      return "pythnet";
+    default:
+      return cluster;
+  }
+}

+ 780 - 0
governance/xc_admin/packages/xc_admin_frontend/components/tabs/Proposals.tsx

@@ -0,0 +1,780 @@
+import { BN } from '@coral-xyz/anchor'
+import { useWallet } from '@solana/wallet-adapter-react'
+import { AccountMeta, PublicKey } from '@solana/web3.js'
+import { getIxPDA } from '@sqds/mesh'
+import { MultisigAccount, TransactionAccount } from '@sqds/mesh/lib/types'
+import copy from 'copy-to-clipboard'
+import { useRouter } from 'next/router'
+import {
+  Dispatch,
+  SetStateAction,
+  useCallback,
+  useContext,
+  useEffect,
+  useState,
+} from 'react'
+import {
+  ExecutePostedVaa,
+  getMultisigCluster,
+  getRemoteCluster,
+  MultisigInstruction,
+  MultisigParser,
+  PythMultisigInstruction,
+  UnrecognizedProgram,
+  WormholeMultisigInstruction,
+} from 'xc_admin_common'
+import { ClusterContext } from '../../contexts/ClusterContext'
+import { useMultisigContext } from '../../contexts/MultisigContext'
+import CopyIcon from '../../images/icons/copy.inline.svg'
+import ClusterSwitch from '../ClusterSwitch'
+import Loadbar from '../loaders/Loadbar'
+
+const ProposalRow = ({
+  proposal,
+  setCurrentProposalPubkey,
+}: {
+  proposal: TransactionAccount
+  setCurrentProposalPubkey: Dispatch<SetStateAction<string | undefined>>
+}) => {
+  const status = Object.keys(proposal.status)[0]
+
+  const router = useRouter()
+
+  const handleClickIndividualProposal = useCallback(
+    (proposalPubkey: string) => {
+      router.query.proposal = proposalPubkey
+      setCurrentProposalPubkey(proposalPubkey)
+      router.push(
+        {
+          pathname: router.pathname,
+          query: router.query,
+        },
+        undefined,
+        { scroll: false }
+      )
+    },
+    [setCurrentProposalPubkey, router]
+  )
+  return (
+    <div
+      className="my-2 max-h-[58px] cursor-pointer bg-[#1E1B2F] hover:bg-darkGray2"
+      onClick={() =>
+        handleClickIndividualProposal(proposal.publicKey.toBase58())
+      }
+    >
+      <div className="flex justify-between p-4">
+        <div>{proposal.publicKey.toBase58()}</div>
+        <div
+          className={
+            status === 'active'
+              ? 'text-[#E6DAFE]'
+              : status === 'executed'
+              ? 'text-[#1FC3D7]'
+              : status === 'cancelled'
+              ? 'text-[#FFA7A0]'
+              : status === 'rejected'
+              ? 'text-[#F86B86]'
+              : ''
+          }
+        >
+          <strong>{status}</strong>
+        </div>
+      </div>
+    </div>
+  )
+}
+
+const SignerTag = () => {
+  return (
+    <div className="flex items-center justify-center rounded-full bg-darkGray4 py-1 px-2 text-xs">
+      Signer
+    </div>
+  )
+}
+
+const WritableTag = () => {
+  return (
+    <div className="flex items-center justify-center rounded-full bg-offPurple py-1 px-2 text-xs">
+      Writable
+    </div>
+  )
+}
+
+const Proposal = ({
+  proposal,
+  multisig,
+}: {
+  proposal: TransactionAccount | undefined
+  multisig: MultisigAccount | undefined
+}) => {
+  const [proposalInstructions, setProposalInstructions] = useState<
+    MultisigInstruction[]
+  >([])
+  const [isProposalInstructionsLoading, setIsProposalInstructionsLoading] =
+    useState(false)
+  const { cluster } = useContext(ClusterContext)
+  const { squads, isLoading: isMultisigLoading } = useMultisigContext()
+
+  useEffect(() => {
+    const fetchProposalInstructions = async () => {
+      const multisigParser = MultisigParser.fromCluster(
+        getMultisigCluster(cluster)
+      )
+      if (squads && proposal) {
+        setIsProposalInstructionsLoading(true)
+        const proposalIxs = []
+        for (let i = 1; i <= proposal.instructionIndex; i++) {
+          const instructionPda = getIxPDA(
+            proposal.publicKey,
+            new BN(i),
+            squads.multisigProgramId
+          )[0]
+          const instruction = await squads.getInstruction(instructionPda)
+          const parsedInstruction = multisigParser.parseInstruction({
+            programId: instruction.programId,
+            data: instruction.data as Buffer,
+            keys: instruction.keys as AccountMeta[],
+          })
+          proposalIxs.push(parsedInstruction)
+        }
+        setProposalInstructions(proposalIxs)
+        setIsProposalInstructionsLoading(false)
+      }
+    }
+
+    fetchProposalInstructions()
+  }, [proposal, squads, cluster])
+
+  return proposal !== undefined &&
+    multisig !== undefined &&
+    !isMultisigLoading &&
+    !isProposalInstructionsLoading ? (
+    <div className="grid grid-cols-3 gap-4">
+      <div className="col-span-3 my-2 space-y-4 bg-[#1E1B2F] p-4 lg:col-span-2">
+        <h4 className="h4 font-semibold">Info</h4>
+        <hr className="border-gray-700" />
+        <div className="flex justify-between">
+          <div>Proposal</div>
+          <div>{proposal.publicKey.toBase58()}</div>
+        </div>
+        <div className="flex justify-between">
+          <div>Creator</div>
+          <div>{proposal.creator.toBase58()}</div>
+        </div>
+        <div className="flex justify-between">
+          <div>Multisig</div>
+          <div>{proposal.ms.toBase58()}</div>
+        </div>
+      </div>
+      <div className="col-span-3 my-2 space-y-4 bg-[#1E1B2F] p-4 lg:col-span-1">
+        <h4 className="h4 mb-4 font-semibold">Results</h4>
+        <hr className="border-gray-700" />
+        <div className="grid grid-cols-3 justify-center gap-4 pt-5 text-center align-middle">
+          <div>
+            <div className="font-bold">Confirmed</div>
+            <div className="text-lg">{proposal.approved.length}</div>
+          </div>
+          <div>
+            <div className="font-bold">Cancelled</div>
+            <div className="text-lg">{proposal.cancelled.length}</div>
+          </div>
+          <div>
+            <div className="font-bold">Threshold</div>
+            <div className="text-lg">
+              {multisig.threshold}/{multisig.keys.length}
+            </div>
+          </div>
+        </div>
+      </div>
+      <div className="col-span-3 my-2 space-y-4 bg-[#1E1B2F] p-4">
+        <h4 className="h4 font-semibold">Instructions</h4>
+        <hr className="border-gray-700" />
+        {proposalInstructions?.map((instruction, index) => (
+          <>
+            <h4 className="h4 text-[20px] font-semibold">
+              Instruction {index + 1}
+            </h4>
+            <div
+              key={`${index}_instructionType`}
+              className="flex justify-between"
+            >
+              <div>Program</div>
+              <div>
+                {instruction instanceof PythMultisigInstruction
+                  ? 'Pyth Oracle'
+                  : instruction instanceof WormholeMultisigInstruction
+                  ? 'Wormhole'
+                  : 'Unknown'}
+              </div>
+            </div>
+            {instruction instanceof PythMultisigInstruction ||
+            instruction instanceof WormholeMultisigInstruction ? (
+              <div
+                key={`${index}_instructionName`}
+                className="flex justify-between"
+              >
+                <div>Instruction Name</div>
+                <div>{instruction.name}</div>
+              </div>
+            ) : null}
+            {instruction instanceof WormholeMultisigInstruction &&
+            instruction.governanceAction ? (
+              <>
+                <div
+                  key={`${index}_targetChain`}
+                  className="flex justify-between"
+                >
+                  <div>Target Chain</div>
+                  <div>
+                    {instruction.governanceAction.targetChainId === 'pythnet' &&
+                    cluster === 'devnet'
+                      ? 'pythtest'
+                      : 'pythnet'}
+                  </div>
+                </div>
+              </>
+            ) : null}
+            {instruction instanceof WormholeMultisigInstruction ||
+            instruction instanceof UnrecognizedProgram ? null : (
+              <div
+                key={`${index}_arguments`}
+                className="grid grid-cols-4 justify-between"
+              >
+                <div>Arguments</div>
+                {instruction instanceof PythMultisigInstruction ? (
+                  Object.keys(instruction.args).length > 0 ? (
+                    <div className="col-span-4 mt-2 bg-darkGray2 p-4 lg:col-span-3 lg:mt-0">
+                      <div className="base16 flex justify-between pt-2 pb-6 font-semibold opacity-60">
+                        <div>Key</div>
+                        <div>Value</div>
+                      </div>
+                      {Object.keys(instruction.args).map((key, index) => (
+                        <div
+                          key={index}
+                          className="flex justify-between border-t border-beige-300 py-3"
+                        >
+                          <div>{key}</div>
+                          <div className="max-w-sm break-all">
+                            {instruction.args[key] instanceof PublicKey
+                              ? instruction.args[key].toBase58()
+                              : typeof instruction.args[key] === 'string'
+                              ? instruction.args[key]
+                              : instruction.args[key] instanceof Uint8Array
+                              ? instruction.args[key].toString('hex')
+                              : JSON.stringify(instruction.args[key])}
+                          </div>
+                        </div>
+                      ))}
+                    </div>
+                  ) : (
+                    <div className="col-span-3 text-right">No arguments</div>
+                  )
+                ) : (
+                  <div className="col-span-3 text-right">Unknown</div>
+                )}
+              </div>
+            )}
+            {instruction instanceof PythMultisigInstruction ? (
+              <div
+                key={`${index}_accounts`}
+                className="grid grid-cols-4 justify-between"
+              >
+                <div>Accounts</div>
+                {Object.keys(instruction.accounts.named).length > 0 ? (
+                  <div className="col-span-4 mt-2 bg-darkGray2 p-4 lg:col-span-3 lg:mt-0">
+                    <div className="base16 flex justify-between pt-2 pb-6 font-semibold opacity-60">
+                      <div>Account</div>
+                      <div>Pubkey</div>
+                    </div>
+                    {Object.keys(instruction.accounts.named).map(
+                      (key, index) => (
+                        <>
+                          <div
+                            key={index}
+                            className="flex justify-between border-t border-beige-300 py-3"
+                          >
+                            <div>{key}</div>
+                            <div className="flex space-x-2">
+                              {instruction.accounts.named[key].isSigner ? (
+                                <SignerTag />
+                              ) : null}
+                              {instruction.accounts.named[key].isWritable ? (
+                                <WritableTag />
+                              ) : null}
+                              <div
+                                className="-ml-1 inline-flex cursor-pointer items-center px-1 hover:bg-dark hover:text-white active:bg-darkGray3"
+                                onClick={() => {
+                                  copy(
+                                    instruction.accounts.named[
+                                      key
+                                    ].pubkey.toBase58()
+                                  )
+                                }}
+                              >
+                                <span className="mr-2 hidden xl:block">
+                                  {instruction.accounts.named[
+                                    key
+                                  ].pubkey.toBase58()}
+                                </span>
+                                <span className="mr-2 xl:hidden">
+                                  {instruction.accounts.named[key].pubkey
+                                    .toBase58()
+                                    .slice(0, 6) +
+                                    '...' +
+                                    instruction.accounts.named[key].pubkey
+                                      .toBase58()
+                                      .slice(-6)}
+                                </span>{' '}
+                                <CopyIcon className="shrink-0" />
+                              </div>
+                            </div>
+                          </div>
+                        </>
+                      )
+                    )}
+                  </div>
+                ) : (
+                  <div>No arguments</div>
+                )}
+              </div>
+            ) : instruction instanceof UnrecognizedProgram ? (
+              <>
+                <div
+                  key={`${index}_programId`}
+                  className="flex justify-between"
+                >
+                  <div>Program ID</div>
+                  <div>{instruction.instruction.programId.toBase58()}</div>
+                </div>
+                <div key={`${index}_data`} className="flex justify-between">
+                  <div>Data</div>
+                  <div>
+                    {instruction.instruction.data.length > 0
+                      ? instruction.instruction.data.toString('hex')
+                      : 'No data'}
+                  </div>
+                </div>
+                <div
+                  key={`${index}_keys`}
+                  className="grid grid-cols-4 justify-between"
+                >
+                  <div>Keys</div>
+                  <div className="col-span-4 mt-2 bg-darkGray2 p-4 lg:col-span-3 lg:mt-0">
+                    <div className="base16 flex justify-between pt-2 pb-6 font-semibold opacity-60">
+                      <div>Key #</div>
+                      <div>Pubkey</div>
+                    </div>
+                    {instruction.instruction.keys.map((key, index) => (
+                      <>
+                        <div
+                          key={index}
+                          className="flex justify-between border-t border-beige-300 py-3"
+                        >
+                          <div>Key {index + 1}</div>
+                          <div className="flex space-x-2">
+                            {key.isSigner ? <SignerTag /> : null}
+                            {key.isWritable ? <WritableTag /> : null}
+                            <div
+                              className="-ml-1 inline-flex cursor-pointer items-center px-1 hover:bg-dark hover:text-white active:bg-darkGray3"
+                              onClick={() => {
+                                copy(key.pubkey.toBase58())
+                              }}
+                            >
+                              <span className="mr-2 hidden xl:block">
+                                {key.pubkey.toBase58()}
+                              </span>
+                              <span className="mr-2 xl:hidden">
+                                {key.pubkey.toBase58().slice(0, 6) +
+                                  '...' +
+                                  key.pubkey.toBase58().slice(-6)}
+                              </span>{' '}
+                              <CopyIcon className="shrink-0" />
+                            </div>
+                          </div>
+                        </div>
+                      </>
+                    ))}
+                  </div>
+                </div>
+              </>
+            ) : null}
+            {instruction instanceof WormholeMultisigInstruction ? (
+              <div className="col-span-4 my-2 space-y-4 bg-darkGray2 p-4 lg:col-span-3">
+                <h4 className="h4">Wormhole Instructions</h4>
+                <hr className="border-[#E6DAFE] opacity-30" />
+                {instruction.governanceAction instanceof ExecutePostedVaa
+                  ? instruction.governanceAction.instructions.map(
+                      (innerInstruction, index) => {
+                        const multisigParser = MultisigParser.fromCluster(
+                          getRemoteCluster(cluster)
+                        )
+                        const parsedInstruction =
+                          multisigParser.parseInstruction({
+                            programId: innerInstruction.programId,
+                            data: innerInstruction.data as Buffer,
+                            keys: innerInstruction.keys as AccountMeta[],
+                          })
+                        return (
+                          <>
+                            <div
+                              key={`${index}_program`}
+                              className="flex justify-between"
+                            >
+                              <div>Program</div>
+                              <div>
+                                {parsedInstruction instanceof
+                                PythMultisigInstruction
+                                  ? 'Pyth Oracle'
+                                  : innerInstruction instanceof
+                                    WormholeMultisigInstruction
+                                  ? 'Wormhole'
+                                  : 'Unknown'}
+                              </div>
+                            </div>
+                            <div
+                              key={`${index}_instructionName`}
+                              className="flex justify-between"
+                            >
+                              <div>Instruction Name</div>
+                              <div>
+                                {parsedInstruction instanceof
+                                  PythMultisigInstruction ||
+                                parsedInstruction instanceof
+                                  WormholeMultisigInstruction
+                                  ? parsedInstruction.name
+                                  : 'Unknown'}
+                              </div>
+                            </div>
+                            <div
+                              key={`${index}_arguments`}
+                              className="grid grid-cols-4 justify-between"
+                            >
+                              <div>Arguments</div>
+                              {parsedInstruction instanceof
+                                PythMultisigInstruction ||
+                              parsedInstruction instanceof
+                                WormholeMultisigInstruction ? (
+                                Object.keys(parsedInstruction.args).length >
+                                0 ? (
+                                  <div className="col-span-4 mt-2 bg-[#444157] p-4 lg:col-span-3 lg:mt-0">
+                                    <div className="base16 flex justify-between pt-2 pb-6 font-semibold opacity-60">
+                                      <div>Key</div>
+                                      <div>Value</div>
+                                    </div>
+                                    {Object.keys(parsedInstruction.args).map(
+                                      (key, index) => (
+                                        <div
+                                          key={index}
+                                          className="flex justify-between border-t border-beige-300 py-3"
+                                        >
+                                          <div>{key}</div>
+                                          <div className="max-w-sm break-all">
+                                            {parsedInstruction.args[
+                                              key
+                                            ] instanceof PublicKey
+                                              ? parsedInstruction.args[
+                                                  key
+                                                ].toBase58()
+                                              : typeof parsedInstruction.args[
+                                                  key
+                                                ] === 'string'
+                                              ? parsedInstruction.args[key]
+                                              : parsedInstruction.args[
+                                                  key
+                                                ] instanceof Uint8Array
+                                              ? parsedInstruction.args[
+                                                  key
+                                                ].toString('hex')
+                                              : JSON.stringify(
+                                                  parsedInstruction.args[key]
+                                                )}
+                                          </div>
+                                        </div>
+                                      )
+                                    )}
+                                  </div>
+                                ) : (
+                                  <div className="col-span-3 text-right">
+                                    No arguments
+                                  </div>
+                                )
+                              ) : (
+                                <div className="col-span-3 text-right">
+                                  Unknown
+                                </div>
+                              )}
+                            </div>
+                            {parsedInstruction instanceof
+                              PythMultisigInstruction ||
+                            parsedInstruction instanceof
+                              WormholeMultisigInstruction ? (
+                              <div
+                                key={`${index}_accounts`}
+                                className="grid grid-cols-4 justify-between"
+                              >
+                                <div>Accounts</div>
+                                {Object.keys(parsedInstruction.accounts.named)
+                                  .length > 0 ? (
+                                  <div className="col-span-4 mt-2 bg-[#444157] p-4 lg:col-span-3 lg:mt-0">
+                                    <div className="base16 flex justify-between pt-2 pb-6 font-semibold opacity-60">
+                                      <div>Account</div>
+                                      <div>Pubkey</div>
+                                    </div>
+                                    {Object.keys(
+                                      parsedInstruction.accounts.named
+                                    ).map((key, index) => (
+                                      <>
+                                        <div
+                                          key={index}
+                                          className="flex justify-between border-t border-beige-300 py-3"
+                                        >
+                                          <div>{key}</div>
+                                          <div className="flex space-x-2">
+                                            {parsedInstruction.accounts.named[
+                                              key
+                                            ].isSigner ? (
+                                              <SignerTag />
+                                            ) : null}
+                                            {parsedInstruction.accounts.named[
+                                              key
+                                            ].isWritable ? (
+                                              <WritableTag />
+                                            ) : null}
+                                            <div
+                                              className="-ml-1 inline-flex cursor-pointer items-center px-1 hover:bg-dark hover:text-white active:bg-darkGray3"
+                                              onClick={() => {
+                                                copy(
+                                                  parsedInstruction.accounts.named[
+                                                    key
+                                                  ].pubkey.toBase58()
+                                                )
+                                              }}
+                                            >
+                                              <span className="mr-2 hidden xl:block">
+                                                {parsedInstruction.accounts.named[
+                                                  key
+                                                ].pubkey.toBase58()}
+                                              </span>
+                                              <span className="mr-2 xl:hidden">
+                                                {parsedInstruction.accounts.named[
+                                                  key
+                                                ].pubkey
+                                                  .toBase58()
+                                                  .slice(0, 6) +
+                                                  '...' +
+                                                  parsedInstruction.accounts.named[
+                                                    key
+                                                  ].pubkey
+                                                    .toBase58()
+                                                    .slice(-6)}
+                                              </span>{' '}
+                                              <CopyIcon className="shrink-0" />
+                                            </div>
+                                          </div>
+                                        </div>
+                                      </>
+                                    ))}
+                                  </div>
+                                ) : (
+                                  <div>No arguments</div>
+                                )}
+                              </div>
+                            ) : parsedInstruction instanceof
+                              UnrecognizedProgram ? (
+                              <>
+                                <div
+                                  key={`${index}_programId`}
+                                  className="flex justify-between"
+                                >
+                                  <div>Program ID</div>
+                                  <div>
+                                    {parsedInstruction.instruction.programId.toBase58()}
+                                  </div>
+                                </div>
+                                <div
+                                  key={`${index}_data`}
+                                  className="flex justify-between"
+                                >
+                                  <div>Data</div>
+                                  <div>
+                                    {parsedInstruction.instruction.data.length >
+                                    0
+                                      ? parsedInstruction.instruction.data.toString(
+                                          'hex'
+                                        )
+                                      : 'No data'}
+                                  </div>
+                                </div>
+                                <div
+                                  key={`${index}_keys`}
+                                  className="grid grid-cols-4 justify-between"
+                                >
+                                  <div>Keys</div>
+                                  <div className="col-span-4 mt-2 bg-darkGray4 p-4 lg:col-span-3 lg:mt-0">
+                                    <div className="base16 flex justify-between pt-2 pb-6 font-semibold opacity-60">
+                                      <div>Key #</div>
+                                      <div>Pubkey</div>
+                                    </div>
+                                    {parsedInstruction.instruction.keys.map(
+                                      (key, index) => (
+                                        <>
+                                          <div
+                                            key={index}
+                                            className="flex justify-between border-t border-beige-300 py-3"
+                                          >
+                                            <div>Key {index + 1}</div>
+                                            <div className="flex space-x-2">
+                                              {key.isSigner ? (
+                                                <SignerTag />
+                                              ) : null}
+                                              {key.isWritable ? (
+                                                <WritableTag />
+                                              ) : null}
+                                              <div
+                                                className="-ml-1 inline-flex cursor-pointer items-center px-1 hover:bg-dark hover:text-white active:bg-darkGray3"
+                                                onClick={() => {
+                                                  copy(key.pubkey.toBase58())
+                                                }}
+                                              >
+                                                <span className="mr-2 hidden xl:block">
+                                                  {key.pubkey.toBase58()}
+                                                </span>
+                                                <span className="mr-2 xl:hidden">
+                                                  {key.pubkey
+                                                    .toBase58()
+                                                    .slice(0, 6) +
+                                                    '...' +
+                                                    key.pubkey
+                                                      .toBase58()
+                                                      .slice(-6)}
+                                                </span>{' '}
+                                                <CopyIcon className="shrink-0" />
+                                              </div>
+                                            </div>
+                                          </div>
+                                        </>
+                                      )
+                                    )}
+                                  </div>
+                                </div>
+                              </>
+                            ) : null}
+                          </>
+                        )
+                      }
+                    )
+                  : ''}
+              </div>
+            ) : null}
+
+            {index !== proposalInstructions.length - 1 ? (
+              <hr className="border-gray-700" />
+            ) : null}
+          </>
+        ))}
+      </div>
+    </div>
+  ) : (
+    <div className="mt-6">
+      <Loadbar theme="light" />
+    </div>
+  )
+}
+
+const Proposals = () => {
+  const router = useRouter()
+  const [currentProposal, setCurrentProposal] = useState<TransactionAccount>()
+  const [currentProposalPubkey, setCurrentProposalPubkey] = useState<string>()
+  const {
+    securityMultisigAccount,
+    securityMultisigProposals,
+    isLoading: isMultisigLoading,
+  } = useMultisigContext()
+  const { connected } = useWallet()
+
+  const handleClickBackToPriceFeeds = () => {
+    delete router.query.proposal
+    router.push(
+      {
+        pathname: router.pathname,
+        query: router.query,
+      },
+      undefined,
+      { scroll: false }
+    )
+  }
+
+  useEffect(() => {
+    if (router.query.proposal) {
+      setCurrentProposalPubkey(router.query.proposal as string)
+    }
+  }, [router.query.proposal])
+
+  useEffect(() => {
+    if (currentProposalPubkey) {
+      const currentProposal = securityMultisigProposals.find(
+        (proposal) => proposal.publicKey.toBase58() === currentProposalPubkey
+      )
+      setCurrentProposal(currentProposal)
+    }
+  }, [currentProposalPubkey, securityMultisigProposals])
+
+  return (
+    <div className="relative">
+      <div className="container flex flex-col items-center justify-between lg:flex-row">
+        <div className="mb-4 w-full text-left lg:mb-0">
+          <h1 className="h1 mb-4">
+            {router.query.proposal === undefined ? 'Proposals' : 'Proposal'}
+          </h1>
+        </div>
+      </div>
+      <div className="container min-h-[50vh]">
+        {router.query.proposal === undefined ? (
+          <>
+            <div className="flex justify-between">
+              <div className="mb-4 md:mb-0">
+                <ClusterSwitch />
+              </div>
+            </div>
+            <div className="relative mt-6">
+              {isMultisigLoading ? (
+                <div className="mt-3">
+                  <Loadbar theme="light" />
+                </div>
+              ) : securityMultisigProposals.length > 0 ? (
+                <div className="flex flex-col">
+                  {securityMultisigProposals.map((proposal, idx) => (
+                    <ProposalRow
+                      key={idx}
+                      proposal={proposal}
+                      setCurrentProposalPubkey={setCurrentProposalPubkey}
+                    />
+                  ))}
+                </div>
+              ) : (
+                "No proposals found. If you're a member of the security multisig, you can create a proposal."
+              )}
+            </div>
+          </>
+        ) : (
+          <>
+            <div
+              className="max-w-fit cursor-pointer bg-darkGray2 p-3 text-xs font-semibold outline-none transition-colors hover:bg-darkGray3 md:text-base"
+              onClick={handleClickBackToPriceFeeds}
+            >
+              &#8592; back to proposals
+            </div>
+            <div className="relative mt-6">
+              <Proposal
+                proposal={currentProposal}
+                multisig={securityMultisigAccount}
+              />
+            </div>
+          </>
+        )}
+      </div>
+    </div>
+  )
+}
+
+export default Proposals

+ 31 - 8
governance/xc_admin/packages/xc_admin_frontend/contexts/MultisigContext.tsx

@@ -1,7 +1,7 @@
 import { Wallet } from '@coral-xyz/anchor'
 import { useAnchorWallet } from '@solana/wallet-adapter-react'
 import SquadsMesh from '@sqds/mesh'
-import { TransactionAccount } from '@sqds/mesh/lib/types'
+import { MultisigAccount, TransactionAccount } from '@sqds/mesh/lib/types'
 import React, { createContext, useContext, useMemo } from 'react'
 import { useMultisig } from '../hooks/useMultisig'
 
@@ -10,11 +10,17 @@ interface MultisigContextProps {
   isLoading: boolean
   error: any // TODO: fix any
   squads: SquadsMesh | undefined
-  proposals: TransactionAccount[]
+  upgradeMultisigAccount: MultisigAccount | undefined
+  securityMultisigAccount: MultisigAccount | undefined
+  upgradeMultisigProposals: TransactionAccount[]
+  securityMultisigProposals: TransactionAccount[]
 }
 
 const MultisigContext = createContext<MultisigContextProps>({
-  proposals: [],
+  upgradeMultisigAccount: undefined,
+  securityMultisigAccount: undefined,
+  upgradeMultisigProposals: [],
+  securityMultisigProposals: [],
   isLoading: true,
   error: null,
   squads: undefined,
@@ -30,18 +36,35 @@ export const MultisigContextProvider: React.FC<
   MultisigContextProviderProps
 > = ({ children }) => {
   const anchorWallet = useAnchorWallet()
-  const { isLoading, error, squads, proposals } = useMultisig(
-    anchorWallet as Wallet
-  )
+  const {
+    isLoading,
+    error,
+    squads,
+    upgradeMultisigAccount,
+    securityMultisigAccount,
+    upgradeMultisigProposals,
+    securityMultisigProposals,
+  } = useMultisig(anchorWallet as Wallet)
 
   const value = useMemo(
     () => ({
-      proposals,
+      upgradeMultisigAccount,
+      securityMultisigAccount,
+      upgradeMultisigProposals,
+      securityMultisigProposals,
       isLoading,
       error,
       squads,
     }),
-    [squads, isLoading, error, proposals]
+    [
+      squads,
+      isLoading,
+      error,
+      upgradeMultisigAccount,
+      securityMultisigAccount,
+      upgradeMultisigProposals,
+      securityMultisigProposals,
+    ]
   )
 
   return (

+ 91 - 29
governance/xc_admin/packages/xc_admin_frontend/hooks/useMultisig.ts

@@ -1,7 +1,7 @@
 import { Wallet } from '@coral-xyz/anchor'
-import { Cluster, Connection, PublicKey } from '@solana/web3.js'
+import { Cluster, Connection, PublicKey, Transaction } from '@solana/web3.js'
 import SquadsMesh from '@sqds/mesh'
-import { TransactionAccount } from '@sqds/mesh/lib/types'
+import { MultisigAccount, TransactionAccount } from '@sqds/mesh/lib/types'
 import { useContext, useEffect, useRef, useState } from 'react'
 import { getMultisigCluster, getProposals } from 'xc_admin_common'
 import { ClusterContext } from '../contexts/ClusterContext'
@@ -25,7 +25,18 @@ interface MultisigHookData {
   isLoading: boolean
   error: any // TODO: fix any
   squads: SquadsMesh | undefined
-  proposals: TransactionAccount[]
+  upgradeMultisigAccount: MultisigAccount | undefined
+  securityMultisigAccount: MultisigAccount | undefined
+  upgradeMultisigProposals: TransactionAccount[]
+  securityMultisigProposals: TransactionAccount[]
+}
+
+const getSortedProposals = async (
+  squads: SquadsMesh,
+  vault: PublicKey
+): Promise<TransactionAccount[]> => {
+  const proposals = await getProposals(squads, vault)
+  return proposals.sort((a, b) => b.transactionIndex - a.transactionIndex)
 }
 
 export const useMultisig = (wallet: Wallet): MultisigHookData => {
@@ -33,7 +44,16 @@ export const useMultisig = (wallet: Wallet): MultisigHookData => {
   const { cluster } = useContext(ClusterContext)
   const [isLoading, setIsLoading] = useState(true)
   const [error, setError] = useState(null)
-  const [proposals, setProposals] = useState<TransactionAccount[]>([])
+  const [upgradeMultisigAccount, setUpgradeMultisigAccount] =
+    useState<MultisigAccount>()
+  const [securityMultisigAccount, setSecurityMultisigAccount] =
+    useState<MultisigAccount>()
+  const [upgradeMultisigProposals, setUpgradeMultisigProposals] = useState<
+    TransactionAccount[]
+  >([])
+  const [securityMultisigProposals, setSecurityMultisigProposals] = useState<
+    TransactionAccount[]
+  >([])
   const [squads, setSquads] = useState<SquadsMesh>()
   const [urlsIndex, setUrlsIndex] = useState(0)
 
@@ -52,35 +72,74 @@ export const useMultisig = (wallet: Wallet): MultisigHookData => {
 
     connectionRef.current = connection
     ;(async () => {
-      if (wallet) {
-        try {
-          const squads = new SquadsMesh({
-            connection,
-            wallet,
+      try {
+        // mock wallet to allow users to view proposals without connecting their wallet
+        const signTransaction = () =>
+          new Promise<Transaction>((resolve) => {
+            resolve(new Transaction())
+          })
+        const signAllTransactions = () =>
+          new Promise<Transaction[]>((resolve) => {
+            resolve([new Transaction()])
           })
-          setProposals(
-            await getProposals(
+        const squads = wallet
+          ? new SquadsMesh({
+              connection,
+              wallet,
+            })
+          : new SquadsMesh({
+              connection,
+              wallet: {
+                signTransaction: () => signTransaction(),
+                signAllTransactions: () => signAllTransactions(),
+                publicKey: new PublicKey(0),
+              },
+            })
+        setUpgradeMultisigAccount(
+          await squads.getMultisig(
+            UPGRADE_MULTISIG[getMultisigCluster(cluster)]
+          )
+        )
+        if (cluster === 'devnet') {
+          setSecurityMultisigAccount(
+            await squads.getMultisig(
+              SECURITY_MULTISIG[getMultisigCluster(cluster)]
+            )
+          )
+        } else {
+          setSecurityMultisigAccount(undefined)
+        }
+        setUpgradeMultisigProposals(
+          await getSortedProposals(
+            squads,
+            UPGRADE_MULTISIG[getMultisigCluster(cluster)]
+          )
+        )
+        if (cluster === 'devnet') {
+          setSecurityMultisigProposals(
+            await getSortedProposals(
               squads,
-              UPGRADE_MULTISIG[getMultisigCluster(cluster)]
+              SECURITY_MULTISIG[getMultisigCluster(cluster)]
             )
           )
-          setSquads(squads)
+        } else {
+          setSecurityMultisigProposals([])
+        }
+        setSquads(squads)
+        setIsLoading(false)
+      } catch (e) {
+        console.log(e)
+        if (cancelled) return
+        if (urlsIndex === urls.length - 1) {
+          // @ts-ignore
+          setError(e)
           setIsLoading(false)
-        } catch (e) {
-          if (cancelled) return
-          if (urlsIndex === urls.length - 1) {
-            // @ts-ignore
-            setError(e)
-            setIsLoading(false)
-            console.warn(`Failed to fetch accounts`)
-          } else if (urlsIndex < urls.length - 1) {
-            setUrlsIndex((urlsIndex) => urlsIndex + 1)
-            console.warn(
-              `Failed with ${urls[urlsIndex]}, trying with ${
-                urls[urlsIndex + 1]
-              }`
-            )
-          }
+          console.warn(`Failed to fetch accounts`)
+        } else if (urlsIndex < urls.length - 1) {
+          setUrlsIndex((urlsIndex) => urlsIndex + 1)
+          console.warn(
+            `Failed with ${urls[urlsIndex]}, trying with ${urls[urlsIndex + 1]}`
+          )
         }
       }
     })()
@@ -92,6 +151,9 @@ export const useMultisig = (wallet: Wallet): MultisigHookData => {
     isLoading,
     error,
     squads,
-    proposals,
+    upgradeMultisigAccount,
+    securityMultisigAccount,
+    upgradeMultisigProposals,
+    securityMultisigProposals,
   }
 }

+ 54 - 39
governance/xc_admin/packages/xc_admin_frontend/pages/index.tsx

@@ -6,8 +6,10 @@ import Layout from '../components/layout/Layout'
 import AddRemovePublishers from '../components/tabs/AddRemovePublishers'
 import General from '../components/tabs/General'
 import MinPublishers from '../components/tabs/MinPublishers'
+import Proposals from '../components/tabs/Proposals'
 import UpdatePermissions from '../components/tabs/UpdatePermissions'
 import UpdateProductMetadata from '../components/tabs/UpdateProductMetadata'
+import { MultisigContextProvider } from '../contexts/MultisigContext'
 import { PythContextProvider } from '../contexts/PythContext'
 import { classNames } from '../utils/classNames'
 
@@ -38,6 +40,11 @@ const TAB_INFO = {
     description: 'Update the metadata of a product.',
     queryString: 'update-product-metadata',
   },
+  Proposals: {
+    title: 'Proposals',
+    description: 'View and vote on proposals.',
+    queryString: 'proposals',
+  },
 }
 
 const DEFAULT_TAB = 'general'
@@ -50,6 +57,9 @@ const Home: NextPage = () => {
 
   // set current tab value when tab is clicked
   const handleChangeTab = (index: number) => {
+    if (tabInfoArray[index].queryString !== 'proposals') {
+      delete router.query.proposal
+    }
     router.query.tab = tabInfoArray[index].queryString
     setCurrentTabIndex(index)
     router.push(
@@ -76,46 +86,51 @@ const Home: NextPage = () => {
   return (
     <Layout>
       <PythContextProvider>
-        <div className="container relative pt-16 md:pt-20">
-          <div className="py-8 md:py-16">
-            <Tab.Group
-              selectedIndex={currentTabIndex}
-              onChange={handleChangeTab}
-            >
-              <Tab.List className="mx-auto gap-1 space-x-4 space-y-4 text-center sm:gap-2.5 md:space-x-8">
-                {Object.entries(TAB_INFO).map((tab, idx) => (
-                  <Tab
-                    key={idx}
-                    className={({ selected }) =>
-                      classNames(
-                        'p-3 text-xs font-semibold uppercase outline-none transition-colors hover:bg-darkGray3 md:text-base',
-                        selected ? 'bg-darkGray3' : 'bg-darkGray2'
-                      )
-                    }
-                  >
-                    {tab[1].title}
-                  </Tab>
-                ))}
-              </Tab.List>
-            </Tab.Group>
+        <MultisigContextProvider>
+          <div className="container relative pt-16 md:pt-20">
+            <div className="py-8 md:py-16">
+              <Tab.Group
+                selectedIndex={currentTabIndex}
+                onChange={handleChangeTab}
+              >
+                <Tab.List className="mx-auto gap-1 space-x-4 space-y-4 text-center sm:gap-2.5 md:space-x-8">
+                  {Object.entries(TAB_INFO).map((tab, idx) => (
+                    <Tab
+                      key={idx}
+                      className={({ selected }) =>
+                        classNames(
+                          'p-3 text-xs font-semibold uppercase outline-none transition-colors hover:bg-darkGray3 md:text-base',
+                          selected ? 'bg-darkGray3' : 'bg-darkGray2'
+                        )
+                      }
+                    >
+                      {tab[1].title}
+                    </Tab>
+                  ))}
+                </Tab.List>
+              </Tab.Group>
+            </div>
           </div>
-        </div>
-        {tabInfoArray[currentTabIndex].queryString ===
-        TAB_INFO.General.queryString ? (
-          <General />
-        ) : tabInfoArray[currentTabIndex].queryString ===
-          TAB_INFO.MinPublishers.queryString ? (
-          <MinPublishers />
-        ) : tabInfoArray[currentTabIndex].queryString ===
-          TAB_INFO.UpdatePermissions.queryString ? (
-          <UpdatePermissions />
-        ) : tabInfoArray[currentTabIndex].queryString ===
-          TAB_INFO.AddRemovePublishers.queryString ? (
-          <AddRemovePublishers />
-        ) : tabInfoArray[currentTabIndex].queryString ===
-          TAB_INFO.UpdateProductMetadata.queryString ? (
-          <UpdateProductMetadata />
-        ) : null}
+          {tabInfoArray[currentTabIndex].queryString ===
+          TAB_INFO.General.queryString ? (
+            <General />
+          ) : tabInfoArray[currentTabIndex].queryString ===
+            TAB_INFO.MinPublishers.queryString ? (
+            <MinPublishers />
+          ) : tabInfoArray[currentTabIndex].queryString ===
+            TAB_INFO.UpdatePermissions.queryString ? (
+            <UpdatePermissions />
+          ) : tabInfoArray[currentTabIndex].queryString ===
+            TAB_INFO.AddRemovePublishers.queryString ? (
+            <AddRemovePublishers />
+          ) : tabInfoArray[currentTabIndex].queryString ===
+            TAB_INFO.UpdateProductMetadata.queryString ? (
+            <UpdateProductMetadata />
+          ) : tabInfoArray[currentTabIndex].queryString ===
+            TAB_INFO.Proposals.queryString ? (
+            <Proposals />
+          ) : null}
+        </MultisigContextProvider>
       </PythContextProvider>
     </Layout>
   )