Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,25 +1,211 @@
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,
cluster,
}: {
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>
)
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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][])
: []),
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -35,66 +35,85 @@ 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.
*
* @param {MultisigInstruction[]} options.instructions - The list of multisig instructions to summarize.
* @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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the point of the "as const"

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as const does two things:

  1. It narrows the inference engine. In particular, this means that e.g. the type of { name: 'addPublisher' } is interpreted with the name field's type as the literal "addPublisher" instead of the type string.
  2. It marks types as readonly

It's a bit of a shame that typescript doesn't have a nice syntax for narrowly inferring types that does only #1 without #2 since the readonly modifier is, IMO, quite clunky and not very useful.

I did this for #1 -- it actually doesn't matter in this code because the fact that parsedInstruction.name can be any arbitrary string means that the inference engine actually can't match the literal addPublisher to the case on line 106 (as far as I know typescript is not smart enough to infer that the case here only matches for any string except addPublisher and delPublisher). However, it doesn't make sense to me that an instruction can be an arbitrary name, that seems like something that eventually will be enumerated, and the idea is that by doing this now, eventually if we do enumerate the names, it would be very very easy to update this function so that type inference can correctly match the shape of the return value based on the instruction name. This would be especially valuable if we added more bespoke summaries for different instruction types.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In case you're curious, here's a good illustration in the typescript playground of the difference between using the const assertion vs not: https://www.typescriptlang.org/play/?#code/MYewdgzgLgBAJiAyiAtgUygCwJZgOYwC8MAFAIYBOeAXDNBbngJREB8MA3gLABQMdAd2xRgmUpWade-fsDIQ0MAOQAjMgC8ltChgCuFMJxhgy6Wqo1KANHVQYc+WgFYYAXwDc0mXDQAzMroANlDaegZGJmbKEHZYjACigQrWtuhx+IkKtFAUuooeXq68RTy8oJCwCMhpDngA6sKYAMLg0ETiVLT0jCyE7Nx8gsKiHZIDMjByCspqmqFQ+oYcxqZo5rMpMTWMzm4w8pOtUJ6D-D7+QSEwOgvhy5Fr0bG1mWibzwlJjzl5ewfl0BO-BKriAA.

If you take a look at the .d.ts tab on the right side, you'll see how typescript infers the types. If you see how doSomethingWithConst gets inferred with literals, it essentially allows any consuming code that matches name to baz to automatically infer correctly that something is 5 and somethingElse is undefined.

}
}
Loading