Skip to content

Commit 443b769

Browse files
authored
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.
1 parent 51c05cd commit 443b769

File tree

5 files changed

+294
-70
lines changed

5 files changed

+294
-70
lines changed
Lines changed: 194 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,211 @@
1+
import { Listbox, Transition } from '@headlessui/react'
12
import { PythCluster } from '@pythnetwork/client'
23
import { MultisigInstruction } from '@pythnetwork/xc-admin-common'
34
import { getInstructionsSummary } from './utils'
5+
import { getMappingCluster } from '../../InstructionViews/utils'
6+
import CopyText from '../../common/CopyText'
7+
import Arrow from '@images/icons/down.inline.svg'
8+
import { Fragment, useState, useMemo, useContext } from 'react'
9+
import { usePythContext } from '../../../contexts/PythContext'
10+
import { ClusterContext } from '../../../contexts/ClusterContext'
411

512
export const InstructionsSummary = ({
613
instructions,
714
cluster,
815
}: {
916
instructions: MultisigInstruction[]
1017
cluster: PythCluster
18+
}) => (
19+
<div className="space-y-4">
20+
{getInstructionsSummary({ instructions, cluster }).map((instruction) => (
21+
<SummaryItem instruction={instruction} key={instruction.name} />
22+
))}
23+
</div>
24+
)
25+
26+
const SummaryItem = ({
27+
instruction,
28+
}: {
29+
instruction: ReturnType<typeof getInstructionsSummary>[number]
1130
}) => {
12-
const instructionsCount = getInstructionsSummary({ instructions, cluster })
31+
switch (instruction.name) {
32+
case 'addPublisher':
33+
case 'delPublisher': {
34+
return (
35+
<div className="grid grid-cols-4 justify-between">
36+
<div className="col-span-4 lg:col-span-1">
37+
{instruction.name}: {instruction.count}
38+
</div>
39+
<AddRemovePublisherDetails
40+
isAdd={instruction.name === 'addPublisher'}
41+
summaries={
42+
instruction.summaries as AddRemovePublisherDetailsProps['summaries']
43+
}
44+
/>
45+
</div>
46+
)
47+
}
48+
default: {
49+
return (
50+
<div>
51+
{instruction.name}: {instruction.count}
52+
</div>
53+
)
54+
}
55+
}
56+
}
57+
58+
type AddRemovePublisherDetailsProps = {
59+
isAdd: boolean
60+
summaries: {
61+
readonly priceAccount: string
62+
readonly pub: string
63+
}[]
64+
}
65+
66+
const AddRemovePublisherDetails = ({
67+
isAdd,
68+
summaries,
69+
}: AddRemovePublisherDetailsProps) => {
70+
const { cluster } = useContext(ClusterContext)
71+
const { priceAccountKeyToSymbolMapping, publisherKeyToNameMapping } =
72+
usePythContext()
73+
const publisherKeyToName =
74+
publisherKeyToNameMapping[getMappingCluster(cluster)]
75+
const [groupBy, setGroupBy] = useState<'publisher' | 'price account'>(
76+
'publisher'
77+
)
78+
const grouped = useMemo(
79+
() =>
80+
Object.groupBy(summaries, (summary) =>
81+
groupBy === 'publisher' ? summary.pub : summary.priceAccount
82+
),
83+
[groupBy, summaries]
84+
)
1385

1486
return (
15-
<div className="space-y-4">
16-
{Object.entries(instructionsCount).map(([name, count]) => {
17-
return (
18-
<div key={name}>
19-
{name}: {count}
87+
<div className="col-span-4 mt-2 bg-[#444157] p-4 lg:col-span-3 lg:mt-0">
88+
<div className="flex flex-row gap-4 items-center pb-4 mb-4 border-b border-light/50 justify-end">
89+
<div className="font-semibold">Group by</div>
90+
<Select
91+
items={['publisher', 'price account']}
92+
value={groupBy}
93+
onChange={setGroupBy}
94+
/>
95+
</div>
96+
<div className="base16 flex justify-between pt-2 pb-6 font-semibold opacity-60">
97+
<div>{groupBy === 'publisher' ? 'Publisher' : 'Price Account'}</div>
98+
<div>
99+
{groupBy === 'publisher'
100+
? isAdd
101+
? 'Added To'
102+
: 'Removed From'
103+
: `${isAdd ? 'Added' : 'Removed'} Publishers`}
104+
</div>
105+
</div>
106+
{Object.entries(grouped).map(([groupKey, summaries = []]) => (
107+
<>
108+
<div
109+
key={groupKey}
110+
className="flex justify-between border-t border-beige-300 py-3"
111+
>
112+
<div>
113+
<KeyAndName
114+
mapping={
115+
groupBy === 'publisher'
116+
? publisherKeyToName
117+
: priceAccountKeyToSymbolMapping
118+
}
119+
>
120+
{groupKey}
121+
</KeyAndName>
122+
</div>
123+
<ul className="flex flex-col gap-2">
124+
{summaries.map((summary, index) => (
125+
<li key={index}>
126+
<KeyAndName
127+
mapping={
128+
groupBy === 'publisher'
129+
? priceAccountKeyToSymbolMapping
130+
: publisherKeyToName
131+
}
132+
>
133+
{groupBy === 'publisher'
134+
? summary.priceAccount
135+
: summary.pub}
136+
</KeyAndName>
137+
</li>
138+
))}
139+
</ul>
20140
</div>
21-
)
22-
})}
141+
</>
142+
))}
143+
</div>
144+
)
145+
}
146+
147+
const KeyAndName = ({
148+
mapping,
149+
children,
150+
}: {
151+
mapping: { [key: string]: string }
152+
children: string
153+
}) => {
154+
const name = useMemo(() => mapping[children], [mapping, children])
155+
156+
return (
157+
<div>
158+
<CopyText text={children} />
159+
{name && <div className="ml-4 text-xs opacity-80"> &#10551; {name} </div>}
23160
</div>
24161
)
25162
}
163+
164+
type SelectProps<T extends string> = {
165+
items: T[]
166+
value: T
167+
onChange: (newValue: T) => void
168+
}
169+
170+
const Select = <T extends string>({
171+
items,
172+
value,
173+
onChange,
174+
}: SelectProps<T>) => (
175+
<Listbox
176+
as="div"
177+
className="relative z-[3] block w-[180px] text-left"
178+
value={value}
179+
onChange={onChange}
180+
>
181+
{({ open }) => (
182+
<>
183+
<Listbox.Button className="inline-flex w-full items-center justify-between py-3 px-6 text-sm outline-0 bg-light/20">
184+
<span className="mr-3">{value}</span>
185+
<Arrow className={`${open && 'rotate-180'}`} />
186+
</Listbox.Button>
187+
<Transition
188+
as={Fragment}
189+
enter="transition ease-out duration-100"
190+
enterFrom="transform opacity-0 scale-95"
191+
enterTo="transform opacity-100 scale-100"
192+
leave="transition ease-in duration-75"
193+
leaveFrom="transform opacity-100 scale-100"
194+
leaveTo="transform opacity-0 scale-95"
195+
>
196+
<Listbox.Options className="absolute right-0 mt-2 w-full origin-top-right">
197+
{items.map((item) => (
198+
<Listbox.Option
199+
key={item}
200+
value={item}
201+
className="block w-full py-3 px-6 text-left text-sm bg-darkGray hover:bg-darkGray2 cursor-pointer"
202+
>
203+
{item}
204+
</Listbox.Option>
205+
))}
206+
</Listbox.Options>
207+
</Transition>
208+
</>
209+
)}
210+
</Listbox>
211+
)

governance/xc_admin/packages/xc_admin_frontend/components/tabs/Proposals/ProposalRow.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ export const ProposalRow = ({
2323
multisig: MultisigAccount | undefined
2424
}) => {
2525
const [time, setTime] = useState<Date>()
26-
const [instructions, setInstructions] = useState<[string, number][]>()
26+
const [instructions, setInstructions] =
27+
useState<(readonly [string, number])[]>()
2728
const status = getProposalStatus(proposal, multisig)
2829
const { cluster } = useContext(ClusterContext)
2930
const { isLoading: isMultisigLoading, connection } = useMultisigContext()
@@ -92,12 +93,13 @@ export const ProposalRow = ({
9293

9394
// show only the first two instructions
9495
// and group the rest under 'other'
95-
const shortSummary = Object.entries(summary).slice(0, 2)
96-
const otherValue = Object.values(summary)
96+
const shortSummary = summary.slice(0, 2)
97+
const otherValue = summary
9798
.slice(2)
98-
.reduce((acc, curr) => acc + curr, 0)
99+
.map(({ count }) => count)
100+
.reduce((total, item) => total + item, 0)
99101
const updatedSummary = [
100-
...shortSummary,
102+
...shortSummary.map(({ name, count }) => [name, count] as const),
101103
...(otherValue > 0
102104
? ([['other', otherValue]] as [string, number][])
103105
: []),
Lines changed: 71 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { PythCluster } from '@pythnetwork/client'
2-
import { AccountMeta } from '@solana/web3.js'
2+
import { PublicKey } from '@solana/web3.js'
33
import { MultisigAccount, TransactionAccount } from '@sqds/mesh/lib/types'
44
import {
55
ExecutePostedVaa,
@@ -35,66 +35,85 @@ export const getProposalStatus = (
3535
}
3636
}
3737

38-
/**
39-
* Sorts the properties of an object by their values in ascending order.
40-
*
41-
* @param {Record<string, number>} obj - The object to sort. All property values should be numbers.
42-
* @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.
43-
*
44-
* @example
45-
* const obj = { a: 2, b: 3, c: 1 };
46-
* const sortedObj = sortObjectByValues(obj);
47-
* console.log(sortedObj); // Outputs: { b: 3, a: 2, c: 1 }
48-
*/
49-
const sortObjectByValues = (obj: Record<string, number>) => {
50-
const sortedEntries = Object.entries(obj).sort(([, a], [, b]) => b - a)
51-
const sortedObj: Record<string, number> = {}
52-
for (const [key, value] of sortedEntries) {
53-
sortedObj[key] = value
54-
}
55-
return sortedObj
56-
}
57-
5838
/**
5939
* Returns a summary of the instructions in a list of multisig instructions.
6040
*
6141
* @param {MultisigInstruction[]} options.instructions - The list of multisig instructions to summarize.
6242
* @param {PythCluster} options.cluster - The Pyth cluster to use for parsing instructions.
6343
* @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.
6444
*/
65-
export const getInstructionsSummary = (options: {
45+
export const getInstructionsSummary = ({
46+
instructions,
47+
cluster,
48+
}: {
6649
instructions: MultisigInstruction[]
6750
cluster: PythCluster
68-
}) => {
69-
const { instructions, cluster } = options
51+
}) =>
52+
Object.entries(
53+
getInstructionSummariesByName(
54+
MultisigParser.fromCluster(cluster),
55+
instructions
56+
)
57+
)
58+
.map(([name, summaries = []]) => ({
59+
name,
60+
count: summaries.length ?? 0,
61+
summaries,
62+
}))
63+
.toSorted(({ count }) => count)
7064

71-
return sortObjectByValues(
72-
instructions.reduce((acc, instruction) => {
73-
if (instruction instanceof WormholeMultisigInstruction) {
74-
const governanceAction = instruction.governanceAction
75-
if (governanceAction instanceof ExecutePostedVaa) {
76-
const innerInstructions = governanceAction.instructions
77-
innerInstructions.forEach((innerInstruction) => {
78-
const multisigParser = MultisigParser.fromCluster(cluster)
79-
const parsedInstruction = multisigParser.parseInstruction({
80-
programId: innerInstruction.programId,
81-
data: innerInstruction.data as Buffer,
82-
keys: innerInstruction.keys as AccountMeta[],
83-
})
84-
acc[parsedInstruction.name] = (acc[parsedInstruction.name] ?? 0) + 1
85-
})
86-
} else if (governanceAction instanceof PythGovernanceActionImpl) {
87-
acc[governanceAction.action] = (acc[governanceAction.action] ?? 0) + 1
88-
} else if (governanceAction instanceof SetDataSources) {
89-
acc[governanceAction.actionName] =
90-
(acc[governanceAction.actionName] ?? 0) + 1
91-
} else {
92-
acc['unknown'] = (acc['unknown'] ?? 0) + 1
93-
}
94-
} else {
95-
acc[instruction.name] = (acc[instruction.name] ?? 0) + 1
96-
}
97-
return acc
98-
}, {} as Record<string, number>)
65+
const getInstructionSummariesByName = (
66+
parser: MultisigParser,
67+
instructions: MultisigInstruction[]
68+
) =>
69+
Object.groupBy(
70+
instructions.flatMap((instruction) =>
71+
getInstructionSummary(parser, instruction)
72+
),
73+
({ name }) => name
9974
)
75+
76+
const getInstructionSummary = (
77+
parser: MultisigParser,
78+
instruction: MultisigInstruction
79+
) => {
80+
if (instruction instanceof WormholeMultisigInstruction) {
81+
const { governanceAction } = instruction
82+
if (governanceAction instanceof ExecutePostedVaa) {
83+
return governanceAction.instructions.map((innerInstruction) =>
84+
getTransactionSummary(parser.parseInstruction(innerInstruction))
85+
)
86+
} else if (governanceAction instanceof PythGovernanceActionImpl) {
87+
return [{ name: governanceAction.action } as const]
88+
} else if (governanceAction instanceof SetDataSources) {
89+
return [{ name: governanceAction.actionName } as const]
90+
} else {
91+
return [{ name: 'unknown' } as const]
92+
}
93+
} else {
94+
return [getTransactionSummary(instruction)]
95+
}
96+
}
97+
98+
const getTransactionSummary = (instruction: MultisigInstruction) => {
99+
switch (instruction.name) {
100+
case 'addPublisher':
101+
return {
102+
name: 'addPublisher',
103+
priceAccount:
104+
instruction.accounts.named['priceAccount'].pubkey.toBase58(),
105+
pub: (instruction.args['pub'] as PublicKey).toBase58(),
106+
} as const
107+
case 'delPublisher':
108+
return {
109+
name: 'delPublisher',
110+
priceAccount:
111+
instruction.accounts.named['priceAccount'].pubkey.toBase58(),
112+
pub: (instruction.args['pub'] as PublicKey).toBase58(),
113+
} as const
114+
default:
115+
return {
116+
name: instruction.name,
117+
} as const
118+
}
100119
}

0 commit comments

Comments
 (0)