Преглед изворни кода

add UpdateProductMetadata page

Daniel Chew пре 2 година
родитељ
комит
957b9f4c4a

+ 346 - 0
governance/xc-admin/packages/xc-admin-frontend/components/tabs/UpdateProductMetadata.tsx

@@ -0,0 +1,346 @@
+import { AnchorProvider, Program, Wallet } from '@coral-xyz/anchor'
+import {
+  getPythProgramKeyForCluster,
+  Product,
+  pythOracleProgram,
+} from '@pythnetwork/client'
+import { PythOracle } from '@pythnetwork/client/lib/anchor'
+import { useAnchorWallet, useWallet } from '@solana/wallet-adapter-react'
+import { WalletModalButton } from '@solana/wallet-adapter-react-ui'
+import { PublicKey, TransactionInstruction } from '@solana/web3.js'
+import { useContext, useEffect, useState } from 'react'
+import toast from 'react-hot-toast'
+import { getMultisigCluster, proposeInstructions } from 'xc-admin-common'
+import { ClusterContext } from '../../contexts/ClusterContext'
+import { usePythContext } from '../../contexts/PythContext'
+import { SECURITY_MULTISIG, useMultisig } from '../../hooks/useMultisig'
+import { capitalizeFirstLetter } from '../../utils/capitalizeFirstLetter'
+import ClusterSwitch from '../ClusterSwitch'
+import Modal from '../common/Modal'
+import Spinner from '../common/Spinner'
+import Loadbar from '../loaders/Loadbar'
+
+interface SymbolToProductMetadata {
+  [key: string]: Product
+}
+
+interface ProductMetadataInfo {
+  prev: Product
+  new: Product
+}
+
+const symbolToProductAccountKeyMapping: Record<string, PublicKey> = {}
+
+const UpdateProductMetadata = () => {
+  const [data, setData] = useState<SymbolToProductMetadata>({})
+  const [productMetadataChanges, setProductMetadataChanges] =
+    useState<Record<string, ProductMetadataInfo>>()
+  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)
+  }
+
+  useEffect(() => {
+    if (!dataIsLoading && rawConfig) {
+      const symbolToProductMetadataMapping: SymbolToProductMetadata = {}
+      rawConfig.mappingAccounts
+        .sort(
+          (mapping1, mapping2) =>
+            mapping2.products.length - mapping1.products.length
+        )[0]
+        .products.map((product) => {
+          symbolToProductAccountKeyMapping[product.metadata.symbol] =
+            product.address
+          symbolToProductMetadataMapping[product.metadata.symbol] =
+            product.metadata
+        })
+      setData(sortData(symbolToProductMetadataMapping))
+    }
+  }, [rawConfig, dataIsLoading])
+
+  const sortData = (data: SymbolToProductMetadata) => {
+    const sortedSymbolToProductMetadataMapping: SymbolToProductMetadata = {}
+    Object.keys(data)
+      .sort()
+      .forEach((key) => {
+        const sortedInnerData: any = {}
+        Object.keys(data[key])
+          .sort()
+          .forEach((innerKey) => {
+            sortedInnerData[innerKey] = data[key][innerKey]
+          })
+        sortedSymbolToProductMetadataMapping[key] = sortedInnerData
+      })
+
+    return sortedSymbolToProductMetadataMapping
+  }
+
+  // function to download json file
+  const handleDownloadJsonButtonClick = () => {
+    const dataStr =
+      'data:text/json;charset=utf-8,' +
+      encodeURIComponent(JSON.stringify(data, null, 2))
+    const downloadAnchor = document.createElement('a')
+    downloadAnchor.setAttribute('href', dataStr)
+    downloadAnchor.setAttribute('download', 'products.json')
+    document.body.appendChild(downloadAnchor) // required for firefox
+    downloadAnchor.click()
+    downloadAnchor.remove()
+  }
+
+  // function to upload json file and update productMetadataChanges state
+  const handleUploadJsonButtonClick = () => {
+    const uploadAnchor = document.createElement('input')
+    uploadAnchor.setAttribute('type', 'file')
+    uploadAnchor.setAttribute('accept', '.json')
+    uploadAnchor.addEventListener('change', (e) => {
+      const file = (e.target as HTMLInputElement).files![0]
+      const reader = new FileReader()
+      reader.onload = (e) => {
+        if (e.target) {
+          const fileData = e.target.result
+          if (!isValidJson(fileData as string)) return
+          const fileDataParsed = sortData(JSON.parse(fileData as string))
+          const changes: Record<string, ProductMetadataInfo> = {}
+          Object.keys(fileDataParsed).forEach((symbol) => {
+            if (
+              JSON.stringify(data[symbol]) !==
+              JSON.stringify(fileDataParsed[symbol])
+            ) {
+              changes[symbol] = {
+                prev: data[symbol],
+                new: fileDataParsed[symbol],
+              }
+            }
+          })
+          setProductMetadataChanges(changes)
+          openModal()
+        }
+      }
+      reader.readAsText(file)
+    })
+    document.body.appendChild(uploadAnchor) // required for firefox
+    uploadAnchor.click()
+    uploadAnchor.remove()
+  }
+
+  // check if uploaded json is valid json
+  const isValidJson = (json: string) => {
+    try {
+      JSON.parse(json)
+    } catch (e: any) {
+      toast.error(capitalizeFirstLetter(e.message))
+      return false
+    }
+    // check if json keys are existing products
+    const jsonParsed = JSON.parse(json)
+    const jsonSymbols = Object.keys(jsonParsed)
+    const existingSymbols = Object.keys(data)
+    // check that jsonSymbols is equal to existingSymbols no matter the order
+    if (
+      JSON.stringify(jsonSymbols.sort()) !==
+      JSON.stringify(existingSymbols.sort())
+    ) {
+      toast.error('Symbols in json file do not match existing symbols!')
+      return false
+    }
+
+    let isValid = true
+    // check that the keys of the values of json are equal to the keys of the values of data
+    jsonSymbols.forEach((symbol) => {
+      const jsonKeys = Object.keys(jsonParsed[symbol])
+      const existingKeys = Object.keys(data[symbol])
+      if (
+        JSON.stringify(jsonKeys.sort()) !== JSON.stringify(existingKeys.sort())
+      ) {
+        toast.error(
+          `Keys in json file do not match existing keys for symbol ${symbol}!`
+        )
+        isValid = false
+      }
+    })
+    return isValid
+  }
+
+  const handleSendProposalButtonClick = async () => {
+    if (pythProgramClient && productMetadataChanges) {
+      const instructions: TransactionInstruction[] = []
+      Object.keys(productMetadataChanges).forEach((symbol) => {
+        const { prev, new: newProductMetadata } = productMetadataChanges[symbol]
+        // prev and new are json object of metadata
+        // check if there are any new metadata by comparing prev and new values
+        if (JSON.stringify(prev) !== JSON.stringify(newProductMetadata)) {
+          pythProgramClient.methods
+            .updProduct(newProductMetadata)
+            .accounts({
+              fundingAccount: squads?.getAuthorityPDA(
+                SECURITY_MULTISIG[getMultisigCluster(cluster)],
+                1
+              ),
+              productAccount: symbolToProductAccountKeyMapping[symbol],
+            })
+            .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)
+        }
+      }
+    }
+  }
+
+  const ModalContent = ({ changes }: { changes: any }) => {
+    return (
+      <>
+        {Object.keys(changes).length > 0 ? (
+          <table className="mb-10 w-full table-auto bg-darkGray text-left">
+            <thead>
+              <tr>
+                <th className="base16 py-8 pl-6 pr-2 font-semibold lg:pl-6">
+                  Description
+                </th>
+                <th className="base16 py-8 pl-1 pr-2 font-semibold lg:pl-6">
+                  Value
+                </th>
+              </tr>
+            </thead>
+            {Object.keys(changes).map((key) => {
+              const { prev, new: newProductMetadata } = changes[key]
+              const diff = Object.keys(prev).filter(
+                (k) => prev[k] !== newProductMetadata[k]
+              )
+              return (
+                <tbody key={key}>
+                  {diff.map((k) => (
+                    <tr key={k}>
+                      <td className="base16 py-4 pl-6 pr-2 lg:pl-6">
+                        {k
+                          .split('_')
+                          .map((word) => capitalizeFirstLetter(word))
+                          .join(' ')}
+                      </td>
+                      <td className="base16 py-4 pl-1 pr-2 lg:pl-6">
+                        {newProductMetadata[k]}
+                      </td>
+                    </tr>
+                  ))}
+                </tbody>
+              )
+            })}
+          </table>
+        ) : (
+          <p className="mb-8 leading-6">No proposed changes.</p>
+        )}
+        {Object.keys(changes).length > 0 ? (
+          !connected ? (
+            <div className="flex justify-center">
+              <WalletModalButton className="action-btn text-base" />
+            </div>
+          ) : (
+            <button
+              className="action-btn text-base"
+              onClick={handleSendProposalButtonClick}
+            >
+              {isSendProposalButtonLoading ? <Spinner /> : 'Send Proposal'}
+            </button>
+          )
+        ) : null}
+      </>
+    )
+  }
+
+  // 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}
+        content={<ModalContent changes={productMetadataChanges} />}
+      />
+      <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">Update Product Metadata</h1>
+        </div>
+      </div>
+      <div className="container min-h-[50vh]">
+        <div className="flex justify-between">
+          <div className="mb-4 md:mb-0">
+            <ClusterSwitch />
+          </div>
+        </div>
+        <div className="relative mt-6">
+          {dataIsLoading ? (
+            <div className="mt-3">
+              <Loadbar theme="light" />
+            </div>
+          ) : (
+            <div className="flex items-center space-x-4">
+              <div className="mb-10">
+                <button
+                  className="action-btn text-base"
+                  onClick={handleDownloadJsonButtonClick}
+                >
+                  Download JSON
+                </button>
+              </div>
+              <div className="mb-10">
+                <button
+                  className="action-btn text-base"
+                  onClick={handleUploadJsonButtonClick}
+                >
+                  Upload JSON
+                </button>
+              </div>
+            </div>
+          )}
+        </div>
+      </div>
+    </div>
+  )
+}
+
+export default UpdateProductMetadata

