[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
This commit is contained in:
Daniel Chew 2023-01-26 18:37:14 +09:00 committed by GitHub
parent fac47b5bef
commit e02afb7ee9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 506 additions and 57 deletions

View File

@ -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<SetStateAction<boolean>>
closeModal: () => void
changes: any
handleSendProposalButtonClick: () => void
isSendProposalButtonLoading: boolean
}> = ({
isModalOpen,
setIsModalOpen,
closeModal,
changes,
handleSendProposalButtonClick,
isSendProposalButtonLoading,
}) => {
content: any
}> = ({ isModalOpen, setIsModalOpen, closeModal, content }) => {
return (
<Transition appear show={isModalOpen} as={Fragment}>
<Dialog
as="div"
className="relative z-10"
className="relative z-40"
onClose={() => setIsModalOpen(false)}
>
<Transition.Child
@ -55,40 +45,7 @@ const Modal: React.FC<{
<Dialog.Title as="h3" className="diaglogTitle">
Proposed Changes
</Dialog.Title>
{!changes ? (
<p className="mb-8 leading-6 ">No proposed changes.</p>
) : (
Object.keys(changes).map((key) => {
if (changes[key].prev !== changes[key].new) {
return (
<div
key={key}
className="flex items-center justify-between pb-4"
>
<span className="pr-4 text-left font-bold">
{key}
</span>
<span className="mr-2">
{changes[key].prev} &rarr; {changes[key].new}
</span>
</div>
)
}
})
)}
<button
className="action-btn text-base "
onClick={handleSendProposalButtonClick}
disabled={!changes}
>
{isSendProposalButtonLoading ? (
<Spinner />
) : (
'Send Proposal'
)}
</button>
{content}
</div>
</Dialog.Panel>
</Transition.Child>

View File

@ -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<string, string> = {}
const AddRemovePublishers = () => {
const [data, setData] = useState<SymbolToPublisherKeys>({})
const [publisherChanges, setPublisherChanges] =
useState<Record<string, PublishersInfo>>()
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) {
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<string, PublishersInfo> = {}
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 ? (
<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">
ID
</th>
</tr>
</thead>
{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 && (
<tbody>
<Fragment key={key}>
<tr>
<td className="py-3 pl-6 pr-1 lg:pl-6">Product</td>
<td className="py-3 pl-1 pr-8 lg:pl-6">{key}</td>
</tr>
{publisherKeysToAdd.length > 0 && (
<tr>
<td className="py-3 pl-6 pr-1 lg:pl-6">
Add Publisher(s)
</td>
<td className="py-3 pl-1 pr-8 lg:pl-6">
{publisherKeysToAdd.map((publisherKey: string) => (
<span key={publisherKey} className="block">
{publisherKey}
</span>
))}
</td>
</tr>
)}
{publisherKeysToRemove.length > 0 && (
<tr>
<td className="py-3 pl-6 pr-1 lg:pl-6">
Remove Publisher(s)
</td>
<td className="py-3 pl-1 pr-8 lg:pl-6">
{publisherKeysToRemove.map(
(publisherKey: string) => (
<span key={publisherKey} className="block">
{publisherKey}
</span>
)
)}
</td>
</tr>
)}
</Fragment>
</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={publisherChanges} />}
/>
<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">Add/Remove Publishers</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 AddRemovePublishers

View File

@ -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 ? (
<div className="mb-10">
{Object.keys(changes).map((key) => {
return (
changes[key].prev !== changes[key].new && (
<>
<div
key={key}
className="mb-4 flex items-center justify-between"
>
<span className="pr-4 text-left font-bold">{key}</span>
<span className="mr-2">
{changes[key].prev} &rarr; {changes[key].new}
</span>
</div>
</>
)
)
})}
</div>
) : (
<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) {
@ -226,16 +272,14 @@ const MinPublishers = () => {
isModalOpen={isModalOpen}
setIsModalOpen={setIsModalOpen}
closeModal={closeModal}
changes={minPublishersChanges}
handleSendProposalButtonClick={handleSendProposalButtonClick}
isSendProposalButtonLoading={isSendProposalButtonLoading}
content={<ModalContent changes={minPublishersChanges} />}
/>
<div className="container flex flex-col items-center justify-between lg:flex-row">
<div className="mb-4 w-full text-left lg:mb-0">
<h1 className="h1 mb-4">Min Publishers</h1>
</div>
</div>
<div className="container">
<div className="container min-h-[50vh]">
<div className="flex justify-between">
<div className="mb-4 md:mb-0">
<ClusterSwitch />

View File

@ -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 ? (
<div className="mb-10">
{Object.keys(changes).map((key) => {
return (
changes[key].prev !== changes[key].new && (
<>
<div
key={key}
className="mb-4 flex items-center justify-between"
>
<span className="pr-4 text-left font-bold">{key}</span>
<span className="mr-2">
{changes[key].prev} &rarr; {changes[key].new}
</span>
</div>
</>
)
)
})}
</div>
) : (
<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) {
@ -302,16 +348,14 @@ const UpdatePermissions = () => {
isModalOpen={isModalOpen}
setIsModalOpen={setIsModalOpen}
closeModal={closeModal}
changes={pubkeyChanges}
handleSendProposalButtonClick={handleSendProposalButtonClick}
isSendProposalButtonLoading={isSendProposalButtonLoading}
content={<ModalContent changes={pubkeyChanges} />}
/>
<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 Permissions</h1>
</div>
</div>
<div className="container">
<div className="container min-h-[50vh]">
<div className="flex justify-between">
<div className="mb-4 md:mb-0">
<ClusterSwitch />

View File

@ -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 (
<Layout>
<PythContextProvider>
<div className="relative pt-16 md:pt-20">
<div className="container relative pt-16 md:pt-20">
<div className="py-8 md:py-16">
<Tab.Group
selectedIndex={currentTabIndex}
onChange={handleChangeTab}
>
<Tab.List className="mx-auto max-w-[526px] gap-1 space-x-4 text-center sm:gap-2.5 md:space-x-8">
<Tab.List className="mx-auto gap-1 space-x-4 text-center sm:gap-2.5 md:space-x-8">
{Object.entries(TAB_INFO).map((tab, idx) => (
<Tab
key={idx}
@ -91,6 +97,9 @@ const Home: NextPage = () => {
) : tabInfoArray[currentTabIndex].queryString ===
TAB_INFO.UpdatePermissions.queryString ? (
<UpdatePermissions />
) : tabInfoArray[currentTabIndex].queryString ===
TAB_INFO.AddRemovePublishers.queryString ? (
<AddRemovePublishers />
) : null}
</PythContextProvider>
</Layout>