From 1ca1d595d14e815b5d13e36dfde48d2f51360841 Mon Sep 17 00:00:00 2001 From: Daniel Chew Date: Wed, 1 Feb 2023 10:52:13 +0900 Subject: [PATCH 01/12] add UpdateProductMetadata page --- .../components/tabs/AddRemovePublishers.tsx | 4 +- .../components/tabs/MinPublishers.tsx | 2 +- .../components/tabs/UpdateProductMetadata.tsx | 346 ++++++++++++++++++ .../packages/xc_admin_frontend/package.json | 2 +- .../xc_admin_frontend/pages/index.tsx | 9 + package-lock.json | 6 +- 6 files changed, 363 insertions(+), 6 deletions(-) create mode 100644 governance/xc_admin/packages/xc_admin_frontend/components/tabs/UpdateProductMetadata.tsx 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 index cdb601e8dc..ba40604801 100644 --- a/governance/xc_admin/packages/xc_admin_frontend/components/tabs/AddRemovePublishers.tsx +++ b/governance/xc_admin/packages/xc_admin_frontend/components/tabs/AddRemovePublishers.tsx @@ -9,7 +9,7 @@ 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, getMultisigCluster } from 'xc_admin_common' +import { getMultisigCluster, proposeInstructions } from 'xc_admin_common' import { ClusterContext } from '../../contexts/ClusterContext' import { usePythContext } from '../../contexts/PythContext' import { SECURITY_MULTISIG, useMultisig } from '../../hooks/useMultisig' @@ -56,7 +56,7 @@ const AddRemovePublishers = () => { } useEffect(() => { - if (!dataIsLoading && rawConfig) { + if (!dataIsLoading && rawConfig && rawConfig.mappingAccounts.length > 0) { let symbolToPublisherKeysMapping: SymbolToPublisherKeys = {} rawConfig.mappingAccounts.map((mappingAccount) => { mappingAccount.products.map((product) => { 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 5cdf42c6b0..2b628c4c05 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 @@ -130,7 +130,7 @@ const MinPublishers = () => { } useEffect(() => { - if (!dataIsLoading && rawConfig) { + if (!dataIsLoading && rawConfig && rawConfig.mappingAccounts.length > 0) { const minPublishersData: MinPublishersProps[] = [] rawConfig.mappingAccounts .sort( diff --git a/governance/xc_admin/packages/xc_admin_frontend/components/tabs/UpdateProductMetadata.tsx b/governance/xc_admin/packages/xc_admin_frontend/components/tabs/UpdateProductMetadata.tsx new file mode 100644 index 0000000000..cb1b8beaf8 --- /dev/null +++ b/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 = {} + +const UpdateProductMetadata = () => { + const [data, setData] = useState({}) + const [productMetadataChanges, setProductMetadataChanges] = + 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 && rawConfig.mappingAccounts.length > 0) { + 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 = {} + 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 ? ( + + + + + + + + {Object.keys(changes).map((key) => { + const { prev, new: newProductMetadata } = changes[key] + const diff = Object.keys(prev).filter( + (k) => prev[k] !== newProductMetadata[k] + ) + return ( + + {diff.map((k) => ( + + + + + ))} + + ) + })} +
+ Description + + Value +
+ {k + .split('_') + .map((word) => capitalizeFirstLetter(word)) + .join(' ')} + + {newProductMetadata[k]} +
+ ) : ( +

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 ( +
+ } + /> +
+
+

Update Product Metadata

+
+
+
+
+
+ +
+
+
+ {dataIsLoading ? ( +
+ +
+ ) : ( +
+
+ +
+
+ +
+
+ )} +
+
+
+ ) +} + +export default UpdateProductMetadata diff --git a/governance/xc_admin/packages/xc_admin_frontend/package.json b/governance/xc_admin/packages/xc_admin_frontend/package.json index e6002cb23c..3d4544da6a 100644 --- a/governance/xc_admin/packages/xc_admin_frontend/package.json +++ b/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", 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 10bb8ae609..bd99957b31 100644 --- a/governance/xc_admin/packages/xc_admin_frontend/pages/index.tsx +++ b/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 ? ( + ) : tabInfoArray[currentTabIndex].queryString === + TAB_INFO.UpdateProductMetadata.queryString ? ( + ) : null} diff --git a/package-lock.json b/package-lock.json index fd019357a8..5f7c37a74a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1395,7 +1395,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", @@ -27210,6 +27210,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" @@ -27539,6 +27540,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" @@ -85134,7 +85136,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", From 7439f3fec8adfe37b865b561b6f5eb0c9eae6f78 Mon Sep 17 00:00:00 2001 From: Daniel Chew Date: Wed, 1 Feb 2023 12:51:43 +0900 Subject: [PATCH 02/12] add first iteration of general tab --- .../components/tabs/General.tsx | 185 ++++++++++++++++++ .../xc_admin_frontend/pages/index.tsx | 13 +- 2 files changed, 196 insertions(+), 2 deletions(-) create mode 100644 governance/xc_admin/packages/xc_admin_frontend/components/tabs/General.tsx diff --git a/governance/xc_admin/packages/xc_admin_frontend/components/tabs/General.tsx b/governance/xc_admin/packages/xc_admin_frontend/components/tabs/General.tsx new file mode 100644 index 0000000000..1cd56072f0 --- /dev/null +++ b/governance/xc_admin/packages/xc_admin_frontend/components/tabs/General.tsx @@ -0,0 +1,185 @@ +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 { useContext, useEffect, useState } from 'react' +import { ClusterContext } from '../../contexts/ClusterContext' +import { usePythContext } from '../../contexts/PythContext' +import { useMultisig } from '../../hooks/useMultisig' +import ClusterSwitch from '../ClusterSwitch' +import Modal from '../common/Modal' +import Spinner from '../common/Spinner' +import Loadbar from '../loaders/Loadbar' + +const General = () => { + const [data, setData] = 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 && rawConfig.mappingAccounts.length > 0) { + const symbolToData: any = {} + rawConfig.mappingAccounts + .sort( + (mapping1, mapping2) => + mapping2.products.length - mapping1.products.length + )[0] + .products.sort((product1, product2) => + product1.metadata.symbol.localeCompare(product2.metadata.symbol) + ) + .map( + (product) => + (symbolToData[product.metadata.symbol] = { + address: product.address.toBase58(), + metadata: product.metadata, + priceAccounts: product.priceAccounts.map((p) => { + return { + address: p.address.toBase58(), + publishers: p.publishers.map((p) => p.toBase58()), + expo: p.expo, + minPub: p.minPub, + } + }), + }) + ) + setData(symbolToData) + } + }, [rawConfig, dataIsLoading]) + + // 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', `data-${cluster}.json`) + document.body.appendChild(downloadAnchor) // required for firefox + downloadAnchor.click() + downloadAnchor.remove() + } + + const ModalContent = ({ changes }: { changes: any }) => { + return ( + <> + {Object.keys(changes).length > 0 ? ( + + + + + + + +
+ Description + + ID +
+ ) : ( +

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 ( +
+ } + /> +
+
+

General

+
+
+
+
+
+ +
+
+
+ {dataIsLoading ? ( +
+ +
+ ) : ( +
+
+ +
+
+ +
+
+ )} +
+
+
+ ) +} + +export default General 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 bd99957b31..28e2cabc4d 100644 --- a/governance/xc_admin/packages/xc_admin_frontend/pages/index.tsx +++ b/governance/xc_admin/packages/xc_admin_frontend/pages/index.tsx @@ -4,6 +4,7 @@ import { useRouter } from 'next/router' import { useEffect, useState } from 'react' 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 UpdatePermissions from '../components/tabs/UpdatePermissions' import UpdateProductMetadata from '../components/tabs/UpdateProductMetadata' @@ -11,6 +12,11 @@ import { PythContextProvider } from '../contexts/PythContext' import { classNames } from '../utils/classNames' const TAB_INFO = { + General: { + title: 'General', + description: 'General panel for the program.', + queryString: 'general', + }, MinPublishers: { title: 'Min Publishers', description: @@ -76,7 +82,7 @@ const Home: NextPage = () => { selectedIndex={currentTabIndex} onChange={handleChangeTab} > - + {Object.entries(TAB_INFO).map((tab, idx) => ( { {tabInfoArray[currentTabIndex].queryString === - TAB_INFO.MinPublishers.queryString ? ( + TAB_INFO.General.queryString ? ( + + ) : tabInfoArray[currentTabIndex].queryString === + TAB_INFO.MinPublishers.queryString ? ( ) : tabInfoArray[currentTabIndex].queryString === TAB_INFO.UpdatePermissions.queryString ? ( From cb7e1f77cb87d44195db15ce4e0b6330351450b8 Mon Sep 17 00:00:00 2001 From: Daniel Chew Date: Thu, 2 Feb 2023 00:11:45 +0900 Subject: [PATCH 03/12] add more functions --- .../components/tabs/General.tsx | 133 +++++++++++++++++- 1 file changed, 129 insertions(+), 4 deletions(-) diff --git a/governance/xc_admin/packages/xc_admin_frontend/components/tabs/General.tsx b/governance/xc_admin/packages/xc_admin_frontend/components/tabs/General.tsx index 1cd56072f0..346773d17f 100644 --- a/governance/xc_admin/packages/xc_admin_frontend/components/tabs/General.tsx +++ b/governance/xc_admin/packages/xc_admin_frontend/components/tabs/General.tsx @@ -7,16 +7,20 @@ import { PythOracle } from '@pythnetwork/client/lib/anchor' import { useAnchorWallet, useWallet } from '@solana/wallet-adapter-react' import { WalletModalButton } from '@solana/wallet-adapter-react-ui' import { useContext, useEffect, useState } from 'react' +import toast from 'react-hot-toast' import { ClusterContext } from '../../contexts/ClusterContext' import { usePythContext } from '../../contexts/PythContext' import { useMultisig } from '../../hooks/useMultisig' +import { PriceRawConfig } from '../../hooks/usePyth' +import { capitalizeFirstLetter } from '../../utils/capitalizeFirstLetter' import ClusterSwitch from '../ClusterSwitch' import Modal from '../common/Modal' import Spinner from '../common/Spinner' import Loadbar from '../loaders/Loadbar' const General = () => { - const [data, setData] = useState({}) + const [data, setData] = useState<{ [key: string]: any }>({}) + const [dataChanges, setDataChanges] = useState>() const [isModalOpen, setIsModalOpen] = useState(false) const [isSendProposalButtonLoading, setIsSendProposalButtonLoading] = useState(false) @@ -40,7 +44,7 @@ const General = () => { useEffect(() => { if (!dataIsLoading && rawConfig && rawConfig.mappingAccounts.length > 0) { - const symbolToData: any = {} + let symbolToData: any = {} rawConfig.mappingAccounts .sort( (mapping1, mapping2) => @@ -64,10 +68,73 @@ const General = () => { }), }) ) + symbolToData = sortData(symbolToData) setData(symbolToData) } }, [rawConfig, dataIsLoading]) + const sortData = (data: any) => { + const sortedData: any = {} + Object.keys(data) + .sort() + .forEach((key) => { + const sortedInnerData: any = {} + Object.keys(data[key]) + .sort() + .forEach((innerKey) => { + if (innerKey === 'metadata') { + sortedInnerData[innerKey] = sortObjectByKeys(data[key][innerKey]) + } else if (innerKey === 'priceAccounts') { + // sort price accounts by address + sortedInnerData[innerKey] = data[key][innerKey].sort( + ( + priceAccount1: PriceRawConfig, + priceAccount2: PriceRawConfig + ) => + priceAccount1.address + .toBase58() + .localeCompare(priceAccount2.address.toBase58()) + ) + // sort price accounts keys + sortedInnerData[innerKey] = sortedInnerData[innerKey].map( + (priceAccount: any) => { + const sortedPriceAccount: any = {} + Object.keys(priceAccount) + .sort() + .forEach((priceAccountKey) => { + if (priceAccountKey === 'publishers') { + sortedPriceAccount[priceAccountKey] = priceAccount[ + priceAccountKey + ].sort((pub1: string, pub2: string) => + pub1.localeCompare(pub2) + ) + } else { + sortedPriceAccount[priceAccountKey] = + priceAccount[priceAccountKey] + } + }) + return sortedPriceAccount + } + ) + } else { + sortedInnerData[innerKey] = data[key][innerKey] + } + }) + sortedData[key] = sortedInnerData + }) + return sortedData + } + + const sortObjectByKeys = (obj: any) => { + const sortedObj: any = {} + Object.keys(obj) + .sort() + .forEach((key) => { + sortedObj[key] = obj[key] + }) + return sortedObj + } + // function to download json file const handleDownloadJsonButtonClick = () => { const dataStr = @@ -81,6 +148,64 @@ const General = () => { downloadAnchor.remove() } + // function to upload json file and update changes 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] + changes[symbol].new = fileDataParsed[symbol] + } + }) + setDataChanges(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 ModalContent = ({ changes }: { changes: any }) => { return ( <> @@ -138,7 +263,7 @@ const General = () => { isModalOpen={isModalOpen} setIsModalOpen={setIsModalOpen} closeModal={closeModal} - content={} + content={} />
@@ -169,7 +294,7 @@ const General = () => {
From fc9503041d25895dccc9e3ec80a673b9ced1fe97 Mon Sep 17 00:00:00 2001 From: Daniel Chew Date: Fri, 3 Feb 2023 23:17:54 +0900 Subject: [PATCH 04/12] fix ModalContent to show diff --- .../components/tabs/General.tsx | 262 ++++++++++++++---- .../components/tabs/UpdatePermissions.tsx | 6 +- .../components/tabs/UpdateProductMetadata.tsx | 2 + .../xc_admin_frontend/hooks/useMultisig.ts | 1 - .../xc_admin_frontend/pages/index.tsx | 11 +- 5 files changed, 223 insertions(+), 59 deletions(-) diff --git a/governance/xc_admin/packages/xc_admin_frontend/components/tabs/General.tsx b/governance/xc_admin/packages/xc_admin_frontend/components/tabs/General.tsx index 346773d17f..8338662556 100644 --- a/governance/xc_admin/packages/xc_admin_frontend/components/tabs/General.tsx +++ b/governance/xc_admin/packages/xc_admin_frontend/components/tabs/General.tsx @@ -1,12 +1,9 @@ import { AnchorProvider, Program, Wallet } from '@coral-xyz/anchor' -import { - getPythProgramKeyForCluster, - pythOracleProgram, -} from '@pythnetwork/client' -import { PythOracle } from '@pythnetwork/client/lib/anchor' +import { getPythProgramKeyForCluster } from '@pythnetwork/client' +import { PythOracle, pythOracleProgram } from '@pythnetwork/client/lib/anchor' import { useAnchorWallet, useWallet } from '@solana/wallet-adapter-react' import { WalletModalButton } from '@solana/wallet-adapter-react-ui' -import { useContext, useEffect, useState } from 'react' +import { useCallback, useContext, useEffect, useState } from 'react' import toast from 'react-hot-toast' import { ClusterContext } from '../../contexts/ClusterContext' import { usePythContext } from '../../contexts/PythContext' @@ -19,7 +16,7 @@ import Spinner from '../common/Spinner' import Loadbar from '../loaders/Loadbar' const General = () => { - const [data, setData] = useState<{ [key: string]: any }>({}) + const [data, setData] = useState({}) const [dataChanges, setDataChanges] = useState>() const [isModalOpen, setIsModalOpen] = useState(false) const [isSendProposalButtonLoading, setIsSendProposalButtonLoading] = @@ -42,37 +39,6 @@ const General = () => { setIsModalOpen(false) } - useEffect(() => { - if (!dataIsLoading && rawConfig && rawConfig.mappingAccounts.length > 0) { - let symbolToData: any = {} - rawConfig.mappingAccounts - .sort( - (mapping1, mapping2) => - mapping2.products.length - mapping1.products.length - )[0] - .products.sort((product1, product2) => - product1.metadata.symbol.localeCompare(product2.metadata.symbol) - ) - .map( - (product) => - (symbolToData[product.metadata.symbol] = { - address: product.address.toBase58(), - metadata: product.metadata, - priceAccounts: product.priceAccounts.map((p) => { - return { - address: p.address.toBase58(), - publishers: p.publishers.map((p) => p.toBase58()), - expo: p.expo, - minPub: p.minPub, - } - }), - }) - ) - symbolToData = sortData(symbolToData) - setData(symbolToData) - } - }, [rawConfig, dataIsLoading]) - const sortData = (data: any) => { const sortedData: any = {} Object.keys(data) @@ -124,6 +90,39 @@ const General = () => { }) return sortedData } + const sortDataMemo = useCallback(sortData, []) + + useEffect(() => { + if (!dataIsLoading && rawConfig && rawConfig.mappingAccounts.length > 0) { + const symbolToData: any = {} + rawConfig.mappingAccounts + .sort( + (mapping1, mapping2) => + mapping2.products.length - mapping1.products.length + )[0] + .products.sort((product1, product2) => + product1.metadata.symbol.localeCompare(product2.metadata.symbol) + ) + .map((product) => { + symbolToData[product.metadata.symbol] = { + address: product.address.toBase58(), + metadata: { + ...product.metadata, + }, + priceAccounts: product.priceAccounts.map((p) => ({ + address: p.address.toBase58(), + publishers: p.publishers.map((p) => p.toBase58()), + expo: p.expo, + minPub: p.minPub, + })), + } + // these fields are immutable and should not be updated + delete symbolToData[product.metadata.symbol].metadata.symbol + delete symbolToData[product.metadata.symbol].metadata.price_account + }) + setData(sortDataMemo(symbolToData)) + } + }, [rawConfig, dataIsLoading, sortDataMemo]) const sortObjectByKeys = (obj: any) => { const sortedObj: any = {} @@ -206,21 +205,188 @@ const General = () => { return true } + const AddressChangesRow = ({ changes }: { changes: any }) => { + const key = 'address' + return ( + <> + {changes.prev !== changes.new && ( + + + {key + .split('_') + .map((word) => capitalizeFirstLetter(word)) + .join(' ')} + + + {changes.prev} +
+ {changes.new} + + + )} + + ) + } + + const MetadataChangesRows = ({ changes }: { changes: any }) => { + return ( + <> + {Object.keys(changes.new).map( + (metadataKey) => + changes.prev[metadataKey] !== changes.new[metadataKey] && ( + + + {metadataKey + .split('_') + .map((word) => capitalizeFirstLetter(word)) + .join(' ')} + + + + {changes.prev[metadataKey]} +
+ {changes.new[metadataKey]} + + + ) + )} + + ) + } + + const PriceAccountsChangesRows = ({ changes }: { changes: any }) => { + return ( + <> + {changes.new.map((priceAccount: any, index: number) => + Object.keys(priceAccount).map((priceAccountKey) => + priceAccountKey === 'publishers' && + JSON.stringify(changes.prev[index][priceAccountKey]) !== + JSON.stringify(priceAccount[priceAccountKey]) ? ( + + ) : ( + priceAccountKey !== 'publishers' && + changes.prev[index][priceAccountKey] !== + priceAccount[priceAccountKey] && ( + + + {priceAccountKey + + .split('_') + .map((word) => capitalizeFirstLetter(word)) + .join(' ')} + + + {changes.prev[index][priceAccountKey]} +
+ {priceAccount[priceAccountKey]} + + + ) + ) + ) + )} + + ) + } + + const PublisherKeysChangesRows = ({ changes }: { changes: any }) => { + const publisherKeysToAdd = changes.new.filter( + (newPublisher: string) => !changes.prev.includes(newPublisher) + ) + const publisherKeysToRemove = changes.prev.filter( + (prevPublisher: string) => !changes.new.includes(prevPublisher) + ) + return ( + <> + {publisherKeysToAdd.length > 0 && ( + + Add Publisher(s) + + {publisherKeysToAdd.map((publisherKey: string) => ( + + {publisherKey} + + ))} + + + )} + {publisherKeysToRemove.length > 0 && ( + + Remove Publisher(s) + + {publisherKeysToRemove.map((publisherKey: string) => ( + + {publisherKey} + + ))} + + + )} + + ) + } + const ModalContent = ({ changes }: { changes: any }) => { return ( <> {Object.keys(changes).length > 0 ? ( - - - - - - + {/* compare changes.prev and changes.new and display the fields that are different */} + {Object.keys(changes).map((key) => { + const { prev, new: newChanges } = changes[key] + const diff = Object.keys(prev).filter( + (k) => JSON.stringify(prev[k]) !== JSON.stringify(newChanges[k]) + ) + return ( + + + + + {diff.map((k) => + k === 'address' ? ( + + ) : k === 'metadata' ? ( + + ) : k === 'priceAccounts' ? ( + + ) : null + )} + + {/* add a divider only if its not the last item */} + {Object.keys(changes).indexOf(key) !== + Object.keys(changes).length - 1 ? ( + + + + ) : null} + + ) + })}
- Description - - ID -
+ {key} +
+
+
) : (

No proposed changes.

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 88280c3575..6a835d0fef 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 @@ -17,12 +17,12 @@ import copy from 'copy-to-clipboard' import { useContext, useEffect, useState } from 'react' import toast from 'react-hot-toast' import { - proposeInstructions, - getMultisigCluster, BPF_UPGRADABLE_LOADER, + getMultisigCluster, isRemoteCluster, - WORMHOLE_ADDRESS, mapKey, + proposeInstructions, + WORMHOLE_ADDRESS, } from 'xc_admin_common' import { ClusterContext } from '../../contexts/ClusterContext' import { usePythContext } from '../../contexts/PythContext' diff --git a/governance/xc_admin/packages/xc_admin_frontend/components/tabs/UpdateProductMetadata.tsx b/governance/xc_admin/packages/xc_admin_frontend/components/tabs/UpdateProductMetadata.tsx index 20dd45fd64..0040c1573b 100644 --- a/governance/xc_admin/packages/xc_admin_frontend/components/tabs/UpdateProductMetadata.tsx +++ b/governance/xc_admin/packages/xc_admin_frontend/components/tabs/UpdateProductMetadata.tsx @@ -254,6 +254,8 @@ const UpdateProductMetadata = () => { .join(' ')} + {prev[k]} +
{newProductMetadata[k]} diff --git a/governance/xc_admin/packages/xc_admin_frontend/hooks/useMultisig.ts b/governance/xc_admin/packages/xc_admin_frontend/hooks/useMultisig.ts index a3463d01aa..56a946f99c 100644 --- a/governance/xc_admin/packages/xc_admin_frontend/hooks/useMultisig.ts +++ b/governance/xc_admin/packages/xc_admin_frontend/hooks/useMultisig.ts @@ -1,5 +1,4 @@ import { Wallet } from '@coral-xyz/anchor' -import { PythCluster } from '@pythnetwork/client/lib/cluster' import { Cluster, Connection, PublicKey } from '@solana/web3.js' import SquadsMesh from '@sqds/mesh' import { TransactionAccount } from '@sqds/mesh/lib/types' 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 28e2cabc4d..84ddf9b80b 100644 --- a/governance/xc_admin/packages/xc_admin_frontend/pages/index.tsx +++ b/governance/xc_admin/packages/xc_admin_frontend/pages/index.tsx @@ -40,7 +40,7 @@ const TAB_INFO = { }, } -const DEFAULT_TAB = 'min-publishers' +const DEFAULT_TAB = 'general' const Home: NextPage = () => { const [currentTabIndex, setCurrentTabIndex] = useState(0) @@ -82,17 +82,14 @@ const Home: NextPage = () => { selectedIndex={currentTabIndex} onChange={handleChangeTab} > - + {Object.entries(TAB_INFO).map((tab, idx) => ( classNames( - 'p-3 text-xs font-semibold uppercase outline-none transition-colors md:text-base', - currentTabIndex === idx - ? 'bg-darkGray3' - : 'bg-darkGray2', - selected ? 'bg-darkGray3' : 'hover:bg-darkGray3' + 'p-3 text-xs font-semibold uppercase outline-none transition-colors hover:bg-darkGray3 md:text-base', + selected ? 'bg-darkGray3' : 'bg-darkGray2' ) } > From ce4620379e76364e05761bab860b3fdc8a73e0e2 Mon Sep 17 00:00:00 2001 From: Daniel Chew Date: Mon, 6 Feb 2023 17:26:21 +0900 Subject: [PATCH 05/12] add feature to add new price account --- .../packages/xc_admin_common/src/multisig.ts | 7 + .../components/tabs/AddRemovePublishers.tsx | 2 +- .../components/tabs/General.tsx | 395 ++++++++++++++---- 3 files changed, 331 insertions(+), 73 deletions(-) diff --git a/governance/xc_admin/packages/xc_admin_common/src/multisig.ts b/governance/xc_admin/packages/xc_admin_common/src/multisig.ts index 1f5781b9d1..73694e9a7a 100644 --- a/governance/xc_admin/packages/xc_admin_common/src/multisig.ts +++ b/governance/xc_admin/packages/xc_admin_common/src/multisig.ts @@ -8,6 +8,13 @@ import { InstructionAccount, TransactionAccount } from "@sqds/mesh/lib/types"; import BN from "bn.js"; import lodash from "lodash"; +/** + * Address of the ops key (same on all networks) + */ +export const OPS_KEY = new PublicKey( + "ACzP6RC98vcBk9oTeAwcH1o5HJvtBzU59b5nqdwc7Cxy" +); + /** * Find all proposals for vault `vault` using Squads client `squad` * @param squad Squads client 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 index ba40604801..ba1c541b7e 100644 --- a/governance/xc_admin/packages/xc_admin_frontend/components/tabs/AddRemovePublishers.tsx +++ b/governance/xc_admin/packages/xc_admin_frontend/components/tabs/AddRemovePublishers.tsx @@ -175,7 +175,7 @@ const AddRemovePublishers = () => { 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 + // check if there are any new publishers to add by comparing prev and new const publisherKeysToAdd = newPublisherKeys.filter( (newPublisher) => !prev.includes(newPublisher) ) diff --git a/governance/xc_admin/packages/xc_admin_frontend/components/tabs/General.tsx b/governance/xc_admin/packages/xc_admin_frontend/components/tabs/General.tsx index 8338662556..e8c797400b 100644 --- a/governance/xc_admin/packages/xc_admin_frontend/components/tabs/General.tsx +++ b/governance/xc_admin/packages/xc_admin_frontend/components/tabs/General.tsx @@ -3,12 +3,17 @@ import { getPythProgramKeyForCluster } from '@pythnetwork/client' import { PythOracle, pythOracleProgram } 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 { useCallback, useContext, useEffect, useState } from 'react' import toast from 'react-hot-toast' +import { + getMultisigCluster, + OPS_KEY, + proposeInstructions, +} from 'xc_admin_common' import { ClusterContext } from '../../contexts/ClusterContext' import { usePythContext } from '../../contexts/PythContext' -import { useMultisig } from '../../hooks/useMultisig' -import { PriceRawConfig } from '../../hooks/usePyth' +import { SECURITY_MULTISIG, useMultisig } from '../../hooks/useMultisig' import { capitalizeFirstLetter } from '../../utils/capitalizeFirstLetter' import ClusterSwitch from '../ClusterSwitch' import Modal from '../common/Modal' @@ -18,6 +23,7 @@ import Loadbar from '../loaders/Loadbar' const General = () => { const [data, setData] = useState({}) const [dataChanges, setDataChanges] = useState>() + const [existingSymbols, setExistingSymbols] = useState>(new Set()) const [isModalOpen, setIsModalOpen] = useState(false) const [isSendProposalButtonLoading, setIsSendProposalButtonLoading] = useState(false) @@ -53,13 +59,8 @@ const General = () => { } else if (innerKey === 'priceAccounts') { // sort price accounts by address sortedInnerData[innerKey] = data[key][innerKey].sort( - ( - priceAccount1: PriceRawConfig, - priceAccount2: PriceRawConfig - ) => - priceAccount1.address - .toBase58() - .localeCompare(priceAccount2.address.toBase58()) + (priceAccount1: any, priceAccount2: any) => + priceAccount1.address.localeCompare(priceAccount2.address) ) // sort price accounts keys sortedInnerData[innerKey] = sortedInnerData[innerKey].map( @@ -120,6 +121,7 @@ const General = () => { delete symbolToData[product.metadata.symbol].metadata.symbol delete symbolToData[product.metadata.symbol].metadata.price_account }) + setExistingSymbols(new Set(Object.keys(symbolToData))) setData(sortDataMemo(symbolToData)) } }, [rawConfig, dataIsLoading, sortDataMemo]) @@ -162,7 +164,12 @@ const General = () => { const fileDataParsed = sortData(JSON.parse(fileData as string)) const changes: Record = {} Object.keys(fileDataParsed).forEach((symbol) => { - if ( + if (!existingSymbols.has(symbol)) { + // if symbol is not in existing symbols, create new entry + changes[symbol] = { new: {} } + changes[symbol].new = fileDataParsed[symbol] + } else if ( + // if symbol is in existing symbols, check if data is different JSON.stringify(data[symbol]) !== JSON.stringify(fileDataParsed[symbol]) ) { @@ -190,21 +197,207 @@ const General = () => { 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 && dataChanges) { + const instructions: TransactionInstruction[] = [] + Object.keys(dataChanges).forEach(async (symbol) => { + const { prev, new: newChanges } = dataChanges[symbol] + // if prev is undefined, it means that the symbol is new + if (!prev) { + // deterministically generate product account key + const productAccountKey = await PublicKey.createWithSeed( + OPS_KEY, + 'product:' + symbol, + pythProgramClient.programId + ) + // create add product account instruction + instructions.push( + await pythProgramClient.methods + .addProduct() + .accounts({ + fundingAccount: squads?.getAuthorityPDA( + SECURITY_MULTISIG[getMultisigCluster(cluster)], + 1 + ), + tailMappingAccount: rawConfig.mappingAccounts[0].address, + productAccount: productAccountKey, + }) + .instruction() + ) + // create update product account instruction + instructions.push( + await pythProgramClient.methods + .updProduct(newChanges.metadata) + .accounts({ + fundingAccount: squads?.getAuthorityPDA( + SECURITY_MULTISIG[getMultisigCluster(cluster)], + 1 + ), + productAccount: productAccountKey, + }) + .instruction() + ) + // deterministically generate price account key + const priceAccountKey = await PublicKey.createWithSeed( + OPS_KEY, + 'price:' + symbol, + pythProgramClient.programId + ) + // create add price account instruction + instructions.push( + await pythProgramClient.methods + .addPrice(newChanges.priceAccounts[0].expo, 1) + .accounts({ + fundingAccount: squads?.getAuthorityPDA( + SECURITY_MULTISIG[getMultisigCluster(cluster)], + 1 + ), + productAccount: productAccountKey, + priceAccount: priceAccountKey, + }) + .instruction() + ) + + // create add publisher instruction if there are any publishers + if (newChanges.priceAccounts[0].publishers.length > 0) { + newChanges.priceAccounts[0].publishers.forEach( + (publisherKey: string) => { + pythProgramClient.methods + .addPublisher(new PublicKey(publisherKey)) + .accounts({ + fundingAccount: squads?.getAuthorityPDA( + SECURITY_MULTISIG[getMultisigCluster(cluster)], + 1 + ), + priceAccount: priceAccountKey, + }) + .instruction() + .then((instruction) => instructions.push(instruction)) + } + ) + } + + // create set min publisher instruction if there are any publishers + if (newChanges.priceAccounts[0].minPub) { + instructions.push( + await pythProgramClient.methods + .setMinPub(newChanges.priceAccounts[0].minPub, [0, 0, 0]) + .accounts({ + priceAccount: priceAccountKey, + fundingAccount: squads?.getAuthorityPDA( + SECURITY_MULTISIG[getMultisigCluster(cluster)], + 1 + ), + }) + .instruction() + ) + } + } else { + // check if metadata has changed, or minPub has changed, or publishers have changed + // check if metadata has changed + // check if there are any new metadata by comparing prev and new values + if ( + JSON.stringify(prev.metadata) !== + JSON.stringify(newChanges.metadata) + ) { + // create update product account instruction + instructions.push( + await pythProgramClient.methods + .updProduct(newChanges.metadata) + .accounts({ + fundingAccount: squads?.getAuthorityPDA( + SECURITY_MULTISIG[getMultisigCluster(cluster)], + 1 + ), + productAccount: new PublicKey(prev.address), + }) + .instruction() + ) + } + // check if minPub has changed + if ( + prev.priceAccounts[0].minPub !== newChanges.priceAccounts[0].minPub + ) { + // create update product account instruction + instructions.push( + await pythProgramClient.methods + .setMinPub(newChanges.priceAccounts[0].minPub, [0, 0, 0]) + .accounts({ + priceAccount: new PublicKey(prev.priceAccounts[0].address), + fundingAccount: squads?.getAuthorityPDA( + SECURITY_MULTISIG[getMultisigCluster(cluster)], + 1 + ), + }) + .instruction() + ) + } + + // check if publishers have changed + // check if there are any new publishers to add by comparing prev and new + const publisherKeysToAdd = + newChanges.priceAccounts[0].publishers.filter( + (newPublisher: string) => + !prev.priceAccounts[0].publishers.includes(newPublisher) + ) + // check if there are any publishers to remove by comparing prev and new + const publisherKeysToRemove = prev.priceAccounts[0].publishers.filter( + (prevPublisher: string) => + !newChanges.priceAccounts[0].publishers.includes(prevPublisher) + ) + + // add instructions to add new publishers + publisherKeysToAdd.forEach((publisherKey: string) => { + pythProgramClient.methods + .addPublisher(new PublicKey(publisherKey)) + .accounts({ + fundingAccount: squads?.getAuthorityPDA( + SECURITY_MULTISIG[getMultisigCluster(cluster)], + 1 + ), + priceAccount: new PublicKey(prev.priceAccounts[0].address), + }) + .instruction() + .then((instruction) => instructions.push(instruction)) + }) + // add instructions to remove publishers + publisherKeysToRemove.forEach((publisherKey: string) => { + pythProgramClient.methods + .delPublisher(new PublicKey(publisherKey)) + .accounts({ + fundingAccount: squads?.getAuthorityPDA( + SECURITY_MULTISIG[getMultisigCluster(cluster)], + 1 + ), + priceAccount: new PublicKey(prev.priceAccounts[0].address), + }) + .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 AddressChangesRow = ({ changes }: { changes: any }) => { const key = 'address' return ( @@ -229,11 +422,15 @@ const General = () => { } const MetadataChangesRows = ({ changes }: { changes: any }) => { + const addNewPriceFeed = + changes.prev === undefined && Object.keys(changes.new).length > 0 + return ( <> {Object.keys(changes.new).map( (metadataKey) => - changes.prev[metadataKey] !== changes.new[metadataKey] && ( + (addNewPriceFeed || + changes.prev[metadataKey] !== changes.new[metadataKey]) && ( {metadataKey @@ -243,8 +440,12 @@ const General = () => { - {changes.prev[metadataKey]} -
+ {!addNewPriceFeed ? ( + <> + {changes.prev[metadataKey]} +
{' '} + + ) : null} {changes.new[metadataKey]} @@ -255,35 +456,50 @@ const General = () => { } const PriceAccountsChangesRows = ({ changes }: { changes: any }) => { + const addNewPriceFeed = + changes.prev === undefined && Object.keys(changes.new).length > 0 return ( <> {changes.new.map((priceAccount: any, index: number) => Object.keys(priceAccount).map((priceAccountKey) => - priceAccountKey === 'publishers' && - JSON.stringify(changes.prev[index][priceAccountKey]) !== - JSON.stringify(priceAccount[priceAccountKey]) ? ( - + priceAccountKey === 'publishers' ? ( + addNewPriceFeed ? ( + + ) : ( + JSON.stringify(changes.prev[index][priceAccountKey]) !== + JSON.stringify(priceAccount[priceAccountKey]) && ( + + ) + ) ) : ( - priceAccountKey !== 'publishers' && - changes.prev[index][priceAccountKey] !== - priceAccount[priceAccountKey] && ( + (addNewPriceFeed || + changes.prev[index][priceAccountKey] !== + priceAccount[priceAccountKey]) && ( {priceAccountKey - .split('_') .map((word) => capitalizeFirstLetter(word)) .join(' ')} - {changes.prev[index][priceAccountKey]} -
+ {!addNewPriceFeed ? ( + <> + {changes.prev[index][priceAccountKey]} +
+ + ) : null} {priceAccount[priceAccountKey]} @@ -296,12 +512,17 @@ const General = () => { } const PublisherKeysChangesRows = ({ changes }: { changes: any }) => { - const publisherKeysToAdd = changes.new.filter( - (newPublisher: string) => !changes.prev.includes(newPublisher) - ) - const publisherKeysToRemove = changes.prev.filter( - (prevPublisher: string) => !changes.new.includes(prevPublisher) - ) + const addNewPriceFeed = changes.prev === undefined && changes.new.length > 0 + const publisherKeysToAdd = addNewPriceFeed + ? changes.new + : changes.new.filter( + (newPublisher: string) => !changes.prev.includes(newPublisher) + ) + const publisherKeysToRemove = addNewPriceFeed + ? [] + : changes.prev.filter( + (prevPublisher: string) => !changes.new.includes(prevPublisher) + ) return ( <> {publisherKeysToAdd.length > 0 && ( @@ -332,6 +553,27 @@ const General = () => { ) } + const NewPriceFeedsRows = ({ priceFeedData }: { priceFeedData: any }) => { + const key = + priceFeedData.metadata.asset_type + + '.' + + priceFeedData.metadata.base + + '/' + + priceFeedData.metadata.quote_currency + return ( + <> + + + + ) + } + const ModalContent = ({ changes }: { changes: any }) => { return ( <> @@ -340,9 +582,14 @@ const General = () => { {/* compare changes.prev and changes.new and display the fields that are different */} {Object.keys(changes).map((key) => { const { prev, new: newChanges } = changes[key] - const diff = Object.keys(prev).filter( - (k) => JSON.stringify(prev[k]) !== JSON.stringify(newChanges[k]) - ) + const addNewPriceFeed = + prev === undefined && Object.keys(newChanges).length > 0 + const diff = addNewPriceFeed + ? [] + : Object.keys(prev).filter( + (k) => + JSON.stringify(prev[k]) !== JSON.stringify(newChanges[k]) + ) return ( @@ -350,29 +597,33 @@ const General = () => { className="base16 py-4 pl-6 pr-2 font-bold lg:pl-6" colSpan={2} > - {key} + {addNewPriceFeed ? 'Add New Price Feed' : key} - {diff.map((k) => - k === 'address' ? ( - - ) : k === 'metadata' ? ( - - ) : k === 'priceAccounts' ? ( - - ) : null + {addNewPriceFeed ? ( + + ) : ( + diff.map((k) => + k === 'address' ? ( + + ) : k === 'metadata' ? ( + + ) : k === 'priceAccounts' ? ( + + ) : null + ) )} {/* add a divider only if its not the last item */} @@ -399,7 +650,7 @@ const General = () => { ) : ( From 2c46c91697f5bd38114b6a25bab24972a033b4ec Mon Sep 17 00:00:00 2001 From: Daniel Chew Date: Tue, 7 Feb 2023 00:43:07 +0900 Subject: [PATCH 06/12] ignore price account address in json when adding new price feeds --- .../packages/xc_admin_frontend/components/tabs/General.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/governance/xc_admin/packages/xc_admin_frontend/components/tabs/General.tsx b/governance/xc_admin/packages/xc_admin_frontend/components/tabs/General.tsx index e8c797400b..1c6cc7092d 100644 --- a/governance/xc_admin/packages/xc_admin_frontend/components/tabs/General.tsx +++ b/governance/xc_admin/packages/xc_admin_frontend/components/tabs/General.tsx @@ -482,7 +482,7 @@ const General = () => { /> ) ) - ) : ( + ) : priceAccountKey === 'address' && addNewPriceFeed ? null : ( (addNewPriceFeed || changes.prev[index][priceAccountKey] !== priceAccount[priceAccountKey]) && ( From 1322b800b03902505357630495e28c44f333bc1a Mon Sep 17 00:00:00 2001 From: Daniel Chew Date: Tue, 7 Feb 2023 00:51:44 +0900 Subject: [PATCH 07/12] ignore product address and price account address in json for new pricefeed --- .../xc_admin_frontend/components/tabs/General.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/governance/xc_admin/packages/xc_admin_frontend/components/tabs/General.tsx b/governance/xc_admin/packages/xc_admin_frontend/components/tabs/General.tsx index 1c6cc7092d..d30c20dcae 100644 --- a/governance/xc_admin/packages/xc_admin_frontend/components/tabs/General.tsx +++ b/governance/xc_admin/packages/xc_admin_frontend/components/tabs/General.tsx @@ -167,15 +167,18 @@ const General = () => { if (!existingSymbols.has(symbol)) { // if symbol is not in existing symbols, create new entry changes[symbol] = { new: {} } - changes[symbol].new = fileDataParsed[symbol] + changes[symbol].new = { ...fileDataParsed[symbol] } + // these fields are generated deterministically and should not be updated + delete changes[symbol].new.address + delete changes[symbol].new.priceAccounts[0].address } else if ( // if symbol is in existing symbols, check if data is different JSON.stringify(data[symbol]) !== JSON.stringify(fileDataParsed[symbol]) ) { changes[symbol] = { prev: {}, new: {} } - changes[symbol].prev = data[symbol] - changes[symbol].new = fileDataParsed[symbol] + changes[symbol].prev = { ...data[symbol] } + changes[symbol].new = { ...fileDataParsed[symbol] } } }) setDataChanges(changes) @@ -482,7 +485,7 @@ const General = () => { /> ) ) - ) : priceAccountKey === 'address' && addNewPriceFeed ? null : ( + ) : ( (addNewPriceFeed || changes.prev[index][priceAccountKey] !== priceAccount[priceAccountKey]) && ( From 098d3288009ce9294e79773d5661b576702e8ca8 Mon Sep 17 00:00:00 2001 From: Daniel Chew Date: Tue, 7 Feb 2023 10:20:48 +0900 Subject: [PATCH 08/12] address comments --- .../components/tabs/General.tsx | 34 ++++++++++--------- .../components/tabs/UpdateProductMetadata.tsx | 1 + 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/governance/xc_admin/packages/xc_admin_frontend/components/tabs/General.tsx b/governance/xc_admin/packages/xc_admin_frontend/components/tabs/General.tsx index d30c20dcae..25878b5027 100644 --- a/governance/xc_admin/packages/xc_admin_frontend/components/tabs/General.tsx +++ b/governance/xc_admin/packages/xc_admin_frontend/components/tabs/General.tsx @@ -110,14 +110,18 @@ const General = () => { metadata: { ...product.metadata, }, - priceAccounts: product.priceAccounts.map((p) => ({ - address: p.address.toBase58(), - publishers: p.publishers.map((p) => p.toBase58()), - expo: p.expo, - minPub: p.minPub, - })), + priceAccounts: { + address: product.priceAccounts[0].address.toBase58(), + publishers: product.priceAccounts[0].publishers.map((p) => + p.toBase58() + ), + expo: product.priceAccounts[0].expo, + minPub: product.priceAccounts[0].minPub, + }, } // these fields are immutable and should not be updated + delete symbolToData[product.metadata.symbol].address + delete symbolToData[product.metadata.symbol].priceAccounts[0].address delete symbolToData[product.metadata.symbol].metadata.symbol delete symbolToData[product.metadata.symbol].metadata.price_account }) @@ -233,7 +237,7 @@ const General = () => { // create update product account instruction instructions.push( await pythProgramClient.methods - .updProduct(newChanges.metadata) + .updProduct({ ...newChanges.metadata, symbol: symbol }) .accounts({ fundingAccount: squads?.getAuthorityPDA( SECURITY_MULTISIG[getMultisigCluster(cluster)], @@ -284,7 +288,7 @@ const General = () => { } // create set min publisher instruction if there are any publishers - if (newChanges.priceAccounts[0].minPub) { + if (newChanges.priceAccounts[0].minPub !== undefined) { instructions.push( await pythProgramClient.methods .setMinPub(newChanges.priceAccounts[0].minPub, [0, 0, 0]) @@ -299,9 +303,7 @@ const General = () => { ) } } else { - // check if metadata has changed, or minPub has changed, or publishers have changed // check if metadata has changed - // check if there are any new metadata by comparing prev and new values if ( JSON.stringify(prev.metadata) !== JSON.stringify(newChanges.metadata) @@ -309,7 +311,7 @@ const General = () => { // create update product account instruction instructions.push( await pythProgramClient.methods - .updProduct(newChanges.metadata) + .updProduct({ ...newChanges.metadata, symbol: symbol }) .accounts({ fundingAccount: squads?.getAuthorityPDA( SECURITY_MULTISIG[getMultisigCluster(cluster)], @@ -340,7 +342,6 @@ const General = () => { } // check if publishers have changed - // check if there are any new publishers to add by comparing prev and new const publisherKeysToAdd = newChanges.priceAccounts[0].publishers.filter( (newPublisher: string) => @@ -426,7 +427,7 @@ const General = () => { const MetadataChangesRows = ({ changes }: { changes: any }) => { const addNewPriceFeed = - changes.prev === undefined && Object.keys(changes.new).length > 0 + changes.prev === undefined && changes.new !== undefined return ( <> @@ -460,7 +461,7 @@ const General = () => { const PriceAccountsChangesRows = ({ changes }: { changes: any }) => { const addNewPriceFeed = - changes.prev === undefined && Object.keys(changes.new).length > 0 + changes.prev === undefined && changes.new !== undefined return ( <> {changes.new.map((priceAccount: any, index: number) => @@ -515,7 +516,8 @@ const General = () => { } const PublisherKeysChangesRows = ({ changes }: { changes: any }) => { - const addNewPriceFeed = changes.prev === undefined && changes.new.length > 0 + const addNewPriceFeed = + changes.prev === undefined && changes.new !== undefined const publisherKeysToAdd = addNewPriceFeed ? changes.new : changes.new.filter( @@ -586,7 +588,7 @@ const General = () => { {Object.keys(changes).map((key) => { const { prev, new: newChanges } = changes[key] const addNewPriceFeed = - prev === undefined && Object.keys(newChanges).length > 0 + prev === undefined && newChanges !== undefined const diff = addNewPriceFeed ? [] : Object.keys(prev).filter( diff --git a/governance/xc_admin/packages/xc_admin_frontend/components/tabs/UpdateProductMetadata.tsx b/governance/xc_admin/packages/xc_admin_frontend/components/tabs/UpdateProductMetadata.tsx index 0040c1573b..98ffb9a2a9 100644 --- a/governance/xc_admin/packages/xc_admin_frontend/components/tabs/UpdateProductMetadata.tsx +++ b/governance/xc_admin/packages/xc_admin_frontend/components/tabs/UpdateProductMetadata.tsx @@ -72,6 +72,7 @@ const UpdateProductMetadata = () => { ...product.metadata, } // these fields are immutable and should not be updated + delete symbolToProductMetadataMapping[product.metadata.symbol].address delete symbolToProductMetadataMapping[product.metadata.symbol].symbol delete symbolToProductMetadataMapping[product.metadata.symbol] .price_account From 8ce87da081ee12717072e87870cefd3ef38f3f77 Mon Sep 17 00:00:00 2001 From: Daniel Chew Date: Tue, 7 Feb 2023 10:26:49 +0900 Subject: [PATCH 09/12] fix preview crashing --- .../components/tabs/General.tsx | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/governance/xc_admin/packages/xc_admin_frontend/components/tabs/General.tsx b/governance/xc_admin/packages/xc_admin_frontend/components/tabs/General.tsx index 25878b5027..a1507a252c 100644 --- a/governance/xc_admin/packages/xc_admin_frontend/components/tabs/General.tsx +++ b/governance/xc_admin/packages/xc_admin_frontend/components/tabs/General.tsx @@ -110,14 +110,16 @@ const General = () => { metadata: { ...product.metadata, }, - priceAccounts: { - address: product.priceAccounts[0].address.toBase58(), - publishers: product.priceAccounts[0].publishers.map((p) => - p.toBase58() - ), - expo: product.priceAccounts[0].expo, - minPub: product.priceAccounts[0].minPub, - }, + priceAccounts: [ + { + address: product.priceAccounts[0].address.toBase58(), + publishers: product.priceAccounts[0].publishers.map((p) => + p.toBase58() + ), + expo: product.priceAccounts[0].expo, + minPub: product.priceAccounts[0].minPub, + }, + ], } // these fields are immutable and should not be updated delete symbolToData[product.metadata.symbol].address From a50a236784c3295a113a67c02d64f16a9a5fc0d9 Mon Sep 17 00:00:00 2001 From: Guillermo Bescos Alapont Date: Mon, 6 Feb 2023 20:36:36 -0600 Subject: [PATCH 10/12] Restore addresses --- .../components/tabs/General.tsx | 49 ++++++++++++++----- 1 file changed, 36 insertions(+), 13 deletions(-) diff --git a/governance/xc_admin/packages/xc_admin_frontend/components/tabs/General.tsx b/governance/xc_admin/packages/xc_admin_frontend/components/tabs/General.tsx index a1507a252c..7342bb0477 100644 --- a/governance/xc_admin/packages/xc_admin_frontend/components/tabs/General.tsx +++ b/governance/xc_admin/packages/xc_admin_frontend/components/tabs/General.tsx @@ -110,20 +110,14 @@ const General = () => { metadata: { ...product.metadata, }, - priceAccounts: [ - { - address: product.priceAccounts[0].address.toBase58(), - publishers: product.priceAccounts[0].publishers.map((p) => - p.toBase58() - ), - expo: product.priceAccounts[0].expo, - minPub: product.priceAccounts[0].minPub, - }, - ], + priceAccounts: product.priceAccounts.map((p) => ({ + address: p.address.toBase58(), + publishers: p.publishers.map((p) => p.toBase58()), + expo: p.expo, + minPub: p.minPub, + })), } // these fields are immutable and should not be updated - delete symbolToData[product.metadata.symbol].address - delete symbolToData[product.metadata.symbol].priceAccounts[0].address delete symbolToData[product.metadata.symbol].metadata.symbol delete symbolToData[product.metadata.symbol].metadata.price_account }) @@ -206,7 +200,36 @@ const General = () => { toast.error(capitalizeFirstLetter(e.message)) return false } - return true + let isValid = true + // check if json keys "address" key is changed + const jsonParsed = JSON.parse(json) + Object.keys(jsonParsed).forEach((symbol) => { + if ( + jsonParsed[symbol].address && + jsonParsed[symbol].address !== data[symbol].address + ) { + toast.error( + `Address field for product cannot be changed for symbol ${symbol}. Please revert any changes to the address field and try again.` + ) + isValid = false + } + }) + + // check if json keys "priceAccounts" key "address" key is changed + Object.keys(jsonParsed).forEach((symbol) => { + if ( + jsonParsed[symbol].priceAccounts[0].address && + jsonParsed[symbol].priceAccounts[0].address !== + data[symbol].priceAccounts[0].address + ) { + toast.error( + `Address field for priceAccounts cannot be changed for symbol ${symbol}. Please revert any changes to the address field and try again.` + ) + isValid = false + } + }) + + return isValid } const handleSendProposalButtonClick = async () => { From d05503ab422a8d957379cb1c02af118efc8ff467 Mon Sep 17 00:00:00 2001 From: Daniel Chew Date: Tue, 7 Feb 2023 11:56:45 +0900 Subject: [PATCH 11/12] fix error when no price account exists --- .../packages/xc_admin_frontend/components/tabs/General.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/governance/xc_admin/packages/xc_admin_frontend/components/tabs/General.tsx b/governance/xc_admin/packages/xc_admin_frontend/components/tabs/General.tsx index 7342bb0477..2fbad3d147 100644 --- a/governance/xc_admin/packages/xc_admin_frontend/components/tabs/General.tsx +++ b/governance/xc_admin/packages/xc_admin_frontend/components/tabs/General.tsx @@ -218,6 +218,7 @@ const General = () => { // check if json keys "priceAccounts" key "address" key is changed Object.keys(jsonParsed).forEach((symbol) => { if ( + jsonParsed[symbol].priceAccounts[0] && jsonParsed[symbol].priceAccounts[0].address && jsonParsed[symbol].priceAccounts[0].address !== data[symbol].priceAccounts[0].address From d607e59827d2bfd9302f33993b1d12be4cac0927 Mon Sep 17 00:00:00 2001 From: Daniel Chew Date: Tue, 7 Feb 2023 11:58:15 +0900 Subject: [PATCH 12/12] fix validation --- .../packages/xc_admin_frontend/components/tabs/General.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/governance/xc_admin/packages/xc_admin_frontend/components/tabs/General.tsx b/governance/xc_admin/packages/xc_admin_frontend/components/tabs/General.tsx index 2fbad3d147..8882358b9a 100644 --- a/governance/xc_admin/packages/xc_admin_frontend/components/tabs/General.tsx +++ b/governance/xc_admin/packages/xc_admin_frontend/components/tabs/General.tsx @@ -219,6 +219,7 @@ const General = () => { Object.keys(jsonParsed).forEach((symbol) => { if ( jsonParsed[symbol].priceAccounts[0] && + data[symbol].priceAccounts[0] && jsonParsed[symbol].priceAccounts[0].address && jsonParsed[symbol].priceAccounts[0].address !== data[symbol].priceAccounts[0].address