|
| 1 | +import { AnchorProvider, Program, Wallet } from '@coral-xyz/anchor' |
| 2 | +import { |
| 3 | + getPythProgramKeyForCluster, |
| 4 | + Product, |
| 5 | + pythOracleProgram, |
| 6 | +} from '@pythnetwork/client' |
| 7 | +import { PythOracle } from '@pythnetwork/client/lib/anchor' |
| 8 | +import { useAnchorWallet, useWallet } from '@solana/wallet-adapter-react' |
| 9 | +import { WalletModalButton } from '@solana/wallet-adapter-react-ui' |
| 10 | +import { PublicKey, TransactionInstruction } from '@solana/web3.js' |
| 11 | +import { useContext, useEffect, useState } from 'react' |
| 12 | +import toast from 'react-hot-toast' |
| 13 | +import { getMultisigCluster, proposeInstructions } from 'xc_admin_common' |
| 14 | +import { ClusterContext } from '../../contexts/ClusterContext' |
| 15 | +import { usePythContext } from '../../contexts/PythContext' |
| 16 | +import { SECURITY_MULTISIG, useMultisig } from '../../hooks/useMultisig' |
| 17 | +import { capitalizeFirstLetter } from '../../utils/capitalizeFirstLetter' |
| 18 | +import ClusterSwitch from '../ClusterSwitch' |
| 19 | +import Modal from '../common/Modal' |
| 20 | +import Spinner from '../common/Spinner' |
| 21 | +import Loadbar from '../loaders/Loadbar' |
| 22 | + |
| 23 | +interface SymbolToProductMetadata { |
| 24 | + [key: string]: Product |
| 25 | +} |
| 26 | + |
| 27 | +interface ProductMetadataInfo { |
| 28 | + prev: Product |
| 29 | + new: Product |
| 30 | +} |
| 31 | + |
| 32 | +const symbolToProductAccountKeyMapping: Record<string, PublicKey> = {} |
| 33 | + |
| 34 | +const UpdateProductMetadata = () => { |
| 35 | + const [data, setData] = useState<SymbolToProductMetadata>({}) |
| 36 | + const [productMetadataChanges, setProductMetadataChanges] = |
| 37 | + useState<Record<string, ProductMetadataInfo>>() |
| 38 | + const [isModalOpen, setIsModalOpen] = useState(false) |
| 39 | + const [isSendProposalButtonLoading, setIsSendProposalButtonLoading] = |
| 40 | + useState(false) |
| 41 | + const { cluster } = useContext(ClusterContext) |
| 42 | + const anchorWallet = useAnchorWallet() |
| 43 | + const { isLoading: isMultisigLoading, squads } = useMultisig( |
| 44 | + anchorWallet as Wallet |
| 45 | + ) |
| 46 | + const { rawConfig, dataIsLoading, connection } = usePythContext() |
| 47 | + const { connected } = useWallet() |
| 48 | + const [pythProgramClient, setPythProgramClient] = |
| 49 | + useState<Program<PythOracle>>() |
| 50 | + |
| 51 | + const openModal = () => { |
| 52 | + setIsModalOpen(true) |
| 53 | + } |
| 54 | + |
| 55 | + const closeModal = () => { |
| 56 | + setIsModalOpen(false) |
| 57 | + } |
| 58 | + |
| 59 | + useEffect(() => { |
| 60 | + if (!dataIsLoading && rawConfig && rawConfig.mappingAccounts.length > 0) { |
| 61 | + const symbolToProductMetadataMapping: SymbolToProductMetadata = {} |
| 62 | + rawConfig.mappingAccounts |
| 63 | + .sort( |
| 64 | + (mapping1, mapping2) => |
| 65 | + mapping2.products.length - mapping1.products.length |
| 66 | + )[0] |
| 67 | + .products.map((product) => { |
| 68 | + symbolToProductAccountKeyMapping[product.metadata.symbol] = |
| 69 | + product.address |
| 70 | + // create copy of product.metadata to avoid mutating the original product.metadata |
| 71 | + symbolToProductMetadataMapping[product.metadata.symbol] = { |
| 72 | + ...product.metadata, |
| 73 | + } |
| 74 | + // these fields are immutable and should not be updated |
| 75 | + delete symbolToProductMetadataMapping[product.metadata.symbol].symbol |
| 76 | + delete symbolToProductMetadataMapping[product.metadata.symbol] |
| 77 | + .price_account |
| 78 | + }) |
| 79 | + setData(sortData(symbolToProductMetadataMapping)) |
| 80 | + } |
| 81 | + }, [rawConfig, dataIsLoading]) |
| 82 | + |
| 83 | + const sortData = (data: SymbolToProductMetadata) => { |
| 84 | + const sortedSymbolToProductMetadataMapping: SymbolToProductMetadata = {} |
| 85 | + Object.keys(data) |
| 86 | + .sort() |
| 87 | + .forEach((key) => { |
| 88 | + const sortedInnerData: any = {} |
| 89 | + Object.keys(data[key]) |
| 90 | + .sort() |
| 91 | + .forEach((innerKey) => { |
| 92 | + sortedInnerData[innerKey] = data[key][innerKey] |
| 93 | + }) |
| 94 | + sortedSymbolToProductMetadataMapping[key] = sortedInnerData |
| 95 | + }) |
| 96 | + |
| 97 | + return sortedSymbolToProductMetadataMapping |
| 98 | + } |
| 99 | + |
| 100 | + // function to download json file |
| 101 | + const handleDownloadJsonButtonClick = () => { |
| 102 | + const dataStr = |
| 103 | + 'data:text/json;charset=utf-8,' + |
| 104 | + encodeURIComponent(JSON.stringify(data, null, 2)) |
| 105 | + const downloadAnchor = document.createElement('a') |
| 106 | + downloadAnchor.setAttribute('href', dataStr) |
| 107 | + downloadAnchor.setAttribute('download', 'products.json') |
| 108 | + document.body.appendChild(downloadAnchor) // required for firefox |
| 109 | + downloadAnchor.click() |
| 110 | + downloadAnchor.remove() |
| 111 | + } |
| 112 | + |
| 113 | + // function to upload json file and update productMetadataChanges state |
| 114 | + const handleUploadJsonButtonClick = () => { |
| 115 | + const uploadAnchor = document.createElement('input') |
| 116 | + uploadAnchor.setAttribute('type', 'file') |
| 117 | + uploadAnchor.setAttribute('accept', '.json') |
| 118 | + uploadAnchor.addEventListener('change', (e) => { |
| 119 | + const file = (e.target as HTMLInputElement).files![0] |
| 120 | + const reader = new FileReader() |
| 121 | + reader.onload = (e) => { |
| 122 | + if (e.target) { |
| 123 | + const fileData = e.target.result |
| 124 | + if (!isValidJson(fileData as string)) return |
| 125 | + const fileDataParsed = sortData(JSON.parse(fileData as string)) |
| 126 | + const changes: Record<string, ProductMetadataInfo> = {} |
| 127 | + Object.keys(fileDataParsed).forEach((symbol) => { |
| 128 | + if ( |
| 129 | + JSON.stringify(data[symbol]) !== |
| 130 | + JSON.stringify(fileDataParsed[symbol]) |
| 131 | + ) { |
| 132 | + changes[symbol] = { |
| 133 | + prev: data[symbol], |
| 134 | + new: fileDataParsed[symbol], |
| 135 | + } |
| 136 | + } |
| 137 | + }) |
| 138 | + setProductMetadataChanges(changes) |
| 139 | + openModal() |
| 140 | + } |
| 141 | + } |
| 142 | + reader.readAsText(file) |
| 143 | + }) |
| 144 | + document.body.appendChild(uploadAnchor) // required for firefox |
| 145 | + uploadAnchor.click() |
| 146 | + uploadAnchor.remove() |
| 147 | + } |
| 148 | + |
| 149 | + // check if uploaded json is valid json |
| 150 | + const isValidJson = (json: string) => { |
| 151 | + try { |
| 152 | + JSON.parse(json) |
| 153 | + } catch (e: any) { |
| 154 | + toast.error(capitalizeFirstLetter(e.message)) |
| 155 | + return false |
| 156 | + } |
| 157 | + // check if json keys are existing products |
| 158 | + const jsonParsed = JSON.parse(json) |
| 159 | + const jsonSymbols = Object.keys(jsonParsed) |
| 160 | + const existingSymbols = Object.keys(data) |
| 161 | + // check that jsonSymbols is equal to existingSymbols no matter the order |
| 162 | + if ( |
| 163 | + JSON.stringify(jsonSymbols.sort()) !== |
| 164 | + JSON.stringify(existingSymbols.sort()) |
| 165 | + ) { |
| 166 | + toast.error('Symbols in json file do not match existing symbols!') |
| 167 | + return false |
| 168 | + } |
| 169 | + |
| 170 | + // check for duplicate keys in jsonParsed |
| 171 | + const jsonSymbolsSet = new Set(jsonSymbols) |
| 172 | + if (jsonSymbols.length !== jsonSymbolsSet.size) { |
| 173 | + toast.error('Duplicate symbols in json file!') |
| 174 | + return false |
| 175 | + } |
| 176 | + |
| 177 | + let isValid = true |
| 178 | + // check that the keys of the values of json are equal to the keys of the values of data |
| 179 | + jsonSymbols.forEach((symbol) => { |
| 180 | + const jsonKeys = Object.keys(jsonParsed[symbol]) |
| 181 | + const existingKeys = Object.keys(data[symbol]) |
| 182 | + if ( |
| 183 | + JSON.stringify(jsonKeys.sort()) !== JSON.stringify(existingKeys.sort()) |
| 184 | + ) { |
| 185 | + toast.error( |
| 186 | + `Keys in json file do not match existing keys for symbol ${symbol}!` |
| 187 | + ) |
| 188 | + isValid = false |
| 189 | + } |
| 190 | + }) |
| 191 | + return isValid |
| 192 | + } |
| 193 | + |
| 194 | + const handleSendProposalButtonClick = async () => { |
| 195 | + if (pythProgramClient && productMetadataChanges) { |
| 196 | + const instructions: TransactionInstruction[] = [] |
| 197 | + Object.keys(productMetadataChanges).forEach((symbol) => { |
| 198 | + const { prev, new: newProductMetadata } = productMetadataChanges[symbol] |
| 199 | + // prev and new are json object of metadata |
| 200 | + // check if there are any new metadata by comparing prev and new values |
| 201 | + if (JSON.stringify(prev) !== JSON.stringify(newProductMetadata)) { |
| 202 | + pythProgramClient.methods |
| 203 | + .updProduct(newProductMetadata) |
| 204 | + .accounts({ |
| 205 | + fundingAccount: squads?.getAuthorityPDA( |
| 206 | + SECURITY_MULTISIG[getMultisigCluster(cluster)], |
| 207 | + 1 |
| 208 | + ), |
| 209 | + productAccount: symbolToProductAccountKeyMapping[symbol], |
| 210 | + }) |
| 211 | + .instruction() |
| 212 | + .then((instruction) => instructions.push(instruction)) |
| 213 | + } |
| 214 | + }) |
| 215 | + |
| 216 | + if (!isMultisigLoading && squads) { |
| 217 | + setIsSendProposalButtonLoading(true) |
| 218 | + try { |
| 219 | + const proposalPubkey = await proposeInstructions( |
| 220 | + squads, |
| 221 | + SECURITY_MULTISIG[getMultisigCluster(cluster)], |
| 222 | + instructions, |
| 223 | + false |
| 224 | + ) |
| 225 | + toast.success(`Proposal sent! 🚀 Proposal Pubkey: ${proposalPubkey}`) |
| 226 | + setIsSendProposalButtonLoading(false) |
| 227 | + } catch (e: any) { |
| 228 | + toast.error(capitalizeFirstLetter(e.message)) |
| 229 | + setIsSendProposalButtonLoading(false) |
| 230 | + } |
| 231 | + } |
| 232 | + } |
| 233 | + } |
| 234 | + |
| 235 | + const ModalContent = ({ changes }: { changes: any }) => { |
| 236 | + return ( |
| 237 | + <> |
| 238 | + {Object.keys(changes).length > 0 ? ( |
| 239 | + <table className="mb-10 w-full table-auto bg-darkGray text-left"> |
| 240 | + {Object.keys(changes).map((key) => { |
| 241 | + const { prev, new: newProductMetadata } = changes[key] |
| 242 | + const diff = Object.keys(prev).filter( |
| 243 | + (k) => prev[k] !== newProductMetadata[k] |
| 244 | + ) |
| 245 | + return ( |
| 246 | + <tbody key={key}> |
| 247 | + <tr> |
| 248 | + <td |
| 249 | + className="base16 py-4 pl-6 pr-2 font-bold lg:pl-6" |
| 250 | + colSpan={2} |
| 251 | + > |
| 252 | + {key} |
| 253 | + </td> |
| 254 | + </tr> |
| 255 | + {diff.map((k) => ( |
| 256 | + <tr key={k}> |
| 257 | + <td className="base16 py-4 pl-6 pr-2 lg:pl-6"> |
| 258 | + {k |
| 259 | + .split('_') |
| 260 | + .map((word) => capitalizeFirstLetter(word)) |
| 261 | + .join(' ')} |
| 262 | + </td> |
| 263 | + <td className="base16 py-4 pl-1 pr-2 lg:pl-6"> |
| 264 | + {newProductMetadata[k]} |
| 265 | + </td> |
| 266 | + </tr> |
| 267 | + ))} |
| 268 | + {/* add a divider only if its not the last item */} |
| 269 | + {Object.keys(changes).indexOf(key) !== |
| 270 | + Object.keys(changes).length - 1 ? ( |
| 271 | + <tr> |
| 272 | + <td className="base16 py-4 pl-6 pr-6" colSpan={2}> |
| 273 | + <hr className="border-gray-700" /> |
| 274 | + </td> |
| 275 | + </tr> |
| 276 | + ) : null} |
| 277 | + </tbody> |
| 278 | + ) |
| 279 | + })} |
| 280 | + </table> |
| 281 | + ) : ( |
| 282 | + <p className="mb-8 leading-6">No proposed changes.</p> |
| 283 | + )} |
| 284 | + {Object.keys(changes).length > 0 ? ( |
| 285 | + !connected ? ( |
| 286 | + <div className="flex justify-center"> |
| 287 | + <WalletModalButton className="action-btn text-base" /> |
| 288 | + </div> |
| 289 | + ) : ( |
| 290 | + <button |
| 291 | + className="action-btn text-base" |
| 292 | + onClick={handleSendProposalButtonClick} |
| 293 | + > |
| 294 | + {isSendProposalButtonLoading ? <Spinner /> : 'Send Proposal'} |
| 295 | + </button> |
| 296 | + ) |
| 297 | + ) : null} |
| 298 | + </> |
| 299 | + ) |
| 300 | + } |
| 301 | + |
| 302 | + // create anchor wallet when connected |
| 303 | + useEffect(() => { |
| 304 | + if (connected) { |
| 305 | + const provider = new AnchorProvider( |
| 306 | + connection, |
| 307 | + anchorWallet as Wallet, |
| 308 | + AnchorProvider.defaultOptions() |
| 309 | + ) |
| 310 | + setPythProgramClient( |
| 311 | + pythOracleProgram(getPythProgramKeyForCluster(cluster), provider) |
| 312 | + ) |
| 313 | + } |
| 314 | + }, [anchorWallet, connection, connected, cluster]) |
| 315 | + |
| 316 | + return ( |
| 317 | + <div className="relative"> |
| 318 | + <Modal |
| 319 | + isModalOpen={isModalOpen} |
| 320 | + setIsModalOpen={setIsModalOpen} |
| 321 | + closeModal={closeModal} |
| 322 | + content={<ModalContent changes={productMetadataChanges} />} |
| 323 | + /> |
| 324 | + <div className="container flex flex-col items-center justify-between lg:flex-row"> |
| 325 | + <div className="mb-4 w-full text-left lg:mb-0"> |
| 326 | + <h1 className="h1 mb-4">Update Product Metadata</h1> |
| 327 | + </div> |
| 328 | + </div> |
| 329 | + <div className="container min-h-[50vh]"> |
| 330 | + <div className="flex justify-between"> |
| 331 | + <div className="mb-4 md:mb-0"> |
| 332 | + <ClusterSwitch /> |
| 333 | + </div> |
| 334 | + </div> |
| 335 | + <div className="relative mt-6"> |
| 336 | + {dataIsLoading ? ( |
| 337 | + <div className="mt-3"> |
| 338 | + <Loadbar theme="light" /> |
| 339 | + </div> |
| 340 | + ) : ( |
| 341 | + <div className="flex items-center space-x-4"> |
| 342 | + <div className="mb-10"> |
| 343 | + <button |
| 344 | + className="action-btn text-base" |
| 345 | + onClick={handleDownloadJsonButtonClick} |
| 346 | + > |
| 347 | + Download JSON |
| 348 | + </button> |
| 349 | + </div> |
| 350 | + <div className="mb-10"> |
| 351 | + <button |
| 352 | + className="action-btn text-base" |
| 353 | + onClick={handleUploadJsonButtonClick} |
| 354 | + > |
| 355 | + Upload JSON |
| 356 | + </button> |
| 357 | + </div> |
| 358 | + </div> |
| 359 | + )} |
| 360 | + </div> |
| 361 | + </div> |
| 362 | + </div> |
| 363 | + ) |
| 364 | +} |
| 365 | + |
| 366 | +export default UpdateProductMetadata |
0 commit comments