+ 1 - 1
governance/xc-admin/packages/xc-admin-frontend/package.json

@@ -11,7 +11,7 @@
   "dependencies": {
     "@coral-xyz/anchor": "^0.26.0",
     "@headlessui/react": "^1.7.7",
-    "@pythnetwork/client": "^2.10.0",
+    "@pythnetwork/client": "^2.12.0",
     "@solana/wallet-adapter-base": "^0.9.20",
     "@solana/wallet-adapter-react": "^0.15.28",
     "@solana/wallet-adapter-react-ui": "^0.9.27",

+ 9 - 0
governance/xc-admin/packages/xc-admin-frontend/pages/index.tsx

@@ -6,6 +6,7 @@ import Layout from '../components/layout/Layout'
 import AddRemovePublishers from '../components/tabs/AddRemovePublishers'
 import MinPublishers from '../components/tabs/MinPublishers'
 import UpdatePermissions from '../components/tabs/UpdatePermissions'
+import UpdateProductMetadata from '../components/tabs/UpdateProductMetadata'
 import { PythContextProvider } from '../contexts/PythContext'
 import { classNames } from '../utils/classNames'
 
@@ -26,6 +27,11 @@ const TAB_INFO = {
     description: 'Add or remove publishers from price feeds.',
     queryString: 'add-remove-publishers',
   },
+  UpdateProductMetadata: {
+    title: 'Update Product Metadata',
+    description: 'Update the metadata of a product.',
+    queryString: 'update-product-metadata',
+  },
 }
 
 const DEFAULT_TAB = 'min-publishers'
