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

[xc-admin] min pub page (#519)

Daniel Chew преди 2 години
родител
ревизия
2c6eb7d1e3

+ 281 - 43
governance/xc-admin/packages/xc-admin-frontend/components/tabs/MinPublishers.tsx

@@ -1,20 +1,245 @@
+import { AnchorProvider, Program, Wallet } from '@coral-xyz/anchor'
+import {
+  getPythProgramKeyForCluster,
+  pythOracleProgram,
+} from '@pythnetwork/client'
+import { PythOracle } from '@pythnetwork/client/lib/anchor'
+import { useAnchorWallet, useWallet } from '@solana/wallet-adapter-react'
+import { TransactionInstruction } from '@solana/web3.js'
+import {
+  createColumnHelper,
+  flexRender,
+  getCoreRowModel,
+  useReactTable,
+} from '@tanstack/react-table'
+import { useContext, useEffect, useState } from 'react'
+import toast from 'react-hot-toast'
+import { proposeInstructions } from 'xc-admin-common'
+import { ClusterContext } from '../../contexts/ClusterContext'
 import { usePythContext } from '../../contexts/PythContext'
+import {
+  getMultisigCluster,
+  SECURITY_MULTISIG,
+  useMultisig,
+} from '../../hooks/useMultisig'
+import { capitalizeFirstLetter } from '../../utils/capitalizeFirstLetter'
 import ClusterSwitch from '../ClusterSwitch'
+import Modal from '../common/Modal'
+import EditButton from '../EditButton'
 import Loadbar from '../loaders/Loadbar'
 
+interface MinPublishersProps {
+  symbol: string
+  minPublishers: number
+  newMinPublishers?: number
+}
+
+interface MinPublishersInfo {
+  prev: number
+  new: number
+}
+
+const columnHelper = createColumnHelper<MinPublishersProps>()
+
+const defaultColumns = [
+  columnHelper.accessor('symbol', {
+    cell: (info) => info.getValue(),
+    header: () => <span>Symbol</span>,
+  }),
+  columnHelper.accessor('minPublishers', {
+    cell: (props) => {
+      const minPublishers = props.getValue()
+      return <span className="mr-2">{minPublishers}</span>
+    },
+    header: () => <span>Min Publishers</span>,
+  }),
+]
+
 const MinPublishers = () => {
-  const { rawConfig, dataIsLoading } = usePythContext()
+  const [data, setData] = useState<MinPublishersProps[]>([])
+  const [columns, setColumns] = useState(() => [...defaultColumns])
+  const [minPublishersChanges, setMinPublishersChanges] =
+    useState<Record<string, MinPublishersInfo>>()
+  const [editable, setEditable] = useState(false)
+  const [isModalOpen, setIsModalOpen] = useState(false)
+  const [isSendProposalButtonLoading, setIsSendProposalButtonLoading] =
+    useState(false)
+  const { cluster } = useContext(ClusterContext)
+  const anchorWallet = useAnchorWallet()
+  const { isLoading: isMultisigLoading, squads } = useMultisig(
+    anchorWallet as Wallet
+  )
+  const { rawConfig, dataIsLoading, connection } = usePythContext()
+  const { connected } = useWallet()
+  const [pythProgramClient, setPythProgramClient] =
+    useState<Program<PythOracle>>()
+
+  const openModal = () => {
+    setIsModalOpen(true)
+  }
+
+  const closeModal = () => {
+    setIsModalOpen(false)
+  }
+
+  const handleEditButtonClick = () => {
+    const nextState = !editable
+    if (nextState) {
+      const newColumns = [
+        ...defaultColumns,
+        columnHelper.accessor('newMinPublishers', {
+          cell: (info) => info.getValue(),
+          header: () => <span>New Min Publishers</span>,
+        }),
+      ]
+      setColumns(newColumns)
+    } else {
+      if (
+        minPublishersChanges &&
+        Object.keys(minPublishersChanges).length > 0
+      ) {
+        openModal()
+        setMinPublishersChanges(minPublishersChanges)
+      } else {
+        setColumns(defaultColumns)
+      }
+    }
+
+    setEditable(nextState)
+  }
+
+  const handleEditMinPublishers = (
+    e: any,
+    symbol: string,
+    prevMinPublishers: number
+  ) => {
+    const newMinPublishers = Number(e.target.textContent)
+    if (prevMinPublishers !== newMinPublishers) {
+      setMinPublishersChanges({
+        ...minPublishersChanges,
+        [symbol]: {
+          prev: prevMinPublishers,
+          new: newMinPublishers,
+        },
+      })
+    } else {
+      // delete symbol from minPublishersChanges if it exists
+      if (minPublishersChanges && minPublishersChanges[symbol]) {
+        delete minPublishersChanges[symbol]
+      }
+      setMinPublishersChanges(minPublishersChanges)
+    }
+  }
+
+  useEffect(() => {
+    if (!dataIsLoading && rawConfig) {
+      const minPublishersData: MinPublishersProps[] = []
+      rawConfig.mappingAccounts
+        .sort(
+          (mapping1, mapping2) =>
+            mapping2.products.length - mapping1.products.length
+        )[0]
+        .products.map((product) =>
+          product.priceAccounts.map((priceAccount) => {
+            minPublishersData.push({
+              symbol: product.metadata.symbol,
+              minPublishers: priceAccount.minPub,
+            })
+          })
+        )
+      setData(minPublishersData)
+    }
+  }, [setData, rawConfig, dataIsLoading])
+
+  const table = useReactTable({
+    data,
+    columns,
+    getCoreRowModel: getCoreRowModel(),
+  })
+
+  const handleSendProposalButtonClick = async () => {
+    if (pythProgramClient && minPublishersChanges) {
+      const instructions: TransactionInstruction[] = []
+      Object.keys(minPublishersChanges).forEach((symbol) => {
+        const { prev, new: newMinPublishers } = minPublishersChanges[symbol]
+        const priceAccountPubkey = rawConfig.mappingAccounts
+          .sort(
+            (mapping1, mapping2) =>
+              mapping2.products.length - mapping1.products.length
+          )[0]
+          .products.find((product) => product.metadata.symbol === symbol)!
+          .priceAccounts.find(
+            (priceAccount) => priceAccount.minPub === prev
+          )!.address
+
+        pythProgramClient.methods
+          .setMinPub(newMinPublishers, [0, 0, 0])
+          .accounts({
+            priceAccount: priceAccountPubkey,
+            fundingAccount: squads?.getAuthorityPDA(
+              SECURITY_MULTISIG[getMultisigCluster(cluster)],
+              1
+            ),
+          })
+          .instruction()
+          .then((instruction) => instructions.push(instruction))
+      })
+      if (!isMultisigLoading && squads) {
+        setIsSendProposalButtonLoading(true)
+        try {
+          const proposalPubkey = await proposeInstructions(
+            squads,
+            SECURITY_MULTISIG[getMultisigCluster(cluster)],
+            instructions,
+            false
+          )
+          toast.success(`Proposal sent! 🚀 Proposal Pubkey: ${proposalPubkey}`)
+          setIsSendProposalButtonLoading(false)
+        } catch (e: any) {
+          toast.error(capitalizeFirstLetter(e.message))
+          setIsSendProposalButtonLoading(false)
+        }
+      }
+    }
+  }
+
+  // create anchor wallet when connected
+  useEffect(() => {
+    if (connected) {
+      const provider = new AnchorProvider(
+        connection,
+        anchorWallet as Wallet,
+        AnchorProvider.defaultOptions()
+      )
+      setPythProgramClient(
+        pythOracleProgram(getPythProgramKeyForCluster(cluster), provider)
+      )
+    }
+  }, [anchorWallet, connection, connected, cluster])
 
   return (
     <div className="relative">
+      <Modal
+        isModalOpen={isModalOpen}
+        setIsModalOpen={setIsModalOpen}
+        closeModal={closeModal}
+        changes={minPublishersChanges}
+        handleSendProposalButtonClick={handleSendProposalButtonClick}
+        isSendProposalButtonLoading={isSendProposalButtonLoading}
+      />
       <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">Min Publishers</h1>
         </div>
       </div>
       <div className="container">
-        <div className="mb-4 md:mb-0">
-          <ClusterSwitch />
+        <div className="flex justify-between">
+          <div className="mb-4 md:mb-0">
+            <ClusterSwitch />
+          </div>
+          <div className="mb-4 md:mb-0">
+            <EditButton editable={editable} onClick={handleEditButtonClick} />
+          </div>
         </div>
         <div className="table-responsive relative mt-6">
           {dataIsLoading ? (
@@ -23,50 +248,63 @@ const MinPublishers = () => {
             </div>
           ) : (
             <div className="table-responsive mb-10">
-              <table className="w-full bg-darkGray text-left">
+              <table className="w-full table-auto bg-darkGray text-left">
                 <thead>
-                  <tr>
-                    <th className="base16 pt-8 pb-6 pl-4 pr-2 font-semibold opacity-60 lg:pl-14">
-                      Symbol
-                    </th>
-                    <th className="base16 pt-8 pb-6 pl-1 pr-2 font-semibold opacity-60 lg:pl-14">
-                      Minimum Publishers
-                    </th>
-                  </tr>
+                  {table.getHeaderGroups().map((headerGroup) => (
+                    <tr key={headerGroup.id}>
+                      {headerGroup.headers.map((header) => (
+                        <th
+                          key={header.id}
+                          className={
+                            header.column.id === 'symbol'
+                              ? 'base16 pt-8 pb-6 pl-4 pr-2 font-semibold opacity-60 xl:pl-14'
+                              : 'base16 pt-8 pb-6 pl-1 pr-2 font-semibold opacity-60'
+                          }
+                        >
+                          {header.isPlaceholder
+                            ? null
+                            : flexRender(
+                                header.column.columnDef.header,
+                                header.getContext()
+                              )}
+                        </th>
+                      ))}
+                    </tr>
+                  ))}
                 </thead>
                 <tbody>
-                  {rawConfig.mappingAccounts.length ? (
-                    rawConfig.mappingAccounts
-                      .sort(
-                        (mapping1, mapping2) =>
-                          mapping2.products.length - mapping1.products.length
-                      )[0]
-                      .products.map((product) =>
-                        product.priceAccounts.map((priceAccount) => {
-                          return (
-                            <tr
-                              key={product.metadata.symbol}
-                              className="border-t border-beige-300"
-                            >
-                              <td className="py-3 pl-4 pr-2 lg:pl-14">
-                                {product.metadata.symbol}
-                              </td>
-                              <td className="py-3 pl-1 lg:pl-14">
-                                <span className="mr-2">
-                                  {priceAccount.minPub}
-                                </span>
-                              </td>
-                            </tr>
-                          )
-                        })
-                      )
-                  ) : (
-                    <tr className="border-t border-beige-300">
-                      <td className="py-3 pl-4 lg:pl-14" colSpan={2}>
-                        No mapping accounts found.
-                      </td>
+                  {table.getRowModel().rows.map((row) => (
+                    <tr key={row.id} className="border-t border-beige-300">
+                      {row.getVisibleCells().map((cell) => (
+                        <td
+                          key={cell.id}
+                          onBlur={(e) =>
+                            handleEditMinPublishers(
+                              e,
+                              cell.row.original.symbol,
+                              cell.row.original.minPublishers
+                            )
+                          }
+                          contentEditable={
+                            cell.column.id === 'newMinPublishers' && editable
+                              ? true
+                              : false
+                          }
+                          suppressContentEditableWarning={true}
+                          className={
+                            cell.column.id === 'symbol'
+                              ? 'py-3 pl-4 pr-2 xl:pl-14'
+                              : 'items-center py-3 pl-1 pr-4'
+                          }
+                        >
+                          {flexRender(
+                            cell.column.columnDef.cell,
+                            cell.getContext()
+                          )}
+                        </td>
+                      ))}
                     </tr>
-                  )}
+                  ))}
                 </tbody>
               </table>
             </div>

+ 7 - 19
governance/xc-admin/packages/xc-admin-frontend/components/tabs/UpdatePermissions.tsx

@@ -20,7 +20,7 @@ import { ClusterContext } from '../../contexts/ClusterContext'
 import { usePythContext } from '../../contexts/PythContext'
 import {
   getMultisigCluster,
-  UPGRADE_MUTLTISIG,
+  UPGRADE_MULTISIG,
   useMultisig,
 } from '../../hooks/useMultisig'
 import CopyIcon from '../../images/icons/copy.inline.svg'
@@ -86,7 +86,6 @@ const defaultColumns = [
   }),
 ]
 
-// make a type with 3 possible values
 type PermissionAccount =
   | 'Master Authority'
   | 'Data Curation Authority'
@@ -141,7 +140,7 @@ const UpdatePermissions = () => {
         },
       ])
     }
-  }, [dataIsLoading, rawConfig])
+  }, [rawConfig])
 
   const table = useReactTable({
     data,
@@ -175,28 +174,17 @@ const UpdatePermissions = () => {
     return newPubkeyChanges
   }
 
-  //   let newPubkeyChanges: Record<PermissionAccount, PermissionAccountInfo>
-  //   data.forEach((d) => {
-  //     if (!newPubkeyChanges[d.account]) {
-  //       newPubkeyChanges[d.account] = {
-  //         prev: d.pubkey,
-  //         new: d.pubkey,
-  //       }
-  //     }
-  //   })
-
-  // return newPubkeyChanges
-
   const handleEditButtonClick = () => {
     const nextState = !editable
     if (nextState) {
-      setColumns([
+      const newColumns = [
         ...defaultColumns,
         columnHelper.accessor('newPubkey', {
           cell: (info) => info.getValue(),
           header: () => <span>New Public Key</span>,
         }),
-      ])
+      ]
+      setColumns(newColumns)
     } else {
       if (pubkeyChanges && Object.keys(pubkeyChanges).length > 0) {
         openModal()
@@ -257,7 +245,7 @@ const UpdatePermissions = () => {
         )
         .accounts({
           upgradeAuthority: squads?.getAuthorityPDA(
-            UPGRADE_MUTLTISIG[getMultisigCluster(cluster)],
+            UPGRADE_MULTISIG[getMultisigCluster(cluster)],
             1
           ),
           programDataAccount,
@@ -269,7 +257,7 @@ const UpdatePermissions = () => {
             try {
               const proposalPubkey = await proposeInstructions(
                 squads,
-                UPGRADE_MUTLTISIG[getMultisigCluster(cluster)],
+                UPGRADE_MULTISIG[getMultisigCluster(cluster)],
                 [instruction],
                 false
               )

+ 9 - 2
governance/xc-admin/packages/xc-admin-frontend/hooks/useMultisig.ts

@@ -19,13 +19,20 @@ export function getMultisigCluster(cluster: PythCluster): Cluster | 'localnet' {
   }
 }
 
-export const UPGRADE_MUTLTISIG: Record<Cluster | 'localnet', PublicKey> = {
+export const UPGRADE_MULTISIG: Record<Cluster | 'localnet', PublicKey> = {
   'mainnet-beta': new PublicKey('FVQyHcooAtThJ83XFrNnv74BcinbRH3bRmfFamAHBfuj'),
   testnet: new PublicKey('FVQyHcooAtThJ83XFrNnv74BcinbRH3bRmfFamAHBfuj'),
   devnet: new PublicKey('6baWtW1zTUVMSJHJQVxDUXWzqrQeYBr6mu31j3bTKwY3'),
   localnet: new PublicKey('FVQyHcooAtThJ83XFrNnv74BcinbRH3bRmfFamAHBfuj'),
 }
 
+export const SECURITY_MULTISIG: Record<Cluster | 'localnet', PublicKey> = {
+  'mainnet-beta': new PublicKey('92hQkq8kBgCUcF9yWN8URZB9RTmA4mZpDGtbiAWA74Z8'), // TODO: placeholder value for now, fix when vault is created
+  testnet: new PublicKey('92hQkq8kBgCUcF9yWN8URZB9RTmA4mZpDGtbiAWA74Z8'), // TODO: placeholder value for now, fix when vault is created
+  devnet: new PublicKey('92hQkq8kBgCUcF9yWN8URZB9RTmA4mZpDGtbiAWA74Z8'),
+  localnet: new PublicKey('92hQkq8kBgCUcF9yWN8URZB9RTmA4mZpDGtbiAWA74Z8'), // TODO: placeholder value for now, fix when vault is created
+}
+
 interface MultisigHookData {
   isLoading: boolean
   error: any // TODO: fix any
@@ -66,7 +73,7 @@ export const useMultisig = (wallet: Wallet): MultisigHookData => {
           setProposals(
             await getProposals(
               squads,
-              UPGRADE_MUTLTISIG[getMultisigCluster(cluster)]
+              UPGRADE_MULTISIG[getMultisigCluster(cluster)]
             )
           )
           setSquads(squads)

+ 6 - 1
governance/xc-admin/packages/xc-admin-frontend/hooks/usePyth.ts

@@ -73,6 +73,7 @@ const usePyth = (): PythHookData => {
         const allPythAccounts = await connection.getProgramAccounts(
           getPythProgramKeyForCluster(cluster)
         )
+        if (cancelled) return
         const priceRawConfigs: { [key: string]: PriceRawConfig } = {}
 
         /// First pass, price accounts
@@ -99,6 +100,7 @@ const usePyth = (): PythHookData => {
           }
         }
 
+        if (cancelled) return
         /// Second pass, product accounts
         i = 0
         const productRawConfigs: { [key: string]: ProductRawConfig } = {}
@@ -139,6 +141,7 @@ const usePyth = (): PythHookData => {
         }
 
         const rawConfig: RawConfig = { mappingAccounts: [] }
+        if (cancelled) return
         /// Third pass, mapping accounts
         i = 0
         while (i < allPythAccounts.length) {
@@ -193,7 +196,9 @@ const usePyth = (): PythHookData => {
       }
     })()
 
-    return () => {}
+    return () => {
+      cancelled = true
+    }
   }, [urlsIndex, cluster])
 
   return {