From e02afb7ee9efd4f4cfcd50e2c45bd680636eb834 Mon Sep 17 00:00:00 2001 From: Daniel Chew Date: Thu, 26 Jan 2023 18:37:14 +0900 Subject: [PATCH] [xc-admin] add add/remove publishers page (#530) * add initial add/remove publishers page * add download/upload json feature and send proposal * show connect wallet button in change modal when wallet is not connected * address comments * use symbol instead of price account key * sort json * fix sorting bug * remove prettify while sorting * fix upload bug * fix reordering bug --- .../components/common/Modal.tsx | 51 +-- .../components/tabs/AddRemovePublishers.tsx | 395 ++++++++++++++++++ .../components/tabs/MinPublishers.tsx | 52 ++- .../components/tabs/UpdatePermissions.tsx | 52 ++- .../xc-admin-frontend/pages/index.tsx | 13 +- 5 files changed, 506 insertions(+), 57 deletions(-) create mode 100644 governance/xc-admin/packages/xc-admin-frontend/components/tabs/AddRemovePublishers.tsx diff --git a/governance/xc-admin/packages/xc-admin-frontend/components/common/Modal.tsx b/governance/xc-admin/packages/xc-admin-frontend/components/common/Modal.tsx index 802c400f..1eea474e 100644 --- a/governance/xc-admin/packages/xc-admin-frontend/components/common/Modal.tsx +++ b/governance/xc-admin/packages/xc-admin-frontend/components/common/Modal.tsx @@ -1,28 +1,18 @@ import { Dialog, Transition } from '@headlessui/react' import { Dispatch, Fragment, SetStateAction } from 'react' import CloseIcon from '../icons/CloseIcon' -import Spinner from './Spinner' const Modal: React.FC<{ isModalOpen: boolean setIsModalOpen: Dispatch> closeModal: () => void - changes: any - handleSendProposalButtonClick: () => void - isSendProposalButtonLoading: boolean -}> = ({ - isModalOpen, - setIsModalOpen, - closeModal, - changes, - handleSendProposalButtonClick, - isSendProposalButtonLoading, -}) => { + content: any +}> = ({ isModalOpen, setIsModalOpen, closeModal, content }) => { return ( setIsModalOpen(false)} > Proposed Changes - - {!changes ? ( -

No proposed changes.

- ) : ( - Object.keys(changes).map((key) => { - if (changes[key].prev !== changes[key].new) { - return ( -
- - {key} - - - {changes[key].prev} → {changes[key].new} - -
- ) - } - }) - )} - - + {content}
diff --git a/governance/xc-admin/packages/xc-admin-frontend/components/tabs/AddRemovePublishers.tsx b/governance/xc-admin/packages/xc-admin-frontend/components/tabs/AddRemovePublishers.tsx new file mode 100644 index 00000000..9611469a --- /dev/null +++ b/governance/xc-admin/packages/xc-admin-frontend/components/tabs/AddRemovePublishers.tsx @@ -0,0 +1,395 @@ +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 { WalletModalButton } from '@solana/wallet-adapter-react-ui' +import { PublicKey, TransactionInstruction } from '@solana/web3.js' +import { Fragment, 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 Spinner from '../common/Spinner' +import Loadbar from '../loaders/Loadbar' + +interface SymbolToPublisherKeys { + [key: string]: PublicKey[] +} + +interface PublishersInfo { + prev: string[] + new: string[] +} + +let symbolToPriceAccountKeyMapping: Record = {} + +const AddRemovePublishers = () => { + const [data, setData] = useState({}) + const [publisherChanges, setPublisherChanges] = + useState>() + 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>() + + const openModal = () => { + setIsModalOpen(true) + } + + const closeModal = () => { + setIsModalOpen(false) + } + + useEffect(() => { + if (!dataIsLoading && rawConfig) { + let symbolToPublisherKeysMapping: SymbolToPublisherKeys = {} + rawConfig.mappingAccounts.map((mappingAccount) => { + mappingAccount.products.map((product) => { + const priceAccount = product.priceAccounts.find( + (priceAccount) => + priceAccount.address.toBase58() === product.metadata.price_account + ) + if (priceAccount) { + symbolToPublisherKeysMapping[product.metadata.symbol] = + priceAccount.publishers + symbolToPriceAccountKeyMapping[product.metadata.symbol] = + priceAccount.address.toBase58() + } + }) + }) + symbolToPublisherKeysMapping = sortData(symbolToPublisherKeysMapping) + setData(symbolToPublisherKeysMapping) + } + }, [rawConfig, dataIsLoading]) + + const sortData = (data: SymbolToPublisherKeys) => { + let sortedSymbolToPublisherKeysMapping: SymbolToPublisherKeys = {} + // sort symbolToPublisherKeysMapping by symbol + sortedSymbolToPublisherKeysMapping = JSON.parse( + JSON.stringify(data, Object.keys(data).sort()) + ) + // sort symbolToPublisherKeysMapping by publisher keys + Object.keys(sortedSymbolToPublisherKeysMapping).forEach((key) => { + // sort publisher keys and make them each of type PublicKey because JSON.stringify makes them of type string + sortedSymbolToPublisherKeysMapping[key] = + sortedSymbolToPublisherKeysMapping[key] + .sort() + .map((publisherKey) => new PublicKey(publisherKey)) + }) + return sortedSymbolToPublisherKeysMapping + } + + // 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', 'publishers.json') + document.body.appendChild(downloadAnchor) // required for firefox + downloadAnchor.click() + downloadAnchor.remove() + } + + // function to upload json file and update publisherChanges 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 = {} + Object.keys(fileDataParsed).forEach((symbol) => { + if ( + JSON.stringify(data[symbol]) !== + JSON.stringify(fileDataParsed[symbol]) + ) { + changes[symbol] = { prev: [], new: [] } + changes[symbol].prev = data[symbol].map((p: PublicKey) => + p.toBase58() + ) + changes[symbol].new = fileDataParsed[symbol].map((p: PublicKey) => + p.toBase58() + ) + } + }) + setPublisherChanges(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 + } + return true + } + + const handleSendProposalButtonClick = async () => { + if (pythProgramClient && publisherChanges) { + const instructions: TransactionInstruction[] = [] + Object.keys(publisherChanges).forEach((symbol) => { + const { prev, new: newPublisherKeys } = publisherChanges[symbol] + // prev and new are arrays of publisher pubkeys + // check if there are any new publishers by comparing prev and new + const publisherKeysToAdd = newPublisherKeys.filter( + (newPublisher) => !prev.includes(newPublisher) + ) + // check if there are any publishers to remove by comparing prev and new + const publisherKeysToRemove = prev.filter( + (prevPublisher) => !newPublisherKeys.includes(prevPublisher) + ) + // add instructions to add new publishers + publisherKeysToAdd.forEach((publisherKey) => { + pythProgramClient.methods + .addPublisher(new PublicKey(publisherKey)) + .accounts({ + fundingAccount: squads?.getAuthorityPDA( + SECURITY_MULTISIG[getMultisigCluster(cluster)], + 1 + ), + priceAccount: new PublicKey( + symbolToPriceAccountKeyMapping[symbol] + ), + }) + .instruction() + .then((instruction) => instructions.push(instruction)) + }) + // add instructions to remove publishers + publisherKeysToRemove.forEach((publisherKey) => { + pythProgramClient.methods + .delPublisher(new PublicKey(publisherKey)) + .accounts({ + fundingAccount: squads?.getAuthorityPDA( + SECURITY_MULTISIG[getMultisigCluster(cluster)], + 1 + ), + priceAccount: new PublicKey( + symbolToPriceAccountKeyMapping[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 ? ( + + + + + + + + {Object.keys(changes).map((key) => { + const publisherKeysToAdd = changes[key].new.filter( + (newPublisher: string) => + !changes[key].prev.includes(newPublisher) + ) + const publisherKeysToRemove = changes[key].prev.filter( + (prevPublisher: string) => + !changes[key].new.includes(prevPublisher) + ) + return ( + changes[key].prev !== changes[key].new && ( + + + + + + + {publisherKeysToAdd.length > 0 && ( + + + + + )} + {publisherKeysToRemove.length > 0 && ( + + + + + )} + + + ) + ) + })} +
+ Description + + ID +
Product{key}
+ Add Publisher(s) + + {publisherKeysToAdd.map((publisherKey: string) => ( + + {publisherKey} + + ))} +
+ Remove Publisher(s) + + {publisherKeysToRemove.map( + (publisherKey: string) => ( + + {publisherKey} + + ) + )} +
+ ) : ( +

No proposed changes.

+ )} + {Object.keys(changes).length > 0 ? ( + !connected ? ( +
+ +
+ ) : ( + + ) + ) : 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 ( +
+ } + /> +
+
+

Add/Remove Publishers

+
+
+
+
+
+ +
+
+
+ {dataIsLoading ? ( +
+ +
+ ) : ( +
+
+ +
+
+ +
+
+ )} +
+
+
+ ) +} + +export default AddRemovePublishers diff --git a/governance/xc-admin/packages/xc-admin-frontend/components/tabs/MinPublishers.tsx b/governance/xc-admin/packages/xc-admin-frontend/components/tabs/MinPublishers.tsx index d10c2e26..ca3f6cfd 100644 --- a/governance/xc-admin/packages/xc-admin-frontend/components/tabs/MinPublishers.tsx +++ b/governance/xc-admin/packages/xc-admin-frontend/components/tabs/MinPublishers.tsx @@ -5,6 +5,7 @@ import { } 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 { TransactionInstruction } from '@solana/web3.js' import { createColumnHelper, @@ -25,6 +26,7 @@ import { import { capitalizeFirstLetter } from '../../utils/capitalizeFirstLetter' import ClusterSwitch from '../ClusterSwitch' import Modal from '../common/Modal' +import Spinner from '../common/Spinner' import EditButton from '../EditButton' import Loadbar from '../loaders/Loadbar' @@ -206,6 +208,50 @@ const MinPublishers = () => { } } + const ModalContent = ({ changes }: { changes: any }) => { + return ( + <> + {Object.keys(changes).length > 0 ? ( +
+ {Object.keys(changes).map((key) => { + return ( + changes[key].prev !== changes[key].new && ( + <> +
+ {key} + + {changes[key].prev} → {changes[key].new} + +
+ + ) + ) + })} +
+ ) : ( +

No proposed changes.

+ )} + {Object.keys(changes).length > 0 ? ( + !connected ? ( +
+ +
+ ) : ( + + ) + ) : null} + + ) + } + // create anchor wallet when connected useEffect(() => { if (connected) { @@ -226,16 +272,14 @@ const MinPublishers = () => { isModalOpen={isModalOpen} setIsModalOpen={setIsModalOpen} closeModal={closeModal} - changes={minPublishersChanges} - handleSendProposalButtonClick={handleSendProposalButtonClick} - isSendProposalButtonLoading={isSendProposalButtonLoading} + content={} />

Min Publishers

-
+
diff --git a/governance/xc-admin/packages/xc-admin-frontend/components/tabs/UpdatePermissions.tsx b/governance/xc-admin/packages/xc-admin-frontend/components/tabs/UpdatePermissions.tsx index 56678f0d..690276f7 100644 --- a/governance/xc-admin/packages/xc-admin-frontend/components/tabs/UpdatePermissions.tsx +++ b/governance/xc-admin/packages/xc-admin-frontend/components/tabs/UpdatePermissions.tsx @@ -5,6 +5,7 @@ import { } 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 } from '@solana/web3.js' import { createColumnHelper, @@ -27,6 +28,7 @@ import CopyIcon from '../../images/icons/copy.inline.svg' import { capitalizeFirstLetter } from '../../utils/capitalizeFirstLetter' import ClusterSwitch from '../ClusterSwitch' import Modal from '../common/Modal' +import Spinner from '../common/Spinner' import EditButton from '../EditButton' import Loadbar from '../loaders/Loadbar' @@ -282,6 +284,50 @@ const UpdatePermissions = () => { } } + const ModalContent = ({ changes }: { changes: any }) => { + return ( + <> + {Object.keys(changes).length > 0 ? ( +
+ {Object.keys(changes).map((key) => { + return ( + changes[key].prev !== changes[key].new && ( + <> +
+ {key} + + {changes[key].prev} → {changes[key].new} + +
+ + ) + ) + })} +
+ ) : ( +

No proposed changes.

+ )} + {Object.keys(changes).length > 0 ? ( + !connected ? ( +
+ +
+ ) : ( + + ) + ) : null} + + ) + } + // create anchor wallet when connected useEffect(() => { if (connected) { @@ -302,16 +348,14 @@ const UpdatePermissions = () => { isModalOpen={isModalOpen} setIsModalOpen={setIsModalOpen} closeModal={closeModal} - changes={pubkeyChanges} - handleSendProposalButtonClick={handleSendProposalButtonClick} - isSendProposalButtonLoading={isSendProposalButtonLoading} + content={} />