@@ -100,6 +106,9 @@ const Home: NextPage = () => {
         ) : tabInfoArray[currentTabIndex].queryString ===
           TAB_INFO.AddRemovePublishers.queryString ? (
           <AddRemovePublishers />
+        ) : tabInfoArray[currentTabIndex].queryString ===
+          TAB_INFO.UpdateProductMetadata.queryString ? (
+          <UpdateProductMetadata />
         ) : null}
       </PythContextProvider>
     </Layout>

+ 4 - 2
package-lock.json

@@ -2652,7 +2652,7 @@
       "dependencies": {
         "@coral-xyz/anchor": "^0.26.0",
         "@headlessui/react": "^1.7.7",
-        "@pythnetwork/client": "^2.10.0",
+        "@pythnetwork/client": "^2.12.0",
         "@solana/wallet-adapter-base": "^0.9.20",
         "@solana/wallet-adapter-react": "^0.15.28",
         "@solana/wallet-adapter-react-ui": "^0.9.27",
@@ -27106,6 +27106,7 @@
       "version": "4.0.5",
       "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.5.tgz",
       "integrity": "sha512-HTm14iMQKK2FjFLRTM5lAVcyaUzOnqbPtesFIvREgXpJHdQm8bWS+GkQgIkfaBYRHuCnea7w8UVNfwiAQhlr9A==",
+      "hasInstallScript": true,
       "optional": true,
       "dependencies": {
         "node-gyp-build": "^4.3.0"
@@ -27435,6 +27436,7 @@
       "version": "5.0.7",
       "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.7.tgz",
       "integrity": "sha512-vLt1O5Pp+flcArHGIyKEQq883nBt8nN8tVBcoL0qUXj2XT1n7p70yGIq2VK98I5FdZ1YHc0wk/koOnHjnXWk1Q==",
+      "hasInstallScript": true,
       "optional": true,
       "dependencies": {
         "node-gyp-build": "^4.3.0"
@@ -85028,7 +85030,7 @@
       "requires": {
         "@coral-xyz/anchor": "^0.26.0",
         "@headlessui/react": "^1.7.7",
-        "@pythnetwork/client": "^2.10.0",
+        "@pythnetwork/client": "^2.12.0",
         "@solana/wallet-adapter-base": "^0.9.20",
         "@solana/wallet-adapter-react": "^0.15.28",
         "@solana/wallet-adapter-react-ui": "^0.9.27",