Forráskód Böngészése

feat(xc_admin_frontend): add more details to summary view (#1612)

This PR extends the summary to include publisher and price account details for
addPublisher and delPublisher instructions.
Connor Prussin 1 éve
szülő
commit
443b769f02

+ 194 - 8
governance/xc_admin/packages/xc_admin_frontend/components/tabs/Proposals/InstructionsSummary.tsx

@@ -1,6 +1,13 @@
+import { Listbox, Transition } from '@headlessui/react'
 import { PythCluster } from '@pythnetwork/client'
 import { MultisigInstruction } from '@pythnetwork/xc-admin-common'
 import { getInstructionsSummary } from './utils'
+import { getMappingCluster } from '../../InstructionViews/utils'
+import CopyText from '../../common/CopyText'
+import Arrow from '@images/icons/down.inline.svg'
+import { Fragment, useState, useMemo, useContext } from 'react'
+import { usePythContext } from '../../../contexts/PythContext'
+import { ClusterContext } from '../../../contexts/ClusterContext'
 
 export const InstructionsSummary = ({
   instructions,
@@ -8,18 +15,197 @@ export const InstructionsSummary = ({
 }: {
   instructions: MultisigInstruction[]
   cluster: PythCluster
+}) => (
+  <div className="space-y-4">
+    {getInstructionsSummary({ instructions, cluster }).map((instruction) => (
+      <SummaryItem instruction={instruction} key={instruction.name} />
+    ))}
+  </div>
+)
+
+const SummaryItem = ({
+  instruction,
+}: {
+  instruction: ReturnType<typeof getInstructionsSummary>[number]
 }) => {
-  const instructionsCount = getInstructionsSummary({ instructions, cluster })
+  switch (instruction.name) {
+    case 'addPublisher':
+    case 'delPublisher': {
+      return (
+        <div className="grid grid-cols-4 justify-between">
+          <div className="col-span-4 lg:col-span-1">
+            {instruction.name}: {instruction.count}
+          </div>
+          <AddRemovePublisherDetails
+            isAdd={instruction.name === 'addPublisher'}
+            summaries={
+              instruction.summaries as AddRemovePublisherDetailsProps['summaries']
+            }
+          />
+        </div>
+      )
+    }
+    default: {
+      return (
+        <div>
+          {instruction.name}: {instruction.count}
+        </div>
+      )
+    }
+  }
+}
+
+type AddRemovePublisherDetailsProps = {
+  isAdd: boolean
+  summaries: {
+    readonly priceAccount: string
+    readonly pub: string
+  }[]
+}
+
+const AddRemovePublisherDetails = ({
+  isAdd,
+  summaries,
+}: AddRemovePublisherDetailsProps) => {
+  const { cluster } = useContext(ClusterContext)
+  const { priceAccountKeyToSymbolMapping, publisherKeyToNameMapping } =
+    usePythContext()
+  const publisherKeyToName =
+    publisherKeyToNameMapping[getMappingCluster(cluster)]
+  const [groupBy, setGroupBy] = useState<'publisher' | 'price account'>(
+    'publisher'
+  )
+  const grouped = useMemo(
+    () =>
+      Object.groupBy(summaries, (summary) =>
+        groupBy === 'publisher' ? summary.pub : summary.priceAccount
+      ),
+    [groupBy, summaries]
+  )
 
   return (
-    <div className="space-y-4">
-      {Object.entries(instructionsCount).map(([name, count]) => {
-        return (
-          <div key={name}>
-            {name}: {count}
+    <div className="col-span-4 mt-2 bg-[#444157] p-4 lg:col-span-3 lg:mt-0">
+      <div className="flex flex-row gap-4 items-center pb-4 mb-4 border-b border-light/50 justify-end">
+        <div className="font-semibold">Group by</div>
+        <Select
+          items={['publisher', 'price account']}
+          value={groupBy}
+          onChange={setGroupBy}
+        />
+      </div>
+      <div className="base16 flex justify-between pt-2 pb-6 font-semibold opacity-60">
+        <div>{groupBy === 'publisher' ? 'Publisher' : 'Price Account'}</div>
+        <div>
+          {groupBy === 'publisher'
+            ? isAdd
+              ? 'Added To'
+              : 'Removed From'
+            : `${isAdd ? 'Added' : 'Removed'} Publishers`}
+        </div>
+      </div>
+      {Object.entries(grouped).map(([groupKey, summaries = []]) => (
+        <>
+          <div
+            key={groupKey}
+            className="flex justify-between border-t border-beige-300 py-3"
+          >
+            <div>
+              <KeyAndName
+                mapping={
+                  groupBy === 'publisher'
+                    ? publisherKeyToName
+                    : priceAccountKeyToSymbolMapping
+                }
+              >
+                {groupKey}
+              </KeyAndName>
+            </div>
+            <ul className="flex flex-col gap-2">
+              {summaries.map((summary, index) => (
+                <li key={index}>
+                  <KeyAndName
+                    mapping={
+                      groupBy === 'publisher'
+                        ? priceAccountKeyToSymbolMapping
+                        : publisherKeyToName
+                    }
+                  >
+                    {groupBy === 'publisher'
+                      ? summary.priceAccount
+                      : summary.pub}
+                  </KeyAndName>
+                </li>
+              ))}
+            </ul>
           </div>
-        )
-      })}
+        </>
+      ))}
+    </div>
+  )
+}
+
+const KeyAndName = ({
+  mapping,
+  children,
+}: {
+  mapping: { [key: string]: string }
+  children: string
+}) => {
+  const name = useMemo(() => mapping[children], [mapping, children])
+
+  return (
+    <div>
+      <CopyText text={children} />
+      {name && <div className="ml-4 text-xs opacity-80"> &#10551; {name} </div>}
     </div>
   )
 }
+
+type SelectProps<T extends string> = {
+  items: T[]
+  value: T
+  onChange: (newValue: T) => void
+}
+
+const Select = <T extends string>({
+  items,
+  value,
+  onChange,
+}: SelectProps<T>) => (
+  <Listbox
+    as="div"
+    className="relative z-[3] block w-[180px] text-left"
+    value={value}
+    onChange={onChange}
+  >
+    {({ open }) => (
+      <>
+        <Listbox.Button className="inline-flex w-full items-center justify-between py-3 px-6 text-sm outline-0 bg-light/20">
+          <span className="mr-3">{value}</span>
+          <Arrow className={`${open && 'rotate-180'}`} />
+        </Listbox.Button>
+        <Transition
+          as={Fragment}
+          enter="transition ease-out duration-100"
+          enterFrom="transform opacity-0 scale-95"
+          enterTo="transform opacity-100 scale-100"
+          leave="transition ease-in duration-75"
+          leaveFrom="transform opacity-100 scale-100"
+          leaveTo="transform opacity-0 scale-95"
+        >
+          <Listbox.Options className="absolute right-0 mt-2 w-full origin-top-right">
+            {items.map((item) => (
+              <Listbox.Option
+                key={item}
+                value={item}
+                className="block w-full py-3 px-6 text-left text-sm bg-darkGray hover:bg-darkGray2 cursor-pointer"
+              >
+                {item}
+              </Listbox.Option>
+            ))}
+          </Listbox.Options>
+        </Transition>
+      </>
+    )}
+  </Listbox>
+)

+ 7 - 5
governance/xc_admin/packages/xc_admin_frontend/components/tabs/Proposals/ProposalRow.tsx

@@ -23,7 +23,8 @@ export const ProposalRow = ({
   multisig: MultisigAccount | undefined
 }) => {
   const [time, setTime] = useState<Date>()
-  const [instructions, setInstructions] = useState<[string, number][]>()
+  const [instructions, setInstructions] =
+    useState<(readonly [string, number])[]>()
   const status = getProposalStatus(proposal, multisig)
   const { cluster } = useContext(ClusterContext)
   const { isLoading: isMultisigLoading, connection } = useMultisigContext()
@@ -92,12 +93,13 @@ export const ProposalRow = ({
 
           // show only the first two instructions
           // and group the rest under 'other'
-          const shortSummary = Object.entries(summary).slice(0, 2)
-          const otherValue = Object.values(summary)
+          const shortSummary = summary.slice(0, 2)
+          const otherValue = summary
             .slice(2)
-            .reduce((acc, curr) => acc + curr, 0)
+            .map(({ count }) => count)
+            .reduce((total, item) => total + item, 0)
           const updatedSummary = [
-            ...shortSummary,
+            ...shortSummary.map(({ name, count }) => [name, count] as const),
             ...(otherValue > 0
               ? ([['other', otherValue]] as [string, number][])
               : []),

+ 71 - 52
governance/xc_admin/packages/xc_admin_frontend/components/tabs/Proposals/utils.ts

@@ -1,5 +1,5 @@
 import { PythCluster } from '@pythnetwork/client'
-import { AccountMeta } from '@solana/web3.js'
+import { PublicKey } from '@solana/web3.js'
 import { MultisigAccount, TransactionAccount } from '@sqds/mesh/lib/types'
 import {
   ExecutePostedVaa,
@@ -35,26 +35,6 @@ export const getProposalStatus = (
   }
 }
 
-/**
- * Sorts the properties of an object by their values in ascending order.
- *
- * @param {Record<string, number>} obj - The object to sort. All property values should be numbers.
- * @returns {Record<string, number>} A new object with the same properties as the input, but ordered such that the property with the largest numerical value comes first.
- *
- * @example
- * const obj = { a: 2, b: 3, c: 1 };
- * const sortedObj = sortObjectByValues(obj);
- * console.log(sortedObj); // Outputs: { b: 3, a: 2, c: 1 }
- */
-const sortObjectByValues = (obj: Record<string, number>) => {
-  const sortedEntries = Object.entries(obj).sort(([, a], [, b]) => b - a)
-  const sortedObj: Record<string, number> = {}
-  for (const [key, value] of sortedEntries) {
-    sortedObj[key] = value
-  }
-  return sortedObj
-}
-
 /**
  * Returns a summary of the instructions in a list of multisig instructions.
  *
@@ -62,39 +42,78 @@ const sortObjectByValues = (obj: Record<string, number>) => {
  * @param {PythCluster} options.cluster - The Pyth cluster to use for parsing instructions.
  * @returns {Record<string, number>} A summary of the instructions, where the keys are the names of the instructions and the values are the number of times each instruction appears in the list.
  */
-export const getInstructionsSummary = (options: {
+export const getInstructionsSummary = ({
+  instructions,
+  cluster,
+}: {
   instructions: MultisigInstruction[]
   cluster: PythCluster
-}) => {
-  const { instructions, cluster } = options
+}) =>
+  Object.entries(
+    getInstructionSummariesByName(
+      MultisigParser.fromCluster(cluster),
+      instructions
+    )
+  )
+    .map(([name, summaries = []]) => ({
+      name,
+      count: summaries.length ?? 0,
+      summaries,
+    }))
+    .toSorted(({ count }) => count)
 
-  return sortObjectByValues(
-    instructions.reduce((acc, instruction) => {
-      if (instruction instanceof WormholeMultisigInstruction) {
-        const governanceAction = instruction.governanceAction
-        if (governanceAction instanceof ExecutePostedVaa) {
-          const innerInstructions = governanceAction.instructions
-          innerInstructions.forEach((innerInstruction) => {
-            const multisigParser = MultisigParser.fromCluster(cluster)
-            const parsedInstruction = multisigParser.parseInstruction({
-              programId: innerInstruction.programId,
-              data: innerInstruction.data as Buffer,
-              keys: innerInstruction.keys as AccountMeta[],
-            })
-            acc[parsedInstruction.name] = (acc[parsedInstruction.name] ?? 0) + 1
-          })
-        } else if (governanceAction instanceof PythGovernanceActionImpl) {
-          acc[governanceAction.action] = (acc[governanceAction.action] ?? 0) + 1
-        } else if (governanceAction instanceof SetDataSources) {
-          acc[governanceAction.actionName] =
-            (acc[governanceAction.actionName] ?? 0) + 1
-        } else {
-          acc['unknown'] = (acc['unknown'] ?? 0) + 1
-        }
-      } else {
-        acc[instruction.name] = (acc[instruction.name] ?? 0) + 1
-      }
-      return acc
-    }, {} as Record<string, number>)
+const getInstructionSummariesByName = (
+  parser: MultisigParser,
+  instructions: MultisigInstruction[]
+) =>
+  Object.groupBy(
+    instructions.flatMap((instruction) =>
+      getInstructionSummary(parser, instruction)
+    ),
+    ({ name }) => name
   )
+
+const getInstructionSummary = (
+  parser: MultisigParser,
+  instruction: MultisigInstruction
+) => {
+  if (instruction instanceof WormholeMultisigInstruction) {
+    const { governanceAction } = instruction
+    if (governanceAction instanceof ExecutePostedVaa) {
+      return governanceAction.instructions.map((innerInstruction) =>
+        getTransactionSummary(parser.parseInstruction(innerInstruction))
+      )
+    } else if (governanceAction instanceof PythGovernanceActionImpl) {
+      return [{ name: governanceAction.action } as const]
+    } else if (governanceAction instanceof SetDataSources) {
+      return [{ name: governanceAction.actionName } as const]
+    } else {
+      return [{ name: 'unknown' } as const]
+    }
+  } else {
+    return [getTransactionSummary(instruction)]
+  }
+}
+
+const getTransactionSummary = (instruction: MultisigInstruction) => {
+  switch (instruction.name) {
+    case 'addPublisher':
+      return {
+        name: 'addPublisher',
+        priceAccount:
+          instruction.accounts.named['priceAccount'].pubkey.toBase58(),
+        pub: (instruction.args['pub'] as PublicKey).toBase58(),
+      } as const
+    case 'delPublisher':
+      return {
+        name: 'delPublisher',
+        priceAccount:
+          instruction.accounts.named['priceAccount'].pubkey.toBase58(),
+        pub: (instruction.args['pub'] as PublicKey).toBase58(),
+      } as const
+    default:
+      return {
+        name: instruction.name,
+      } as const
+  }
 }

+ 2 - 2
governance/xc_admin/packages/xc_admin_frontend/package.json

@@ -33,7 +33,6 @@
     "react": "18.2.0",
     "react-dom": "18.2.0",
     "react-hot-toast": "^2.4.0",
-    "typescript": "4.9.4",
     "use-debounce": "^9.0.2",
     "web3": "^4.8.0",
     "@pythnetwork/xc-admin-common": "*"
@@ -47,6 +46,7 @@
     "postcss": "^8.4.16",
     "prettier": "^2.7.1",
     "prettier-plugin-tailwindcss": "^0.1.13",
-    "tailwindcss": "^3.1.8"
+    "tailwindcss": "^3.1.8",
+    "typescript": "^5.4.5"
   }
 }

+ 20 - 3
package-lock.json

@@ -5112,7 +5112,6 @@
         "react": "18.2.0",
         "react-dom": "18.2.0",
         "react-hot-toast": "^2.4.0",
-        "typescript": "4.9.4",
         "use-debounce": "^9.0.2",
         "web3": "^4.8.0"
       },
@@ -5125,7 +5124,8 @@
         "postcss": "^8.4.16",
         "prettier": "^2.7.1",
         "prettier-plugin-tailwindcss": "^0.1.13",
-        "tailwindcss": "^3.1.8"
+        "tailwindcss": "^3.1.8",
+        "typescript": "^5.4.5"
       }
     },
     "governance/xc_admin/packages/xc_admin_frontend/node_modules/@noble/curves": {
@@ -5691,6 +5691,18 @@
         "node": ">=10"
       }
     },
+    "governance/xc_admin/packages/xc_admin_frontend/node_modules/typescript": {
+      "version": "5.4.5",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
+      "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    },
     "governance/xc_admin/packages/xc_admin_frontend/node_modules/web3": {
       "version": "4.8.0",
       "resolved": "https://registry.npmjs.org/web3/-/web3-4.8.0.tgz",
@@ -83138,7 +83150,7 @@
         "react-dom": "18.2.0",
         "react-hot-toast": "^2.4.0",
         "tailwindcss": "^3.1.8",
-        "typescript": "4.9.4",
+        "typescript": "^5.4.5",
         "use-debounce": "^9.0.2",
         "web3": "^4.8.0"
       },
@@ -83528,6 +83540,11 @@
             "lru-cache": "^6.0.0"
           }
         },
+        "typescript": {
+          "version": "5.4.5",
+          "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
+          "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ=="
+        },
         "web3": {
           "version": "4.8.0",
           "resolved": "https://registry.npmjs.org/web3/-/web3-4.8.0.tgz",