Update Permissions

-
+
diff --git a/governance/xc-admin/packages/xc-admin-frontend/pages/index.tsx b/governance/xc-admin/packages/xc-admin-frontend/pages/index.tsx index aa19bb45..10bb8ae6 100644 --- a/governance/xc-admin/packages/xc-admin-frontend/pages/index.tsx +++ b/governance/xc-admin/packages/xc-admin-frontend/pages/index.tsx @@ -3,6 +3,7 @@ import type { NextPage } from 'next' import { useRouter } from 'next/router' import { useEffect, useState } from 'react' 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 { PythContextProvider } from '../contexts/PythContext' @@ -20,6 +21,11 @@ const TAB_INFO = { description: 'Update the permissions of the program.', queryString: 'update-permissions', }, + AddRemovePublishers: { + title: 'Add/Remove Publishers', + description: 'Add or remove publishers from price feeds.', + queryString: 'add-remove-publishers', + }, } const DEFAULT_TAB = 'min-publishers' @@ -58,13 +64,13 @@ const Home: NextPage = () => { return ( -
+
- + {Object.entries(TAB_INFO).map((tab, idx) => ( { ) : tabInfoArray[currentTabIndex].queryString === TAB_INFO.UpdatePermissions.queryString ? ( + ) : tabInfoArray[currentTabIndex].queryString === + TAB_INFO.AddRemovePublishers.queryString ? ( + ) : null}