Skip to content

Commit f1c5aba

Browse files
authored
[xc_admin] add update product metadata (#550)
1 parent abb238f commit f1c5aba

File tree

6 files changed

+381
-6
lines changed

6 files changed

+381
-6
lines changed

governance/xc_admin/packages/xc_admin_frontend/components/tabs/AddRemovePublishers.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { WalletModalButton } from '@solana/wallet-adapter-react-ui'
99
import { PublicKey, TransactionInstruction } from '@solana/web3.js'
1010
import { Fragment, useContext, useEffect, useState } from 'react'
1111
import toast from 'react-hot-toast'
12-
import { proposeInstructions, getMultisigCluster } from 'xc_admin_common'
12+
import { getMultisigCluster, proposeInstructions } from 'xc_admin_common'
1313
import { ClusterContext } from '../../contexts/ClusterContext'
1414
import { usePythContext } from '../../contexts/PythContext'
1515
import { SECURITY_MULTISIG, useMultisig } from '../../hooks/useMultisig'
@@ -56,7 +56,7 @@ const AddRemovePublishers = () => {
5656
}
5757

5858
useEffect(() => {
59-
if (!dataIsLoading && rawConfig) {
59+
if (!dataIsLoading && rawConfig && rawConfig.mappingAccounts.length > 0) {
6060
let symbolToPublisherKeysMapping: SymbolToPublisherKeys = {}
6161
rawConfig.mappingAccounts.map((mappingAccount) => {
6262
mappingAccount.products.map((product) => {

governance/xc_admin/packages/xc_admin_frontend/components/tabs/MinPublishers.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ const MinPublishers = () => {
130130
}
131131

132132
useEffect(() => {
133-
if (!dataIsLoading && rawConfig) {
133+
if (!dataIsLoading && rawConfig && rawConfig.mappingAccounts.length > 0) {
134134
const minPublishersData: MinPublishersProps[] = []
135135
rawConfig.mappingAccounts
136136
.sort(
Lines changed: 366 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,366 @@
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

Comments
 (0)