diff --git a/governance/xc_admin/packages/xc_admin_common/src/index.ts b/governance/xc_admin/packages/xc_admin_common/src/index.ts index 24c55fbe2c..5431b007d3 100644 --- a/governance/xc_admin/packages/xc_admin_common/src/index.ts +++ b/governance/xc_admin/packages/xc_admin_common/src/index.ts @@ -14,3 +14,27 @@ export * from "./chains"; export * from "./deterministic_stake_accounts"; export * from "./price_store"; export { default as lazerIdl } from "./multisig_transaction/idl/lazer.json"; + +export { + ProgramType, + PROGRAM_TYPE_NAMES, + PriceRawConfig, + ProductRawConfig, + MappingRawConfig, + RawConfig, + DownloadablePriceAccount, + DownloadableProduct, + DownloadableConfig, + ProgramConfig, + ProgramInstructionAccounts, + InstructionAccountsTypeMap, + ValidationResult, +} from "./programs/types"; +export { + getProgramAddress, + isAvailableOnCluster, + getConfig, + getDownloadableConfig, + validateUploadedConfig, + generateInstructions, +} from "./programs/program_registry"; diff --git a/governance/xc_admin/packages/xc_admin_common/src/programs/core/core_functions.ts b/governance/xc_admin/packages/xc_admin_common/src/programs/core/core_functions.ts new file mode 100644 index 0000000000..b2203afcc5 --- /dev/null +++ b/governance/xc_admin/packages/xc_admin_common/src/programs/core/core_functions.ts @@ -0,0 +1,879 @@ +import { + PublicKey, + TransactionInstruction, + AccountInfo, + Connection, +} from "@solana/web3.js"; +import { + AccountType, + PythCluster, + getPythProgramKeyForCluster, + parseBaseData, + parseMappingData, + parsePermissionData, + parsePriceData, + parseProductData, + Product, +} from "@pythnetwork/client"; +import { + findDetermisticAccountAddress, + getMessageBufferAddressForPrice, + getPythOracleMessageBufferCpiAuth, + isMessageBufferAvailable, + MESSAGE_BUFFER_BUFFER_SIZE, + PRICE_FEED_OPS_KEY, + getMaximumNumberOfPublishers, + isPriceStoreInitialized, + isPriceStorePublisherInitialized, + createDetermisticPriceStoreInitializePublisherInstruction, +} from "../../index"; +import { + DownloadableConfig, + DownloadablePriceAccount, + DownloadableProduct, + PriceRawConfig, + RawConfig, + ValidationResult, + ProgramType, +} from "../types"; +import { Program } from "@coral-xyz/anchor"; +import { PythOracle } from "@pythnetwork/client/lib/anchor"; +import { MessageBuffer } from "message_buffer/idl/message_buffer"; + +/** + * Maximum sizes for instruction data to fit into transactions + */ +const MAX_SIZE_ADD_PRODUCT_INSTRUCTION_DATA = 369; +const MAX_SIZE_UPD_PRODUCT_INSTRUCTION_DATA = 403; // upd product has one account less + +/** + * Type for a set of Pyth Core symbols + */ +export type SymbolsSet = Set; + +export type CoreConfigParams = { + accounts: Array<{ pubkey: PublicKey; account: AccountInfo }>; + cluster: PythCluster; +}; + +/** + * Core program instruction accounts needed for generateInstructions + */ +export interface CoreInstructionAccounts { + fundingAccount: PublicKey; + pythProgramClient: Program; + messageBufferClient?: Program; + connection?: Connection; + rawConfig: RawConfig; +} + +/** + * Check if an instruction's data size is within limits + */ +function checkSizeOfProductInstruction( + instruction: TransactionInstruction, + maxSize: number, + symbol: string, +) { + const size = instruction.data.length; + if (size > maxSize) { + throw new Error( + `A symbol metadata is too big to be sent in a transaction (${size} > ${maxSize} bytes). Please reduce the size of the symbol metadata for ${symbol}.`, + ); + } +} + +/** + * Sort object by keys + */ +const sortObjectByKeys = (obj: Record): Array<[string, U]> => + Object.entries(obj).sort(([a], [b]) => a.localeCompare(b)); + +/** + * Helper function to transform object values + */ +const mapValues = ( + obj: Record, + fn: (value: T) => U, +): Record => + Object.fromEntries( + Object.entries(obj).map(([key, value]) => [key, fn(value)]), + ); + +/** + * Sort configuration data for consistent output + */ +function sortData(data: DownloadableConfig): DownloadableConfig { + return mapValues(data, (productData: DownloadableProduct) => ({ + address: productData.address, + metadata: Object.fromEntries( + sortObjectByKeys(productData.metadata), + ) as Omit, + priceAccounts: [...productData.priceAccounts] + .sort((a: DownloadablePriceAccount, b: DownloadablePriceAccount) => + a.address.localeCompare(b.address), + ) + .map((priceAccount: DownloadablePriceAccount) => ({ + address: priceAccount.address, + expo: priceAccount.expo, + minPub: priceAccount.minPub, + maxLatency: priceAccount.maxLatency, + publishers: [...priceAccount.publishers].sort((a: string, b: string) => + a.localeCompare(b), + ), + })), + })); +} + +/** + * Parse raw on-chain accounts into the Pyth Core configuration format + */ +export function getConfig(params: CoreConfigParams): RawConfig { + const accounts = params.accounts; + + // Create a map of parsed base data for each account to avoid repeated parsing + const parsedBaseDataMap = new Map( + accounts.map((account) => { + const baseData = parseBaseData(account.account.data); + return [account.pubkey.toBase58(), baseData]; + }), + ); + + // First pass: Extract price accounts + const priceRawConfigs = Object.fromEntries( + accounts + .filter( + ({ pubkey }) => + parsedBaseDataMap.get(pubkey.toBase58())?.type === AccountType.Price, + ) + .map(({ account, pubkey }) => { + const parsed = parsePriceData(account.data); + return [ + pubkey.toBase58(), + { + next: parsed.nextPriceAccountKey, + address: pubkey, + publishers: parsed.priceComponents + .filter((x) => x.publisher !== null && x.publisher !== undefined) + .map((x) => x.publisher), + expo: parsed.exponent, + minPub: parsed.minPublishers, + maxLatency: parsed.maxLatency, + }, + ]; + }), + ); + + // Second pass: Extract product accounts and link to price accounts + const productRawConfigs = Object.fromEntries( + accounts + .filter( + ({ pubkey }) => + parsedBaseDataMap.get(pubkey.toBase58())?.type === + AccountType.Product, + ) + .map(({ account, pubkey }) => { + const parsed = parseProductData(account.data); + const priceAccounts: PriceRawConfig[] = []; + + // Follow the linked list of price accounts + if (parsed.priceAccountKey) { + let priceAccountKey: string | undefined = + parsed.priceAccountKey.toBase58(); + const processedPriceKeys = new Set(); + + while ( + priceAccountKey && + !processedPriceKeys.has(priceAccountKey) && + priceRawConfigs[priceAccountKey] + ) { + processedPriceKeys.add(priceAccountKey); + const priceConfig: PriceRawConfig = + priceRawConfigs[priceAccountKey]; + priceAccounts.push(priceConfig); + priceAccountKey = priceConfig.next + ? priceConfig.next.toBase58() + : undefined; + } + } + + return [ + pubkey.toBase58(), + { + priceAccounts, + metadata: parsed.product, + address: pubkey, + }, + ]; + }), + ); + + // Third pass: Extract mapping accounts and permission data + const processedProducts = new Map(); + const mappingAccounts = accounts + .filter( + (account) => + parsedBaseDataMap.get(account.pubkey.toBase58())?.type === + AccountType.Mapping, + ) + .map((account) => { + const parsed = parseMappingData(account.account.data); + return { + next: parsed.nextMappingAccount, + address: account.pubkey, + products: parsed.productAccountKeys + .filter((key) => { + const keyStr = key.toBase58(); + // Only include products that exist and haven't been processed yet + return productRawConfigs[keyStr] && !processedProducts.has(keyStr); + }) + .map((key) => { + const keyStr = key.toBase58(); + const product = productRawConfigs[keyStr]; + // Mark this product as processed + processedProducts.set(keyStr, true); + return product; + }), + }; + }); + + // Find permission account if it exists + const permissionAccount = accounts.find( + (account) => + parsedBaseDataMap.get(account.pubkey.toBase58())?.type === + AccountType.Permission, + ); + + return { + mappingAccounts, + ...(permissionAccount && { + permissionAccount: parsePermissionData(permissionAccount.account.data), + }), + }; +} + +/** + * Format configuration for download as JSON + */ +export function getDownloadableConfig( + rawConfig: RawConfig, +): DownloadableConfig { + // Convert the raw config to a user-friendly format for download + if (rawConfig.mappingAccounts.length > 0) { + const symbolToData = Object.fromEntries( + rawConfig.mappingAccounts + .sort( + (mapping1, mapping2) => + mapping2.products.length - mapping1.products.length, + )[0] + .products.sort((product1, product2) => + product1.metadata.symbol.localeCompare(product2.metadata.symbol), + ) + .map((product) => { + const { price_account, ...metadataWithoutPriceAccount } = + product.metadata; + + return [ + product.metadata.symbol, + { + address: product.address.toBase58(), + metadata: metadataWithoutPriceAccount, + priceAccounts: product.priceAccounts.map((p: PriceRawConfig) => { + return { + address: p.address.toBase58(), + publishers: p.publishers.map((p) => p.toBase58()), + expo: p.expo, + minPub: p.minPub, + maxLatency: p.maxLatency, + }; + }), + }, + ]; + }), + ); + + return sortData(symbolToData); + } + + return {}; +} + +/** + * Validate an uploaded configuration against the current configuration + */ +export function validateUploadedConfig( + existingConfig: DownloadableConfig, + uploadedConfig: DownloadableConfig, + cluster: PythCluster, +): ValidationResult { + try { + const existingSymbols = new Set(Object.keys(existingConfig)); + const changes: Record< + string, + { + prev?: Partial; + new?: Partial; + } + > = {}; + + // Create a deep copy of uploadedConfigTyped with deduplicated publishers + const processedConfig = Object.fromEntries( + Object.entries(uploadedConfig).map(([symbol, product]) => { + if ( + product?.priceAccounts?.[0]?.publishers && + Array.isArray(product.priceAccounts[0].publishers) + ) { + // Create a deep copy with deduplicated publishers + return [ + symbol, + { + ...product, + priceAccounts: product.priceAccounts.map((priceAccount) => ({ + ...priceAccount, + publishers: [...new Set(priceAccount.publishers)], + })), + }, + ]; + } + return [symbol, product]; + }), + ); + + // Check for changes to existing symbols + for (const symbol of Object.keys(processedConfig)) { + if (!existingSymbols.has(symbol)) { + // If symbol is not in existing symbols, create new entry + const newProduct = { ...processedConfig[symbol] }; + + // Add required metadata with symbol + if (newProduct.metadata) { + newProduct.metadata = { + symbol, + ...newProduct.metadata, + }; + } + + // These fields are generated deterministically and should not be updated + if (newProduct.address) { + const { address, ...restProduct } = newProduct; + changes[symbol] = { new: restProduct }; + } else { + changes[symbol] = { new: newProduct }; + } + + // Remove address from price accounts if present + if (changes[symbol].new?.priceAccounts?.[0]?.address) { + const newChanges = changes[symbol].new; + + if ( + newChanges && + newChanges.priceAccounts && + newChanges.priceAccounts[0] + ) { + const priceAccount = newChanges.priceAccounts[0]; + const { address, ...restPriceAccount } = priceAccount; + + newChanges.priceAccounts[0] = { + ...restPriceAccount, + address: "", // Placeholder to satisfy type requirements, will be overwritten when created + }; + } + } + } else if ( + // If symbol is in existing symbols, check if data is different + JSON.stringify(existingConfig[symbol]) !== + JSON.stringify(processedConfig[symbol]) + ) { + changes[symbol] = { + prev: existingConfig[symbol], + new: processedConfig[symbol], + }; + } + } + + // Check for symbols to remove (in existing but not in uploaded) + for (const symbol of Object.keys(existingConfig)) { + if (!processedConfig[symbol]) { + changes[symbol] = { + prev: existingConfig[symbol], + }; + } + } + + // Validate that address field is not changed for existing symbols + for (const symbol of Object.keys(processedConfig)) { + if ( + existingSymbols.has(symbol) && + processedConfig[symbol].address && + processedConfig[symbol].address !== existingConfig[symbol].address + ) { + return { + isValid: false, + error: `Address field for product cannot be changed for symbol ${symbol}. Please revert any changes to the address field and try again.`, + }; + } + } + + // Validate that priceAccounts address field is not changed + for (const symbol of Object.keys(processedConfig)) { + if ( + existingSymbols.has(symbol) && + processedConfig[symbol].priceAccounts?.[0] && + existingConfig[symbol].priceAccounts?.[0] && + processedConfig[symbol].priceAccounts[0].address && + processedConfig[symbol].priceAccounts[0].address !== + existingConfig[symbol].priceAccounts[0].address + ) { + return { + isValid: false, + error: `Address field for priceAccounts cannot be changed for symbol ${symbol}. Please revert any changes to the address field and try again.`, + }; + } + } + + // Check that no price account has more than the maximum number of publishers + for (const symbol of Object.keys(processedConfig)) { + const maximumNumberOfPublishers = getMaximumNumberOfPublishers(cluster); + if ( + processedConfig[symbol].priceAccounts?.[0]?.publishers && + processedConfig[symbol].priceAccounts[0].publishers.length > + maximumNumberOfPublishers + ) { + return { + isValid: false, + error: `${symbol} has more than ${maximumNumberOfPublishers} publishers.`, + }; + } + } + + return { + isValid: true, + changes, + }; + } catch (error) { + return { + isValid: false, + error: + error instanceof Error + ? error.message + : "Failed to validate configuration", + }; + } +} + +/** + * Helper function to initialize a publisher in the price store if needed + */ +async function initializePublisherInPriceStore( + publisherKey: PublicKey, + connection: Connection | undefined, + fundingAccount: PublicKey, + verifiedPublishers: PublicKey[], +): Promise { + const instructions: TransactionInstruction[] = []; + + if (!connection || verifiedPublishers.some((el) => el.equals(publisherKey))) { + return instructions; + } + + if ( + (await isPriceStoreInitialized(connection)) && + !(await isPriceStorePublisherInitialized(connection, publisherKey)) + ) { + instructions.push( + await createDetermisticPriceStoreInitializePublisherInstruction( + fundingAccount, + publisherKey, + ), + ); + verifiedPublishers.push(publisherKey); + } + + return instructions; +} + +/** + * Generate instructions for adding a new product and price account + */ +async function generateAddInstructions( + symbol: string, + newChanges: Partial, + cluster: PythCluster, + accounts: CoreInstructionAccounts, + verifiedPublishers: PublicKey[], +): Promise { + const instructions: TransactionInstruction[] = []; + const { + fundingAccount, + pythProgramClient, + messageBufferClient, + connection, + rawConfig, + } = accounts; + + // Generate product account + const [productAccountKey] = await findDetermisticAccountAddress( + AccountType.Product, + symbol, + cluster, + ); + + if (newChanges.metadata) { + const instruction = await pythProgramClient.methods + .addProduct({ ...newChanges.metadata }) + .accounts({ + fundingAccount, + tailMappingAccount: rawConfig.mappingAccounts[0].address, + productAccount: productAccountKey, + }) + .instruction(); + + checkSizeOfProductInstruction( + instruction, + MAX_SIZE_ADD_PRODUCT_INSTRUCTION_DATA, + symbol, + ); + instructions.push(instruction); + } + + // Generate price account + if (newChanges.priceAccounts?.[0]) { + const [priceAccountKey] = await findDetermisticAccountAddress( + AccountType.Price, + symbol, + cluster, + ); + + instructions.push( + await pythProgramClient.methods + .addPrice(newChanges.priceAccounts[0].expo, 1) + .accounts({ + fundingAccount, + productAccount: productAccountKey, + priceAccount: priceAccountKey, + }) + .instruction(), + ); + + // Create message buffer if available + if (isMessageBufferAvailable(cluster) && messageBufferClient) { + instructions.push( + await messageBufferClient.methods + .createBuffer( + getPythOracleMessageBufferCpiAuth(cluster), + priceAccountKey, + MESSAGE_BUFFER_BUFFER_SIZE, + ) + .accounts({ + admin: fundingAccount, + payer: PRICE_FEED_OPS_KEY, + }) + .remainingAccounts([ + { + pubkey: getMessageBufferAddressForPrice(cluster, priceAccountKey), + isSigner: false, + isWritable: true, + }, + ]) + .instruction(), + ); + } + + // Add publishers + if (newChanges.priceAccounts[0].publishers) { + for (const publisherKey of newChanges.priceAccounts[0].publishers) { + const publisherPubKey = new PublicKey(publisherKey); + instructions.push( + await pythProgramClient.methods + .addPublisher(publisherPubKey) + .accounts({ + fundingAccount, + priceAccount: priceAccountKey, + }) + .instruction(), + ); + instructions.push( + ...(await initializePublisherInPriceStore( + publisherPubKey, + connection, + fundingAccount, + verifiedPublishers, + )), + ); + } + } + + // Set min publishers if specified + if (newChanges.priceAccounts[0].minPub !== undefined) { + instructions.push( + await pythProgramClient.methods + .setMinPub(newChanges.priceAccounts[0].minPub, [0, 0, 0]) + .accounts({ + priceAccount: priceAccountKey, + fundingAccount, + }) + .instruction(), + ); + } + + // Set max latency if specified and non-zero + if ( + newChanges.priceAccounts[0].maxLatency !== undefined && + newChanges.priceAccounts[0].maxLatency !== 0 + ) { + instructions.push( + await pythProgramClient.methods + .setMaxLatency(newChanges.priceAccounts[0].maxLatency, [0, 0, 0]) + .accounts({ + priceAccount: priceAccountKey, + fundingAccount, + }) + .instruction(), + ); + } + } + + return instructions; +} + +/** + * Generate instructions for deleting an existing product and price account + */ +async function generateDeleteInstructions( + prev: Partial, + cluster: PythCluster, + accounts: CoreInstructionAccounts, +): Promise { + const instructions: TransactionInstruction[] = []; + const { fundingAccount, pythProgramClient, messageBufferClient } = accounts; + + if (prev.priceAccounts?.[0]?.address) { + const priceAccount = new PublicKey(prev.priceAccounts[0].address); + + // Delete price account + instructions.push( + await pythProgramClient.methods + .delPrice() + .accounts({ + fundingAccount, + productAccount: new PublicKey(prev.address || ""), + priceAccount, + }) + .instruction(), + ); + + // Delete product account + instructions.push( + await pythProgramClient.methods + .delProduct() + .accounts({ + fundingAccount, + mappingAccount: accounts.rawConfig.mappingAccounts[0].address, + productAccount: new PublicKey(prev.address || ""), + }) + .instruction(), + ); + + // Delete message buffer if available + if (isMessageBufferAvailable(cluster) && messageBufferClient) { + instructions.push( + await messageBufferClient.methods + .deleteBuffer( + getPythOracleMessageBufferCpiAuth(cluster), + priceAccount, + ) + .accounts({ + admin: fundingAccount, + payer: PRICE_FEED_OPS_KEY, + messageBuffer: getMessageBufferAddressForPrice( + cluster, + priceAccount, + ), + }) + .instruction(), + ); + } + } + + return instructions; +} + +/** + * Generate instructions for updating an existing product and price account + */ +async function generateUpdateInstructions( + symbol: string, + prev: Partial, + newChanges: Partial, + accounts: CoreInstructionAccounts, + verifiedPublishers: PublicKey[], +): Promise { + const instructions: TransactionInstruction[] = []; + const { fundingAccount, pythProgramClient, connection } = accounts; + + // Update product metadata if changed + if ( + prev.metadata && + newChanges.metadata && + JSON.stringify(prev.metadata) !== JSON.stringify(newChanges.metadata) + ) { + const instruction = await pythProgramClient.methods + .updProduct({ symbol, ...newChanges.metadata }) + .accounts({ + fundingAccount, + productAccount: new PublicKey(prev.address || ""), + }) + .instruction(); + + checkSizeOfProductInstruction( + instruction, + MAX_SIZE_UPD_PRODUCT_INSTRUCTION_DATA, + symbol, + ); + instructions.push(instruction); + } + + const prevPrice = prev.priceAccounts?.[0]; + const newPrice = newChanges.priceAccounts?.[0]; + + if (prevPrice && newPrice) { + // Update exponent if changed + if (prevPrice.expo !== newPrice.expo) { + instructions.push( + await pythProgramClient.methods + .setExponent(newPrice.expo, 1) + .accounts({ + fundingAccount, + priceAccount: new PublicKey(prevPrice.address || ""), + }) + .instruction(), + ); + } + + // Update max latency if changed + if (prevPrice.maxLatency !== newPrice.maxLatency) { + instructions.push( + await pythProgramClient.methods + .setMaxLatency(newPrice.maxLatency, [0, 0, 0]) + .accounts({ + priceAccount: new PublicKey(prevPrice.address || ""), + fundingAccount, + }) + .instruction(), + ); + } + + // Update publishers if changed + if (prevPrice.publishers && newPrice.publishers) { + const publishersToAdd = newPrice.publishers.filter( + (newPub) => !prevPrice.publishers?.includes(newPub), + ); + const publishersToRemove = prevPrice.publishers.filter( + (prevPub) => !newPrice.publishers?.includes(prevPub), + ); + + // Remove publishers + for (const pubKey of publishersToRemove) { + instructions.push( + await pythProgramClient.methods + .delPublisher(new PublicKey(pubKey)) + .accounts({ + fundingAccount, + priceAccount: new PublicKey(prevPrice.address || ""), + }) + .instruction(), + ); + } + + // Add publishers + for (const pubKey of publishersToAdd) { + const publisherPubKey = new PublicKey(pubKey); + instructions.push( + await pythProgramClient.methods + .addPublisher(publisherPubKey) + .accounts({ + fundingAccount, + priceAccount: new PublicKey(prevPrice.address || ""), + }) + .instruction(), + ); + instructions.push( + ...(await initializePublisherInPriceStore( + publisherPubKey, + connection, + fundingAccount, + verifiedPublishers, + )), + ); + } + } + + // Update min publishers if changed + if (prevPrice.minPub !== newPrice.minPub) { + instructions.push( + await pythProgramClient.methods + .setMinPub(newPrice.minPub, [0, 0, 0]) + .accounts({ + priceAccount: new PublicKey(prevPrice.address || ""), + fundingAccount, + }) + .instruction(), + ); + } + } + + return instructions; +} + +/** + * Generate instructions to apply configuration changes + */ +export async function generateInstructions( + changes: Record< + string, + { + prev?: Partial; + new?: Partial; + } + >, + cluster: PythCluster, + accounts: CoreInstructionAccounts, +): Promise { + const instructions: TransactionInstruction[] = []; + const verifiedPublishers: PublicKey[] = []; + + for (const symbol of Object.keys(changes)) { + const { prev, new: newChanges } = changes[symbol]; + + if (!prev && newChanges) { + // Add new product/price + instructions.push( + ...(await generateAddInstructions( + symbol, + newChanges, + cluster, + accounts, + verifiedPublishers, + )), + ); + } else if (prev && !newChanges) { + // Delete existing product/price + instructions.push( + ...(await generateDeleteInstructions(prev, cluster, accounts)), + ); + } else if (prev && newChanges) { + // Update existing product/price + instructions.push( + ...(await generateUpdateInstructions( + symbol, + prev, + newChanges, + accounts, + verifiedPublishers, + )), + ); + } + } + + return instructions; +} diff --git a/governance/xc_admin/packages/xc_admin_common/src/programs/lazer/lazer_functions.ts b/governance/xc_admin/packages/xc_admin_common/src/programs/lazer/lazer_functions.ts new file mode 100644 index 0000000000..54765ae903 --- /dev/null +++ b/governance/xc_admin/packages/xc_admin_common/src/programs/lazer/lazer_functions.ts @@ -0,0 +1,207 @@ +import { PublicKey, TransactionInstruction } from "@solana/web3.js"; +import { PythCluster } from "@pythnetwork/client"; +import { + ValidationResult, + DownloadableProduct, + DownloadableConfig, + ProgramType, +} from "../types"; + +/** + * Program ID for the Pyth Lazer program + */ +export const LAZER_PROGRAM_ID = new PublicKey( + "pytd2yyk641x7ak7mkaasSJVXh6YYZnC7wTmtgAyxPt", +); + +/** + * Lazer-specific configuration type + * TODO: Change to actual Lazer config type + */ +export type LazerConfig = { + programType: ProgramType.PYTH_LAZER; + // Make cluster optional since Lazer might not be tied to a specific cluster + cluster?: PythCluster; + // More generic data source instead of Solana-specific accounts + feeds: LazerFeed[]; + // Additional metadata that might be relevant for Lazer + metadata?: Record; +}; + +/** + * Parameters for getting Lazer configuration + */ +export type LazerConfigParams = { + // Instead of requiring Solana accounts, allow any parameters needed + endpoint?: string; + network?: string; + options?: Record; +}; + +/** + * Lazer program instruction accounts needed for generateInstructions + */ +export interface LazerInstructionAccounts { + fundingAccount: PublicKey; + // Lazer-specific properties + lazerProgramClient?: any; // Replace with proper type when available + cluster: PythCluster; + additionalAccounts?: Record; +} + +/** + * Lazer feed configuration + * TODO: Change to actual Lazer feed type + */ +export type LazerFeed = { + id: string; + metadata: Record; + // Add other feed-specific properties as needed +}; + +/** + * Check if the Pyth Lazer program is available on the specified cluster + * + * @param cluster The Pyth cluster to check + * @returns True if the program is available on the cluster + */ +export function isAvailableOnCluster(cluster: PythCluster): boolean { + return ( + cluster === "pythnet" || + cluster === "mainnet-beta" || + cluster === "devnet" || + cluster === "testnet" + ); +} + +/** + * Get configuration for Lazer program + * + * @param params Parameters to fetch Lazer configuration + * @returns Promise resolving to Lazer-specific configuration object + */ +export function getConfig(params: LazerConfigParams): LazerConfig { + // Extract the properties + const { endpoint, network, options } = params; + + // Example implementation that would fetch data from a non-Solana source + // For now, return a placeholder with empty feeds + + // In a real implementation, this might: + // 1. Connect to a REST API endpoint + // 2. Query a database + // 3. Read from a file + // 4. Or even call a different blockchain's RPC + + // Simulating some async operation + return { + programType: ProgramType.PYTH_LAZER, + // Include cluster if provided in options + cluster: options?.cluster as PythCluster | undefined, + feeds: [], + metadata: { + source: endpoint ?? "unknown", + network: network ?? "unknown", + }, + }; +} + +/** + * Format the configuration for downloading as a JSON file + * + * @param config The program's configuration object + * @returns Configuration formatted for download + */ +export function getDownloadableConfig(config: LazerConfig): DownloadableConfig { + return Object.fromEntries( + config.feeds.map((feed) => [ + feed.id, + { + address: "", + metadata: { + symbol: feed.id, + asset_type: feed.metadata.asset_type?.toString() ?? "", + country: feed.metadata.country?.toString() ?? "", + quote_currency: feed.metadata.quote_currency?.toString() ?? "", + tenor: feed.metadata.tenor?.toString() ?? "", + }, + priceAccounts: [ + { + address: "", + publishers: [], + expo: 0, + minPub: 0, + maxLatency: 0, + }, + ], + }, + ]), + ); +} + +/** + * Validate an uploaded configuration against the current configuration + * + * @param existingConfig Current configuration + * @param uploadedConfig Configuration from an uploaded file + * @param cluster The Pyth cluster the configuration is for + * @returns Object with validation result and optional error message + */ +export function validateUploadedConfig( + existingConfig: DownloadableConfig, + uploadedConfig: unknown, + cluster: PythCluster, +): ValidationResult { + // Basic validation logic for Lazer config + try { + if (typeof uploadedConfig !== "object" || uploadedConfig === null) { + return { + isValid: false, + error: "Invalid JSON format for Lazer configuration", + }; + } + + // More detailed validation would be implemented here + // For now, return not implemented error + return { + isValid: false, + error: "Uploading configuration for Pyth Lazer is not yet supported", + }; + } catch (error) { + return { + isValid: false, + error: + error instanceof Error ? error.message : "Unknown validation error", + }; + } +} + +/** + * Generate the necessary instructions to apply configuration changes + * + * @param changes Configuration changes to apply + * @param cluster The Pyth cluster where the changes will be applied + * @param accounts Additional context needed for generating instructions + * @returns Promise resolving to an array of TransactionInstructions + */ +export async function generateInstructions( + changes: Record< + string, + { + prev?: Partial; + new?: Partial; + } + >, + cluster: PythCluster, + accounts: LazerInstructionAccounts, +): Promise { + // Simple placeholder implementation that returns an empty array of instructions + // In a real implementation, this would transform the changes into Lazer-specific instructions + + // Example of how this might be implemented: + // 1. For each change, determine if it's an add, update, or delete operation + // 2. Map the DownloadableProduct format to Lazer-specific data structure + // 3. Generate appropriate Lazer instructions based on the operation type + + return []; +} diff --git a/governance/xc_admin/packages/xc_admin_common/src/programs/program_registry.ts b/governance/xc_admin/packages/xc_admin_common/src/programs/program_registry.ts new file mode 100644 index 0000000000..fba58c4752 --- /dev/null +++ b/governance/xc_admin/packages/xc_admin_common/src/programs/program_registry.ts @@ -0,0 +1,96 @@ +import { AccountInfo, PublicKey } from "@solana/web3.js"; +import { getPythProgramKeyForCluster, PythCluster } from "@pythnetwork/client"; +import { + DownloadableConfig, + ProgramType, + ProgramConfig, + ValidationResult, + RawConfig, +} from "./types"; + +// Import functions from each program implementation +import * as pythCore from "./core/core_functions"; +import * as pythLazer from "./lazer/lazer_functions"; +import { + LazerConfig, + LAZER_PROGRAM_ID, + LazerConfigParams, +} from "./lazer/lazer_functions"; +import { CoreConfigParams } from "./core/core_functions"; + +/** + * Function to get the program address for each program type + */ +export const getProgramAddress: Record< + ProgramType, + (cluster: PythCluster) => PublicKey +> = { + [ProgramType.PYTH_CORE]: (cluster: PythCluster) => + getPythProgramKeyForCluster(cluster), + [ProgramType.PYTH_LAZER]: () => LAZER_PROGRAM_ID, +}; + +/** + * Function to check if a program is available on a specific cluster + */ +export const isAvailableOnCluster: Record< + ProgramType, + (cluster: PythCluster) => boolean +> = { + [ProgramType.PYTH_CORE]: () => true, // Pyth Core is available on all clusters - using direct value instead of a trivial function + [ProgramType.PYTH_LAZER]: pythLazer.isAvailableOnCluster, +}; + +/** + * Function to get configuration for each program type + */ +export const getConfig: { + [ProgramType.PYTH_CORE]: (params: CoreConfigParams) => RawConfig; + [ProgramType.PYTH_LAZER]: (params: LazerConfigParams) => LazerConfig; +} = { + [ProgramType.PYTH_CORE]: pythCore.getConfig, + [ProgramType.PYTH_LAZER]: pythLazer.getConfig, +}; + +/** + * Function to format the configuration for downloading as a JSON file + * Uses type narrowing to determine the correct implementation based on the config shape + */ +export const getDownloadableConfig = ( + config: ProgramConfig, +): DownloadableConfig => { + if ("mappingAccounts" in config) { + return pythCore.getDownloadableConfig(config); + } else if ( + "feeds" in config && + config.programType === ProgramType.PYTH_LAZER + ) { + return pythLazer.getDownloadableConfig(config); + } + throw new Error( + "Invalid config type - could not determine program type from config structure", + ); +}; + +/** + * Function to validate an uploaded configuration against the current configuration + */ +export const validateUploadedConfig: Record< + ProgramType, + ( + existingConfig: DownloadableConfig, + uploadedConfig: DownloadableConfig, + cluster: PythCluster, + ) => ValidationResult +> = { + [ProgramType.PYTH_CORE]: pythCore.validateUploadedConfig, + [ProgramType.PYTH_LAZER]: pythLazer.validateUploadedConfig, +}; + +/** + * Function to generate the necessary instructions to apply configuration changes + */ +export const generateInstructions = { + [ProgramType.PYTH_CORE]: pythCore.generateInstructions, + [ProgramType.PYTH_LAZER]: pythLazer.generateInstructions, +}; diff --git a/governance/xc_admin/packages/xc_admin_common/src/programs/types.ts b/governance/xc_admin/packages/xc_admin_common/src/programs/types.ts new file mode 100644 index 0000000000..72d5d6cbb9 --- /dev/null +++ b/governance/xc_admin/packages/xc_admin_common/src/programs/types.ts @@ -0,0 +1,118 @@ +import { PublicKey } from "@solana/web3.js"; +import { PermissionData, Product } from "@pythnetwork/client"; +import { LazerConfig, LazerInstructionAccounts } from "./lazer/lazer_functions"; +import { CoreInstructionAccounts } from "./core/core_functions"; +/** + * Represents the different Pyth programs supported by the application. + */ +export enum ProgramType { + /** + * Original Pyth oracle program + */ + PYTH_CORE, + + /** + * Next-generation Pyth oracle program + */ + PYTH_LAZER, +} + +/** + * Human-readable names for program types + */ +export const PROGRAM_TYPE_NAMES: Record = { + [ProgramType.PYTH_CORE]: "Pyth Core", + [ProgramType.PYTH_LAZER]: "Pyth Lazer", +}; + +/** + * Type for raw price configs + */ +export type PriceRawConfig = { + next: PublicKey | null; + address: PublicKey; + expo: number; + minPub: number; + maxLatency: number; + publishers: PublicKey[]; +}; + +/** + * Type for raw product configs + */ +export type ProductRawConfig = { + address: PublicKey; + priceAccounts: PriceRawConfig[]; + metadata: Product; +}; + +/** + * Type for raw mapping configs + */ +export type MappingRawConfig = { + address: PublicKey; + next: PublicKey | null; + products: ProductRawConfig[]; +}; + +/** + * Overall raw configuration type + */ +export type RawConfig = { + mappingAccounts: MappingRawConfig[]; + permissionAccount?: PermissionData; +}; + +/** + * Type for downloadable price account configuration + */ +export type DownloadablePriceAccount = { + address: string; + publishers: string[]; + expo: number; + minPub: number; + maxLatency: number; +}; + +/** + * Type for downloadable product configuration + */ +export type DownloadableProduct = { + address: string; + metadata: Omit; + priceAccounts: DownloadablePriceAccount[]; +}; + +/** + * Type for downloadable configuration + */ +export type DownloadableConfig = Record; + +/** + * Type for configuration that can be either RawConfig for Pyth Core or LazerConfig for Lazer + */ +export type ProgramConfig = RawConfig | LazerConfig; + +/** + * Union type for program instruction accounts + */ +export type ProgramInstructionAccounts = + | CoreInstructionAccounts + | LazerInstructionAccounts; + +/** + * Type mapping to select the appropriate instruction accounts type based on program type + */ +export type InstructionAccountsTypeMap = { + [ProgramType.PYTH_CORE]: CoreInstructionAccounts; + [ProgramType.PYTH_LAZER]: LazerInstructionAccounts; +}; + +/** + * Result of validating an uploaded configuration + */ +export interface ValidationResult { + isValid: boolean; + error?: string; + changes?: any; +} diff --git a/governance/xc_admin/packages/xc_admin_frontend/components/ProgramSwitch.tsx b/governance/xc_admin/packages/xc_admin_frontend/components/ProgramSwitch.tsx new file mode 100644 index 0000000000..77ae2574dd --- /dev/null +++ b/governance/xc_admin/packages/xc_admin_frontend/components/ProgramSwitch.tsx @@ -0,0 +1,93 @@ +import { ProgramType, PROGRAM_TYPE_NAMES } from '@pythnetwork/xc-admin-common' +import { useProgramContext } from '../contexts/ProgramContext' +import { Menu, Transition } from '@headlessui/react' +import { Fragment } from 'react' + +const Arrow = ({ className }: { className?: string }) => ( + + + +) + +/** + * Component that allows users to switch between different Pyth programs + * (Core, Lazer, etc.) + */ +const ProgramSwitch = ({ light = false }: { light?: boolean }) => { + const { programType, setProgramType } = useProgramContext() + + // Convert enum to array of options + const programOptions = Object.entries(PROGRAM_TYPE_NAMES).map( + ([value, label]) => ({ + value: Number(value) as ProgramType, + label, + }) + ) + + return ( + + {({ open }) => ( + <> + + + {programOptions.find((option) => option.value === programType) + ?.label ?? PROGRAM_TYPE_NAMES[programType]} + + + + + + {programOptions.map((option) => ( + + {({ active }) => ( + + )} + + ))} + + + + )} + + ) +} + +export default ProgramSwitch diff --git a/governance/xc_admin/packages/xc_admin_frontend/components/programs/PythCore.tsx b/governance/xc_admin/packages/xc_admin_frontend/components/programs/PythCore.tsx new file mode 100644 index 0000000000..756fa522fb --- /dev/null +++ b/governance/xc_admin/packages/xc_admin_frontend/components/programs/PythCore.tsx @@ -0,0 +1,587 @@ +import { AnchorProvider, Idl, Program } from '@coral-xyz/anchor' +import { getPythProgramKeyForCluster } from '@pythnetwork/client' +import { PythOracle, pythOracleProgram } from '@pythnetwork/client/lib/anchor' +import { PublicKey } from '@solana/web3.js' +import messageBuffer from 'message_buffer/idl/message_buffer.json' +import { MessageBuffer } from 'message_buffer/idl/message_buffer' +import axios from 'axios' +import { useContext, useEffect, useState } from 'react' +import toast from 'react-hot-toast' +import { + getDownloadableConfig, + getMultisigCluster, + isMessageBufferAvailable, + isRemoteCluster, + mapKey, + MESSAGE_BUFFER_PROGRAM_ID, + PRICE_FEED_MULTISIG, + ProgramType, + validateUploadedConfig, + generateInstructions, + DownloadableConfig, + DownloadableProduct, + DownloadablePriceAccount, +} from '@pythnetwork/xc-admin-common' +import { ClusterContext } from '../../contexts/ClusterContext' +import { useMultisigContext } from '../../contexts/MultisigContext' +import { usePythContext } from '../../contexts/PythContext' +import { capitalizeFirstLetter } from '../../utils/capitalizeFirstLetter' +import ClusterSwitch from '../ClusterSwitch' +import Modal from '../common/Modal' +import Spinner from '../common/Spinner' +import Loadbar from '../loaders/Loadbar' +import PermissionDepermissionKey from '../PermissionDepermissionKey' +import { Wallet } from '@coral-xyz/anchor/dist/cjs/provider' + +interface PriceAccountMetadata extends DownloadablePriceAccount { + [key: string]: string | string[] | number +} + +interface PriceFeedData extends DownloadableProduct { + key: string +} + +interface MetadataChanges { + prev?: Record + new: Record +} + +interface PriceAccountChanges { + prev?: PriceAccountMetadata[] + new: PriceAccountMetadata[] +} + +interface PublisherChanges { + prev?: string[] + new: string[] +} + +interface ProductChanges { + prev?: Partial + new: Partial +} + +interface MetadataChangesRowsProps { + changes: MetadataChanges +} + +interface PriceAccountsChangesRowsProps { + changes: PriceAccountChanges +} + +interface PublisherKeysChangesRowsProps { + changes: PublisherChanges +} + +interface NewPriceFeedsRowsProps { + priceFeedData: PriceFeedData +} + +interface OldPriceFeedsRowsProps { + priceFeedSymbol: string +} + +interface ModalContentProps { + changes: Record + onSendProposal: () => void + isSendProposalButtonLoading: boolean +} + +const MetadataChangesRows: React.FC = ({ + changes, +}) => { + const addPriceFeed = !changes.prev && changes.new + return ( + <> + {Object.entries(changes.new).map( + ([metadataKey, newValue]) => + (addPriceFeed || + (changes.prev && changes.prev[metadataKey] !== newValue)) && ( + + + {metadataKey + .split('_') + .map((word) => capitalizeFirstLetter(word)) + .join(' ')} + + + + {!addPriceFeed && changes.prev ? ( + <> + {String(changes.prev[metadataKey])} +
{' '} + + ) : null} + {String(newValue)} + + + ) + )} + + ) +} + +const PriceAccountsChangesRows: React.FC = ({ + changes, +}) => { + const addPriceFeed = !changes.prev && changes.new + return ( + <> + {changes.new.map((priceAccount: PriceAccountMetadata, index: number) => + Object.keys(priceAccount).map((priceAccountKey) => + priceAccountKey === 'publishers' ? ( + addPriceFeed ? ( + + ) : ( + changes.prev && + JSON.stringify(changes.prev[index][priceAccountKey]) !== + JSON.stringify(priceAccount[priceAccountKey]) && ( + + ) + ) + ) : ( + (addPriceFeed || + (changes.prev && + changes.prev[index][priceAccountKey] !== + priceAccount[priceAccountKey])) && ( + + + {priceAccountKey + .split('_') + .map((word) => capitalizeFirstLetter(word)) + .join(' ')} + + + {!addPriceFeed && changes.prev ? ( + <> + {changes.prev[index][priceAccountKey]} +
+ + ) : null} + {priceAccount[priceAccountKey]} + + + ) + ) + ) + )} + + ) +} + +const PublisherKeysChangesRows: React.FC = ({ + changes, +}) => { + const addPriceFeed = !changes.prev && changes.new + const publisherKeysToAdd = addPriceFeed + ? changes.new + : changes.new.filter( + (newPublisher) => !changes.prev?.includes(newPublisher) + ) + const publisherKeysToRemove = addPriceFeed + ? [] + : changes.prev?.filter( + (prevPublisher) => !changes.new.includes(prevPublisher) + ) || [] + return ( + <> + {publisherKeysToRemove.length > 0 && ( + + Remove Publisher(s) + + {publisherKeysToRemove.map((publisherKey) => ( + + {publisherKey} + + ))} + + + )} + {publisherKeysToAdd.length > 0 && ( + + Add Publisher(s) + + {publisherKeysToAdd.map((publisherKey) => ( + + {publisherKey} + + ))} + + + )} + + ) +} + +const NewPriceFeedsRows: React.FC = ({ + priceFeedData, +}) => { + return ( + <> + + + + ) +} + +const OldPriceFeedsRows: React.FC = ({ + priceFeedSymbol, +}) => { + return ( + <> + + Symbol + {priceFeedSymbol} + + + ) +} + +const hasKey = (obj: T, key: PropertyKey): key is keyof T => { + return key in obj +} + +const ModalContent: React.FC = ({ + changes, + onSendProposal, + isSendProposalButtonLoading, +}) => { + return ( + <> + {Object.keys(changes).length > 0 ? ( + + {Object.entries(changes).map(([key, change]) => { + const { prev, new: newChanges } = change + const addPriceFeed = !prev && newChanges + const deletePriceFeed = prev && !newChanges + const diff = + addPriceFeed || deletePriceFeed + ? [] + : prev && newChanges + ? Object.keys(prev).filter( + (k) => + hasKey(prev, k) && + hasKey(newChanges, k) && + JSON.stringify(prev[k]) !== + JSON.stringify(newChanges[k]) + ) + : [] + return ( + + + + + {addPriceFeed && newChanges ? ( + + ) : deletePriceFeed ? ( + + ) : ( + prev && + newChanges && + diff.map((k) => + k === 'metadata' ? ( + , + new: newChanges.metadata as Record< + string, + string | number | boolean + >, + }} + /> + ) : k === 'priceAccounts' ? ( + ({ + ...account, + })) || [], + new: + newChanges.priceAccounts?.map((account) => ({ + ...account, + })) || [], + }} + /> + ) : null + ) + )} + + {Object.keys(changes).indexOf(key) !== + Object.keys(changes).length - 1 ? ( + + + + ) : null} + + ) + })} +
+ {addPriceFeed + ? 'Add New Price Feed' + : deletePriceFeed + ? 'Delete Old Price Feed' + : key} +
+
+
+ ) : ( +

No proposed changes.

+ )} + {Object.keys(changes).length > 0 && ( + <> + + + )} + + ) +} + +interface PythCoreProps { + proposerServerUrl: string +} + +const PythCore: React.FC = ({ proposerServerUrl }) => { + const [data, setData] = useState({}) + const [dataChanges, setDataChanges] = + useState>() + const [isModalOpen, setIsModalOpen] = useState(false) + const [isSendProposalButtonLoading, setIsSendProposalButtonLoading] = + useState(false) + const { cluster } = useContext(ClusterContext) + const isRemote: boolean = isRemoteCluster(cluster) + const { isLoading: isMultisigLoading, readOnlySquads } = useMultisigContext() + const { rawConfig, dataIsLoading, connection } = usePythContext() + const [pythProgramClient, setPythProgramClient] = + useState>() + const [messageBufferClient, setMessageBufferClient] = + useState>() + + const openModal = () => { + setIsModalOpen(true) + } + + const closeModal = () => { + setIsModalOpen(false) + } + + useEffect(() => { + if (!dataIsLoading && rawConfig) { + const downloadableConfig = getDownloadableConfig(rawConfig) + setData(downloadableConfig) + } + }, [rawConfig, dataIsLoading, cluster]) + + const handleDownloadJsonButtonClick = () => { + const dataStr = + 'data:text/json;charset=utf-8,' + + encodeURIComponent(JSON.stringify(data, null, 2)) + const downloadAnchor = document.createElement('a') + downloadAnchor.setAttribute('href', dataStr) + downloadAnchor.setAttribute('download', `data-${cluster}.json`) + document.body.appendChild(downloadAnchor) // required for firefox + downloadAnchor.click() + downloadAnchor.remove() + } + + const handleUploadJsonButtonClick = () => { + const uploadAnchor = document.createElement('input') + uploadAnchor.setAttribute('type', 'file') + uploadAnchor.setAttribute('accept', '.json') + uploadAnchor.addEventListener('change', (e) => { + const file = (e.target as HTMLInputElement).files?.[0] + if (!file) return + + const reader = new FileReader() + reader.onload = (e) => { + if (e.target?.result) { + try { + const uploadedConfig = JSON.parse(e.target.result as string) + const validation = validateUploadedConfig[ProgramType.PYTH_CORE]( + data, + uploadedConfig, + cluster + ) + + if (!validation.isValid) { + toast.error(validation.error || 'Invalid configuration') + return + } + + setDataChanges(validation.changes) + openModal() + } catch (error) { + if (error instanceof Error) { + toast.error(capitalizeFirstLetter(error.message)) + } + } + } + } + reader.readAsText(file) + }) + document.body.appendChild(uploadAnchor) // required for firefox + uploadAnchor.click() + uploadAnchor.remove() + } + + const handleSendProposalButtonClick = async () => { + setIsSendProposalButtonLoading(true) + if (pythProgramClient && dataChanges && !isMultisigLoading) { + try { + const multisigAuthority = readOnlySquads.getAuthorityPDA( + PRICE_FEED_MULTISIG[getMultisigCluster(cluster)], + 1 + ) + const fundingAccount = isRemote + ? mapKey(multisigAuthority) + : multisigAuthority + + const instructions = await generateInstructions[ProgramType.PYTH_CORE]( + dataChanges, + cluster, + { + fundingAccount, + pythProgramClient, + messageBufferClient, + connection, + rawConfig, + } + ) + + const response = await axios.post(proposerServerUrl + '/api/propose', { + instructions, + cluster, + }) + const { proposalPubkey } = response.data + toast.success(`Proposal sent! 🚀 Proposal Pubkey: ${proposalPubkey}`) + setIsSendProposalButtonLoading(false) + closeModal() + } catch (error) { + if (axios.isAxiosError(error) && error.response) { + toast.error(capitalizeFirstLetter(error.response.data)) + } else if (error instanceof Error) { + toast.error(capitalizeFirstLetter(error.message)) + } + setIsSendProposalButtonLoading(false) + } + } + } + + useEffect(() => { + if (connection) { + const provider = new AnchorProvider( + connection, + readOnlySquads.wallet as Wallet, + AnchorProvider.defaultOptions() + ) + setPythProgramClient( + pythOracleProgram(getPythProgramKeyForCluster(cluster), provider) + ) + + if (isMessageBufferAvailable(cluster)) { + setMessageBufferClient( + new Program( + messageBuffer as Idl, + new PublicKey(MESSAGE_BUFFER_PROGRAM_ID), + provider + ) as unknown as Program + ) + } + } + }, [connection, cluster, readOnlySquads]) + + return ( +
+ + } + /> +
+ +
+
+ + +
+
+ {dataIsLoading ? ( +
+ +
+ ) : ( +
+
+ +
+
+ +
+
+ )} +
+
+ ) +} + +export default PythCore diff --git a/governance/xc_admin/packages/xc_admin_frontend/components/programs/PythLazer.tsx b/governance/xc_admin/packages/xc_admin_frontend/components/programs/PythLazer.tsx new file mode 100644 index 0000000000..2d3745d5d9 --- /dev/null +++ b/governance/xc_admin/packages/xc_admin_frontend/components/programs/PythLazer.tsx @@ -0,0 +1,44 @@ +import React from 'react' + +interface PythLazerProps { + proposerServerUrl: string // kept for consistency with PythCore interface +} + +const PythLazer = ({ + proposerServerUrl: _proposerServerUrl, +}: PythLazerProps) => { + return ( +
+
+
+ + + +

+ Pyth Lazer is not supported yet +

+
+

+ The Pyth Lazer program integration is currently under development. +

+

+ Please check back later or switch to Pyth Core using the program + selector above. +

+
+
+ ) +} + +export default PythLazer diff --git a/governance/xc_admin/packages/xc_admin_frontend/components/tabs/General.tsx b/governance/xc_admin/packages/xc_admin_frontend/components/tabs/General.tsx index 8b27c71a37..83a19472f4 100644 --- a/governance/xc_admin/packages/xc_admin_frontend/components/tabs/General.tsx +++ b/governance/xc_admin/packages/xc_admin_frontend/components/tabs/General.tsx @@ -1,957 +1,31 @@ -import { AnchorProvider, Idl, Program } from '@coral-xyz/anchor' -import { AccountType, getPythProgramKeyForCluster } from '@pythnetwork/client' -import { PythOracle, pythOracleProgram } from '@pythnetwork/client/lib/anchor' -import { PublicKey, TransactionInstruction } from '@solana/web3.js' -import messageBuffer from 'message_buffer/idl/message_buffer.json' -import { MessageBuffer } from 'message_buffer/idl/message_buffer' -import axios from 'axios' -import { useCallback, useContext, useEffect, useState } from 'react' -import toast from 'react-hot-toast' -import { - findDetermisticAccountAddress, - getMultisigCluster, - getPythOracleMessageBufferCpiAuth, - isMessageBufferAvailable, - isRemoteCluster, - mapKey, - MESSAGE_BUFFER_PROGRAM_ID, - MESSAGE_BUFFER_BUFFER_SIZE, - PRICE_FEED_MULTISIG, - PRICE_FEED_OPS_KEY, - getMessageBufferAddressForPrice, - getMaximumNumberOfPublishers, - isPriceStoreInitialized, - isPriceStorePublisherInitialized, - createDetermisticPriceStoreInitializePublisherInstruction, -} from '@pythnetwork/xc-admin-common' -import { ClusterContext } from '../../contexts/ClusterContext' -import { useMultisigContext } from '../../contexts/MultisigContext' -import { usePythContext } from '../../contexts/PythContext' -import { capitalizeFirstLetter } from '../../utils/capitalizeFirstLetter' -import ClusterSwitch from '../ClusterSwitch' -import Modal from '../common/Modal' -import Spinner from '../common/Spinner' -import Loadbar from '../loaders/Loadbar' -import PermissionDepermissionKey from '../PermissionDepermissionKey' -import { PriceRawConfig } from '../../hooks/usePyth' -import { Wallet } from '@coral-xyz/anchor/dist/cjs/provider' - -// These are the values such that a transaction adding a remote addProduct or updProduct instruction to a proposal are exactly 1232 bytes -const MAX_SIZE_ADD_PRODUCT_INSTRUCTION_DATA = 369 -const MAX_SIZE_UPD_PRODUCT_INSTRUCTION_DATA = 403 // upd product has one account less - -const checkSizeOfProductInstruction = ( - instruction: TransactionInstruction, - maxSize: number, - symbol: string -) => { - const size = instruction.data.length - if (size > maxSize) { - throw new Error( - `A symbol metadata is too big to be sent in a transaction (${size} > ${maxSize} bytes). Please reduce the size of the symbol metadata for ${symbol}.` - ) - } -} +import { ProgramType } from '@pythnetwork/xc-admin-common' +import { useProgramContext } from '../../contexts/ProgramContext' +import ProgramSwitch from '../ProgramSwitch' +import PythCore from '../programs/PythCore' +import PythLazer from '../programs/PythLazer' const General = ({ proposerServerUrl }: { proposerServerUrl: string }) => { - const [data, setData] = useState({}) // eslint-disable-line @typescript-eslint/no-explicit-any - const [dataChanges, setDataChanges] = useState>() // eslint-disable-line @typescript-eslint/no-explicit-any - const [existingSymbols, setExistingSymbols] = useState>(new Set()) - const [isModalOpen, setIsModalOpen] = useState(false) - const [isSendProposalButtonLoading, setIsSendProposalButtonLoading] = - useState(false) - const { cluster } = useContext(ClusterContext) - const isRemote: boolean = isRemoteCluster(cluster) // Move to multisig context - const { isLoading: isMultisigLoading, readOnlySquads } = useMultisigContext() - const { rawConfig, dataIsLoading, connection } = usePythContext() - const [pythProgramClient, setPythProgramClient] = - useState>() - - const [messageBufferClient, setMessageBufferClient] = - useState>() + const { programType } = useProgramContext() - const openModal = () => { - setIsModalOpen(true) - } - - const closeModal = () => { - setIsModalOpen(false) - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const sortData = (data: any) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const sortedData: any = {} - Object.keys(data) - .sort() - .forEach((key) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const sortedInnerData: any = {} - Object.keys(data[key]) - .sort() - .forEach((innerKey) => { - if (innerKey === 'metadata') { - sortedInnerData[innerKey] = sortObjectByKeys(data[key][innerKey]) - } else if (innerKey === 'priceAccounts') { - // sort price accounts by address - sortedInnerData[innerKey] = data[key][innerKey].sort( - ( - priceAccount1: any, // eslint-disable-line @typescript-eslint/no-explicit-any - priceAccount2: any // eslint-disable-line @typescript-eslint/no-explicit-any - ) => priceAccount1.address.localeCompare(priceAccount2.address) - ) - // sort price accounts keys - sortedInnerData[innerKey] = sortedInnerData[innerKey].map( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (priceAccount: any) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const sortedPriceAccount: any = {} - Object.keys(priceAccount) - .sort() - .forEach((priceAccountKey) => { - if (priceAccountKey === 'publishers') { - sortedPriceAccount[priceAccountKey] = priceAccount[ - priceAccountKey - ].sort((pub1: string, pub2: string) => - pub1.localeCompare(pub2) - ) - } else { - sortedPriceAccount[priceAccountKey] = - priceAccount[priceAccountKey] - } - }) - return sortedPriceAccount - } - ) - } else { - sortedInnerData[innerKey] = data[key][innerKey] - } - }) - sortedData[key] = sortedInnerData - }) - return sortedData - } - const sortDataMemo = useCallback(sortData, []) - - useEffect(() => { - if (!dataIsLoading && rawConfig && rawConfig.mappingAccounts.length > 0) { - const symbolToData: any = {} // eslint-disable-line @typescript-eslint/no-explicit-any - rawConfig.mappingAccounts - .sort( - (mapping1, mapping2) => - mapping2.products.length - mapping1.products.length - )[0] - .products.sort((product1, product2) => - product1.metadata.symbol.localeCompare(product2.metadata.symbol) - ) - .map((product) => { - symbolToData[product.metadata.symbol] = { - address: product.address.toBase58(), - metadata: { - ...product.metadata, - }, - priceAccounts: product.priceAccounts.map((p: PriceRawConfig) => { - return { - address: p.address.toBase58(), - publishers: p.publishers - .map((p) => p.toBase58()) - .slice(0, getMaximumNumberOfPublishers(cluster)), - expo: p.expo, - minPub: p.minPub, - maxLatency: p.maxLatency, - } - }), - } - // this field is immutable and should not be updated - delete symbolToData[product.metadata.symbol].metadata.price_account - }) - setExistingSymbols(new Set(Object.keys(symbolToData))) - setData(sortDataMemo(symbolToData)) - } - }, [rawConfig, dataIsLoading, sortDataMemo, cluster]) - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const sortObjectByKeys = (obj: any) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const sortedObj: any = {} - Object.keys(obj) - .sort() - .forEach((key) => { - sortedObj[key] = obj[key] - }) - return sortedObj - } - - // function to download json file - const handleDownloadJsonButtonClick = () => { - const dataStr = - 'data:text/json;charset=utf-8,' + - encodeURIComponent(JSON.stringify(data, null, 2)) - const downloadAnchor = document.createElement('a') - downloadAnchor.setAttribute('href', dataStr) - downloadAnchor.setAttribute('download', `data-${cluster}.json`) - document.body.appendChild(downloadAnchor) // required for firefox - downloadAnchor.click() - downloadAnchor.remove() - } - - // function to upload json file and update changes state - const handleUploadJsonButtonClick = () => { - const uploadAnchor = document.createElement('input') - uploadAnchor.setAttribute('type', 'file') - uploadAnchor.setAttribute('accept', '.json') - uploadAnchor.addEventListener('change', (e) => { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const file = (e.target as HTMLInputElement).files![0] - const reader = new FileReader() - reader.onload = (e) => { - if (e.target) { - const fileData = e.target.result - if (!isValidJson(fileData as string)) return - const fileDataParsed = sortData(JSON.parse(fileData as string)) - const changes: Record = {} // eslint-disable-line @typescript-eslint/no-explicit-any - Object.keys(fileDataParsed).forEach((symbol) => { - // remove duplicate publishers - fileDataParsed[symbol].priceAccounts[0].publishers = [ - ...new Set(fileDataParsed[symbol].priceAccounts[0].publishers), - ] - if (!existingSymbols.has(symbol)) { - // if symbol is not in existing symbols, create new entry - changes[symbol] = { new: {} } - changes[symbol].new = { ...fileDataParsed[symbol] } - changes[symbol].new.metadata = { - symbol, - ...changes[symbol].new.metadata, - } - // these fields are generated deterministically and should not be updated - delete changes[symbol].new.address - delete changes[symbol].new.priceAccounts[0].address - } else if ( - // if symbol is in existing symbols, check if data is different - JSON.stringify(data[symbol]) !== - JSON.stringify(fileDataParsed[symbol]) - ) { - changes[symbol] = { prev: {}, new: {} } - changes[symbol].prev = { ...data[symbol] } - changes[symbol].new = { ...fileDataParsed[symbol] } - } - }) - // check if any existing symbols are not in uploaded json - Object.keys(data).forEach((symbol) => { - if (!fileDataParsed[symbol]) { - changes[symbol] = { prev: {} } - changes[symbol].prev = { ...data[symbol] } - } - }) - setDataChanges(changes) - openModal() - } - } - reader.readAsText(file) - }) - document.body.appendChild(uploadAnchor) // required for firefox - uploadAnchor.click() - uploadAnchor.remove() - } - - // check if uploaded json is valid json - const isValidJson = (json: string) => { + // Function to render the appropriate program component + const renderProgramComponent = () => { try { - JSON.parse(json) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (e: any) { - toast.error(capitalizeFirstLetter(e.message)) - return false - } - let isValid = true - // check if json keys "address" key is changed - const jsonParsed = JSON.parse(json) - Object.keys(jsonParsed).forEach((symbol) => { - if ( - existingSymbols.has(symbol) && - jsonParsed[symbol].address && - jsonParsed[symbol].address !== data[symbol].address - ) { - toast.error( - `Address field for product cannot be changed for symbol ${symbol}. Please revert any changes to the address field and try again.` - ) - isValid = false - } - }) - - // check if json keys "priceAccounts" key "address" key is changed - Object.keys(jsonParsed).forEach((symbol) => { - if ( - existingSymbols.has(symbol) && - jsonParsed[symbol].priceAccounts[0] && - data[symbol].priceAccounts[0] && - jsonParsed[symbol].priceAccounts[0].address && - jsonParsed[symbol].priceAccounts[0].address !== - data[symbol].priceAccounts[0].address - ) { - toast.error( - `Address field for priceAccounts cannot be changed for symbol ${symbol}. Please revert any changes to the address field and try again.` - ) - isValid = false - } - }) - - // check that no price account has more than the maximum number of publishers - Object.keys(jsonParsed).forEach((symbol) => { - const maximumNumberOfPublishers = getMaximumNumberOfPublishers(cluster) - if ( - jsonParsed[symbol].priceAccounts[0].publishers.length > - maximumNumberOfPublishers - ) { - toast.error( - `${symbol} has more than ${maximumNumberOfPublishers} publishers.` - ) - isValid = false - } - }) - - return isValid - } - - const handleSendProposalButtonClick = () => { - const handleSendProposalButtonClickAsync = async () => { - setIsSendProposalButtonLoading(true) - if (pythProgramClient && dataChanges && !isMultisigLoading) { - const instructions: TransactionInstruction[] = [] - const publisherInPriceStoreInitializationsVerified: PublicKey[] = [] - - for (const symbol of Object.keys(dataChanges)) { - const multisigAuthority = readOnlySquads.getAuthorityPDA( - PRICE_FEED_MULTISIG[getMultisigCluster(cluster)], - 1 - ) - const fundingAccount = isRemote - ? mapKey(multisigAuthority) - : multisigAuthority - - const initPublisherInPriceStore = async (publisherKey: PublicKey) => { - // Ignore this step if Price Store is not initialized (or not deployed) - if (!connection || !(await isPriceStoreInitialized(connection))) { - return - } - - if ( - publisherInPriceStoreInitializationsVerified.every( - (el) => !el.equals(publisherKey) - ) - ) { - if ( - !connection || - !(await isPriceStorePublisherInitialized( - connection, - publisherKey - )) - ) { - instructions.push( - await createDetermisticPriceStoreInitializePublisherInstruction( - fundingAccount, - publisherKey - ) - ) - } - publisherInPriceStoreInitializationsVerified.push(publisherKey) - } - } - const { prev, new: newChanges } = dataChanges[symbol] - // if prev is undefined, it means that the symbol is new - if (!prev) { - // deterministically generate product account key - const productAccountKey: PublicKey = ( - await findDetermisticAccountAddress( - AccountType.Product, - symbol, - cluster - ) - )[0] - // create add product account instruction - const instruction = await pythProgramClient.methods - .addProduct({ ...newChanges.metadata }) - .accounts({ - fundingAccount, - tailMappingAccount: rawConfig.mappingAccounts[0].address, - productAccount: productAccountKey, - }) - .instruction() - checkSizeOfProductInstruction( - instruction, - MAX_SIZE_ADD_PRODUCT_INSTRUCTION_DATA, - symbol - ) - instructions.push(instruction) - - // deterministically generate price account key - const priceAccountKey: PublicKey = ( - await findDetermisticAccountAddress( - AccountType.Price, - symbol, - cluster - ) - )[0] - // create add price account instruction - instructions.push( - await pythProgramClient.methods - .addPrice(newChanges.priceAccounts[0].expo, 1) - .accounts({ - fundingAccount, - productAccount: productAccountKey, - priceAccount: priceAccountKey, - }) - .instruction() - ) - - if (isMessageBufferAvailable(cluster) && messageBufferClient) { - // create create buffer instruction for the price account - instructions.push( - await messageBufferClient.methods - .createBuffer( - getPythOracleMessageBufferCpiAuth(cluster), - priceAccountKey, - MESSAGE_BUFFER_BUFFER_SIZE - ) - .accounts({ - admin: fundingAccount, - payer: PRICE_FEED_OPS_KEY, - }) - .remainingAccounts([ - { - pubkey: getMessageBufferAddressForPrice( - cluster, - priceAccountKey - ), - isSigner: false, - isWritable: true, - }, - ]) - .instruction() - ) - } - - // create add publisher instruction if there are any publishers - for (const publisherKey of newChanges.priceAccounts[0].publishers) { - const publisherPubKey = new PublicKey(publisherKey) - instructions.push( - await pythProgramClient.methods - .addPublisher(publisherPubKey) - .accounts({ - fundingAccount, - priceAccount: priceAccountKey, - }) - .instruction() - ) - await initPublisherInPriceStore(publisherPubKey) - } - - // create set min publisher instruction if there are any publishers - if (newChanges.priceAccounts[0].minPub !== undefined) { - instructions.push( - await pythProgramClient.methods - .setMinPub(newChanges.priceAccounts[0].minPub, [0, 0, 0]) - .accounts({ - priceAccount: priceAccountKey, - fundingAccount, - }) - .instruction() - ) - } - - // If maxLatency is set and is not 0, create update maxLatency instruction - if ( - newChanges.priceAccounts[0].maxLatency !== undefined && - newChanges.priceAccounts[0].maxLatency !== 0 - ) { - instructions.push( - await pythProgramClient.methods - .setMaxLatency( - newChanges.priceAccounts[0].maxLatency, - [0, 0, 0] - ) - .accounts({ - priceAccount: priceAccountKey, - fundingAccount, - }) - .instruction() - ) - } - } else if (!newChanges) { - const priceAccount = new PublicKey(prev.priceAccounts[0].address) - - // if new is undefined, it means that the symbol is deleted - // create delete price account instruction - instructions.push( - await pythProgramClient.methods - .delPrice() - .accounts({ - fundingAccount, - productAccount: new PublicKey(prev.address), - priceAccount, - }) - .instruction() - ) - - // create delete product account instruction - instructions.push( - await pythProgramClient.methods - .delProduct() - .accounts({ - fundingAccount, - mappingAccount: rawConfig.mappingAccounts[0].address, - productAccount: new PublicKey(prev.address), - }) - .instruction() - ) - - if (isMessageBufferAvailable(cluster) && messageBufferClient) { - // create delete buffer instruction for the price buffer - instructions.push( - await messageBufferClient.methods - .deleteBuffer( - getPythOracleMessageBufferCpiAuth(cluster), - priceAccount - ) - .accounts({ - admin: fundingAccount, - payer: PRICE_FEED_OPS_KEY, - messageBuffer: getMessageBufferAddressForPrice( - cluster, - priceAccount - ), - }) - .instruction() - ) - } - } else { - // check if metadata has changed - if ( - JSON.stringify(prev.metadata) !== - JSON.stringify(newChanges.metadata) - ) { - const instruction = await pythProgramClient.methods - .updProduct({ symbol, ...newChanges.metadata }) // If there's a symbol in newChanges.metadata, it will overwrite the current symbol - .accounts({ - fundingAccount, - productAccount: new PublicKey(prev.address), - }) - .instruction() - checkSizeOfProductInstruction( - instruction, - MAX_SIZE_UPD_PRODUCT_INSTRUCTION_DATA, - symbol - ) - instructions.push(instruction) - } - - if ( - JSON.stringify(prev.priceAccounts[0].expo) !== - JSON.stringify(newChanges.priceAccounts[0].expo) - ) { - // create update exponent instruction - instructions.push( - await pythProgramClient.methods - .setExponent(newChanges.priceAccounts[0].expo, 1) - .accounts({ - fundingAccount, - priceAccount: new PublicKey(prev.priceAccounts[0].address), - }) - .instruction() - ) - } - - // check if maxLatency has changed - if ( - prev.priceAccounts[0].maxLatency !== - newChanges.priceAccounts[0].maxLatency - ) { - // create update product account instruction - instructions.push( - await pythProgramClient.methods - .setMaxLatency( - newChanges.priceAccounts[0].maxLatency, - [0, 0, 0] - ) - .accounts({ - priceAccount: new PublicKey(prev.priceAccounts[0].address), - fundingAccount, - }) - .instruction() - ) - } - - // check if publishers have changed - const publisherKeysToAdd = - newChanges.priceAccounts[0].publishers.filter( - (newPublisher: string) => - !prev.priceAccounts[0].publishers.includes(newPublisher) - ) - // check if there are any publishers to remove by comparing prev and new - const publisherKeysToRemove = - prev.priceAccounts[0].publishers.filter( - (prevPublisher: string) => - !newChanges.priceAccounts[0].publishers.includes( - prevPublisher - ) - ) - - // add instructions to remove publishers - - for (const publisherKey of publisherKeysToRemove) { - instructions.push( - await pythProgramClient.methods - .delPublisher(new PublicKey(publisherKey)) - .accounts({ - fundingAccount, - priceAccount: new PublicKey(prev.priceAccounts[0].address), - }) - .instruction() - ) - } - - // add instructions to add new publishers - for (const publisherKey of publisherKeysToAdd) { - const publisherPubKey = new PublicKey(publisherKey) - instructions.push( - await pythProgramClient.methods - .addPublisher(publisherPubKey) - .accounts({ - fundingAccount, - priceAccount: new PublicKey(prev.priceAccounts[0].address), - }) - .instruction() - ) - await initPublisherInPriceStore(publisherPubKey) - } - - // check if minPub has changed - if ( - prev.priceAccounts[0].minPub !== - newChanges.priceAccounts[0].minPub - ) { - // create update product account instruction - instructions.push( - await pythProgramClient.methods - .setMinPub(newChanges.priceAccounts[0].minPub, [0, 0, 0]) - .accounts({ - priceAccount: new PublicKey(prev.priceAccounts[0].address), - fundingAccount, - }) - .instruction() - ) - } - } - } - - const response = await axios.post(proposerServerUrl + '/api/propose', { - instructions, - cluster, - }) - const { proposalPubkey } = response.data - toast.success(`Proposal sent! 🚀 Proposal Pubkey: ${proposalPubkey}`) - setIsSendProposalButtonLoading(false) - closeModal() + switch (programType) { + case ProgramType.PYTH_CORE: + return + case ProgramType.PYTH_LAZER: + return + default: + return
Unknown program type
} + } catch (error) { + console.error('Error rendering program component:', error) + return
An error occurred loading the program component
} - - handleSendProposalButtonClickAsync().catch((error) => { - if (error.response) { - toast.error(capitalizeFirstLetter(error.response.data)) - } else { - toast.error(capitalizeFirstLetter(error.message)) - } - setIsSendProposalButtonLoading(false) - }) - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const MetadataChangesRows = ({ changes }: { changes: any }) => { - const addPriceFeed = changes.prev === undefined && changes.new !== undefined - return ( - <> - {Object.keys(changes.new).map( - (metadataKey) => - (addPriceFeed || - changes.prev[metadataKey] !== changes.new[metadataKey]) && ( - - - {metadataKey - .split('_') - .map((word) => capitalizeFirstLetter(word)) - .join(' ')} - - - - {!addPriceFeed ? ( - <> - {changes.prev[metadataKey]} -
{' '} - - ) : null} - {changes.new[metadataKey]} - - - ) - )} - - ) - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const PriceAccountsChangesRows = ({ changes }: { changes: any }) => { - const addPriceFeed = changes.prev === undefined && changes.new !== undefined - return ( - <> - {changes.new.map( - ( - priceAccount: any, // eslint-disable-line @typescript-eslint/no-explicit-any - index: number - ) => - Object.keys(priceAccount).map((priceAccountKey) => - priceAccountKey === 'publishers' ? ( - addPriceFeed ? ( - - ) : ( - JSON.stringify(changes.prev[index][priceAccountKey]) !== - JSON.stringify(priceAccount[priceAccountKey]) && ( - - ) - ) - ) : ( - (addPriceFeed || - changes.prev[index][priceAccountKey] !== - priceAccount[priceAccountKey]) && ( - - - {priceAccountKey - .split('_') - .map((word) => capitalizeFirstLetter(word)) - .join(' ')} - - - {!addPriceFeed ? ( - <> - {changes.prev[index][priceAccountKey]} -
- - ) : null} - {priceAccount[priceAccountKey]} - - - ) - ) - ) - )} - - ) } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const PublisherKeysChangesRows = ({ changes }: { changes: any }) => { - const addPriceFeed = changes.prev === undefined && changes.new !== undefined - const publisherKeysToAdd = addPriceFeed - ? changes.new - : changes.new.filter( - (newPublisher: string) => !changes.prev.includes(newPublisher) - ) - const publisherKeysToRemove = addPriceFeed - ? [] - : changes.prev.filter( - (prevPublisher: string) => !changes.new.includes(prevPublisher) - ) - return ( - <> - {publisherKeysToRemove.length > 0 && ( - - Remove Publisher(s) - - {publisherKeysToRemove.map((publisherKey: string) => ( - - {publisherKey} - - ))} - - - )} - {publisherKeysToAdd.length > 0 && ( - - Add Publisher(s) - - {publisherKeysToAdd.map((publisherKey: string) => ( - - {publisherKey} - - ))} - - - )} - - ) - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const NewPriceFeedsRows = ({ priceFeedData }: { priceFeedData: any }) => { - return ( - <> - - - - ) - } - - const OldPriceFeedsRows = ({ - priceFeedSymbol, - }: { - priceFeedSymbol: string - }) => { - return ( - <> - - Symbol - {priceFeedSymbol} - - - ) - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const ModalContent = ({ changes }: { changes: any }) => { - return ( - <> - {Object.keys(changes).length > 0 ? ( - - {/* compare changes.prev and changes.new and display the fields that are different */} - {Object.keys(changes).map((key) => { - const { prev, new: newChanges } = changes[key] - const addPriceFeed = - prev === undefined && newChanges !== undefined - const deletePriceFeed = - prev !== undefined && newChanges === undefined - const diff = - addPriceFeed || deletePriceFeed - ? [] - : Object.keys(prev).filter( - (k) => - JSON.stringify(prev[k]) !== - JSON.stringify(newChanges[k]) - ) - return ( - - - - - {addPriceFeed ? ( - - ) : deletePriceFeed ? ( - - ) : ( - diff.map((k) => - k === 'metadata' ? ( - - ) : k === 'priceAccounts' ? ( - - ) : null - ) - )} - - {/* add a divider only if its not the last item */} - {Object.keys(changes).indexOf(key) !== - Object.keys(changes).length - 1 ? ( - - - - ) : null} - - ) - })} -
- {addPriceFeed - ? 'Add New Price Feed' - : deletePriceFeed - ? 'Delete Old Price Feed' - : key} -
-
-
- ) : ( -

No proposed changes.

- )} - {Object.keys(changes).length > 0 && ( - <> - - - )} - - ) - } - - useEffect(() => { - if (connection) { - const provider = new AnchorProvider( - connection, - readOnlySquads.wallet as Wallet, - AnchorProvider.defaultOptions() - ) - setPythProgramClient( - pythOracleProgram(getPythProgramKeyForCluster(cluster), provider) - ) - - if (isMessageBufferAvailable(cluster)) { - setMessageBufferClient( - new Program( - messageBuffer as Idl, - new PublicKey(MESSAGE_BUFFER_PROGRAM_ID), - provider - ) as unknown as Program - ) - } - } - }, [connection, cluster, readOnlySquads]) - return (
- } - />

General

@@ -960,49 +34,12 @@ const General = ({ proposerServerUrl }: { proposerServerUrl: string }) => {
- -
-
-
- - -
-
- {dataIsLoading ? ( -
- +
+
- ) : ( -
-
- -
-
- -
-
- )} +
+ {renderProgramComponent()}
) diff --git a/governance/xc_admin/packages/xc_admin_frontend/components/tabs/Proposals/Proposals.tsx b/governance/xc_admin/packages/xc_admin_frontend/components/tabs/Proposals/Proposals.tsx index 48660cb508..b6dfb6da50 100644 --- a/governance/xc_admin/packages/xc_admin_frontend/components/tabs/Proposals/Proposals.tsx +++ b/governance/xc_admin/packages/xc_admin_frontend/components/tabs/Proposals/Proposals.tsx @@ -1,6 +1,6 @@ import { TransactionAccount } from '@sqds/mesh/lib/types' import { useRouter } from 'next/router' -import { useCallback, useContext, useEffect, useState, useMemo } from 'react' +import { useContext, useEffect, useState, useMemo, Fragment } from 'react' import { ClusterContext } from '../../../contexts/ClusterContext' import { useMultisigContext } from '../../../contexts/MultisigContext' import { PROPOSAL_STATUSES } from './utils' @@ -8,13 +8,20 @@ import ClusterSwitch from '../../ClusterSwitch' import Loadbar from '../../loaders/Loadbar' import { Select } from '../../Select' import { useQueryState, parseAsStringLiteral } from 'nuqs' +import { Menu, Transition } from '@headlessui/react' import { ProposalRow } from './ProposalRow' import { getProposalStatus } from './utils' import { Proposal } from './Proposal' import { useWallet } from '@solana/wallet-adapter-react' -type ProposalType = 'priceFeed' | 'governance' +type ProposalType = 'priceFeed' | 'governance' | 'lazer' + +const PROPOSAL_TYPE_NAMES: Record = { + priceFeed: 'Price Feed', + governance: 'Governance', + lazer: 'Lazer', +} const VOTE_STATUSES = [ 'any', @@ -29,6 +36,25 @@ const DEFAULT_VOTE_STATUS = 'any' const PROPOSAL_STATUS_FILTERS = ['all', ...PROPOSAL_STATUSES] as const const DEFAULT_PROPOSAL_STATUS_FILTER = 'all' +const Arrow = ({ className }: { className?: string }) => ( + + + +) + const Proposals = () => { const router = useRouter() const [currentProposal, setCurrentProposal] = useState() @@ -57,14 +83,29 @@ const Proposals = () => { const [proposalType, setProposalType] = useState('priceFeed') - const multisigAccount = - proposalType === 'priceFeed' - ? priceFeedMultisigAccount - : upgradeMultisigAccount - const multisigProposals = - proposalType === 'priceFeed' - ? priceFeedMultisigProposals - : upgradeMultisigProposals + const multisigAccount = useMemo(() => { + switch (proposalType) { + case 'priceFeed': + return priceFeedMultisigAccount + case 'governance': + return upgradeMultisigAccount + default: + return priceFeedMultisigAccount + } + }, [proposalType, priceFeedMultisigAccount, upgradeMultisigAccount]) + + const multisigProposals = useMemo(() => { + switch (proposalType) { + case 'priceFeed': + return priceFeedMultisigProposals + case 'governance': + return upgradeMultisigProposals + case 'lazer': + return [] + default: + return priceFeedMultisigProposals + } + }, [proposalType, priceFeedMultisigProposals, upgradeMultisigProposals]) const handleClickBackToProposals = () => { delete router.query.proposal @@ -86,14 +127,6 @@ const Proposals = () => { } }, [router.query.proposal]) - const switchProposalType = useCallback(() => { - if (proposalType === 'priceFeed') { - setProposalType('governance') - } else { - setProposalType('priceFeed') - } - }, [proposalType]) - useEffect(() => { if (currentProposalPubkey) { const currProposal = multisigProposals.find( @@ -101,22 +134,34 @@ const Proposals = () => { ) setCurrentProposal(currProposal) if (currProposal === undefined) { - const otherProposals = - proposalType !== 'priceFeed' - ? priceFeedMultisigProposals - : upgradeMultisigProposals - if ( - otherProposals.findIndex( - (proposal) => - proposal.publicKey.toBase58() === currentProposalPubkey - ) !== -1 - ) { - switchProposalType() + // Check if the proposal exists in other proposal types + const allProposalTypes: ProposalType[] = ['priceFeed', 'governance'] + for (const type of allProposalTypes) { + if (type === proposalType) continue + + let otherProposals: TransactionAccount[] = [] + switch (type) { + case 'priceFeed': + otherProposals = priceFeedMultisigProposals + break + case 'governance': + otherProposals = upgradeMultisigProposals + break + } + + if ( + otherProposals.findIndex( + (proposal) => + proposal.publicKey.toBase58() === currentProposalPubkey + ) !== -1 + ) { + setProposalType(type) + break + } } } } }, [ - switchProposalType, priceFeedMultisigProposals, proposalType, upgradeMultisigProposals, @@ -180,12 +225,19 @@ const Proposals = () => { } }, [proposalsFilteredByStatus, walletPublicKey, voteStatus]) + // Convert proposal types to array of options + const proposalTypeOptions: ProposalType[] = [ + 'priceFeed', + 'governance', + 'lazer', + ] + return (

- {proposalType === 'priceFeed' ? 'Price Feed ' : 'Governance '}{' '} + {PROPOSAL_TYPE_NAMES[proposalType]}{' '} {router.query.proposal === undefined ? 'Proposals' : 'Proposal'}

@@ -210,17 +262,50 @@ const Proposals = () => { Refresh )} - + {({ open }) => ( + <> + + + {PROPOSAL_TYPE_NAMES[proposalType]} Proposals + + + + + + {proposalTypeOptions.map((type) => ( + + {({ active }) => ( + + )} + + ))} + + + + )} +
@@ -228,6 +313,10 @@ const Proposals = () => {
+ ) : proposalType === 'lazer' ? ( +
+ Lazer proposals are not supported yet. +
) : ( <>
diff --git a/governance/xc_admin/packages/xc_admin_frontend/contexts/ProgramContext.tsx b/governance/xc_admin/packages/xc_admin_frontend/contexts/ProgramContext.tsx new file mode 100644 index 0000000000..58b986116c --- /dev/null +++ b/governance/xc_admin/packages/xc_admin_frontend/contexts/ProgramContext.tsx @@ -0,0 +1,75 @@ +import { createContext, useContext, useState, ReactNode } from 'react' +import { ProgramType } from '@pythnetwork/xc-admin-common' + +/** + * Interface defining the shape of the Program context + */ +interface ProgramContextType { + /** + * Currently selected program type + */ + programType: ProgramType + + /** + * Function to set the current program type + */ + setProgramType: (type: ProgramType) => void + + /** + * Whether the selected program is supported on the current cluster + */ + isProgramSupported: boolean +} + +/** + * Default context values + */ +const defaultContext: ProgramContextType = { + programType: ProgramType.PYTH_CORE, + setProgramType: () => undefined, + isProgramSupported: true, +} + +/** + * Context for managing the currently selected Pyth program (Core, Lazer, etc.) + */ +const ProgramContext = createContext(defaultContext) + +/** + * Provider component for the Program context + */ +export const ProgramProvider = ({ children }: { children: ReactNode }) => { + // Local state for program type + const [programType, setProgramTypeState] = useState( + ProgramType.PYTH_CORE + ) + + // Local state for program support + const [isProgramSupported] = useState(true) + + /** + * Update program type + */ + const setProgramType = (type: ProgramType) => { + setProgramTypeState(type) + } + + // TODO: Add effect to check if the selected program is supported on the current cluster + // This will be implemented when we have the adapter implementations + + const value = { + programType, + setProgramType, + isProgramSupported, + } + + return ( + {children} + ) +} + +/** + * Hook for accessing the Program context + * @returns The Program context values + */ +export const useProgramContext = () => useContext(ProgramContext) diff --git a/governance/xc_admin/packages/xc_admin_frontend/contexts/PythContext.tsx b/governance/xc_admin/packages/xc_admin_frontend/contexts/PythContext.tsx index 2ab450a8db..4a1014402e 100644 --- a/governance/xc_admin/packages/xc_admin_frontend/contexts/PythContext.tsx +++ b/governance/xc_admin/packages/xc_admin_frontend/contexts/PythContext.tsx @@ -8,6 +8,10 @@ import React, { import { usePyth } from '../hooks/usePyth' import { RawConfig } from '../hooks/usePyth' import { Connection } from '@solana/web3.js' +import { + MappingRawConfig, + ProductRawConfig, +} from '@pythnetwork/xc-admin-common' type AccountKeyToSymbol = { [key: string]: string } interface PythContextProps { @@ -53,8 +57,8 @@ export const PythContextProvider: React.FC = ({ if (!isLoading) { const productAccountMapping: AccountKeyToSymbol = {} const priceAccountMapping: AccountKeyToSymbol = {} - rawConfig.mappingAccounts.map((acc) => - acc.products.map((prod) => { + rawConfig.mappingAccounts.map((acc: MappingRawConfig) => + acc.products.map((prod: ProductRawConfig) => { productAccountMapping[prod.address.toBase58()] = prod.metadata.symbol priceAccountMapping[prod.priceAccounts[0].address.toBase58()] = prod.metadata.symbol diff --git a/governance/xc_admin/packages/xc_admin_frontend/hooks/useMultisig.ts b/governance/xc_admin/packages/xc_admin_frontend/hooks/useMultisig.ts index c5a2144fd6..88e4df241f 100644 --- a/governance/xc_admin/packages/xc_admin_frontend/hooks/useMultisig.ts +++ b/governance/xc_admin/packages/xc_admin_frontend/hooks/useMultisig.ts @@ -133,7 +133,7 @@ export const useMultisig = (): MultisigHookData => { } return { cancel, fetchData } - }, [multisigCluster, urlsIndex, connection]) + }, [readOnlySquads, multisigCluster, urlsIndex]) useEffect(() => { const { cancel, fetchData } = refreshData() diff --git a/governance/xc_admin/packages/xc_admin_frontend/hooks/usePyth.ts b/governance/xc_admin/packages/xc_admin_frontend/hooks/usePyth.ts index 27fccaf158..dd9c2b7655 100644 --- a/governance/xc_admin/packages/xc_admin_frontend/hooks/usePyth.ts +++ b/governance/xc_admin/packages/xc_admin_frontend/hooks/usePyth.ts @@ -1,19 +1,21 @@ import { AccountType, + PythCluster, getPythProgramKeyForCluster, parseBaseData, - parseMappingData, - parsePermissionData, - parsePriceData, - parseProductData, - PermissionData, - Product, } from '@pythnetwork/client' -import { Connection, PublicKey } from '@solana/web3.js' -import assert from 'assert' +import { Connection } from '@solana/web3.js' import { useContext, useEffect, useRef, useState } from 'react' import { ClusterContext } from '../contexts/ClusterContext' import { deriveWsUrl, pythClusterApiUrls } from '../utils/pythClusterApiUrl' +import { + ProgramType, + getConfig, + RawConfig, + MappingRawConfig, + ProductRawConfig, + PriceRawConfig, +} from '@pythnetwork/xc-admin-common' interface PythHookData { isLoading: boolean @@ -21,29 +23,6 @@ interface PythHookData { connection?: Connection } -export type RawConfig = { - mappingAccounts: MappingRawConfig[] - permissionAccount?: PermissionData -} -export type MappingRawConfig = { - address: PublicKey - next: PublicKey | null - products: ProductRawConfig[] -} -export type ProductRawConfig = { - address: PublicKey - priceAccounts: PriceRawConfig[] - metadata: Product -} -export type PriceRawConfig = { - next: PublicKey | null - address: PublicKey - expo: number - minPub: number - maxLatency: number - publishers: PublicKey[] -} - export const usePyth = (): PythHookData => { const connectionRef = useRef(undefined) const { cluster } = useContext(ClusterContext) @@ -72,116 +51,30 @@ export const usePyth = (): PythHookData => { try { const allPythAccounts = [ ...(await connection.getProgramAccounts( - getPythProgramKeyForCluster(cluster) + getPythProgramKeyForCluster(cluster as PythCluster) )), ] if (cancelled) return - const priceRawConfigs: { [key: string]: PriceRawConfig } = {} - - /// First pass, price accounts - let i = 0 - while (i < allPythAccounts.length) { - const base = parseBaseData(allPythAccounts[i].account.data) - switch (base?.type) { - case AccountType.Price: - const parsed = parsePriceData(allPythAccounts[i].account.data) - priceRawConfigs[allPythAccounts[i].pubkey.toBase58()] = { - next: parsed.nextPriceAccountKey, - address: allPythAccounts[i].pubkey, - publishers: parsed.priceComponents.map((x) => { - return x.publisher! - }), - expo: parsed.exponent, - minPub: parsed.minPublishers, - maxLatency: parsed.maxLatency, - } - allPythAccounts[i] = allPythAccounts[allPythAccounts.length - 1] - allPythAccounts.pop() - break - default: - i += 1 - } - } - if (cancelled) return - /// Second pass, product accounts - i = 0 - const productRawConfigs: { [key: string]: ProductRawConfig } = {} - while (i < allPythAccounts.length) { - const base = parseBaseData(allPythAccounts[i].account.data) - switch (base?.type) { - case AccountType.Product: - const parsed = parseProductData(allPythAccounts[i].account.data) - if (parsed.priceAccountKey) { - let priceAccountKey: string | undefined = - parsed.priceAccountKey.toBase58() - const priceAccounts = [] - while (priceAccountKey) { - const toAdd: PriceRawConfig = priceRawConfigs[priceAccountKey] - priceAccounts.push(toAdd) - delete priceRawConfigs[priceAccountKey] - priceAccountKey = toAdd.next - ? toAdd.next.toBase58() - : undefined - } - productRawConfigs[allPythAccounts[i].pubkey.toBase58()] = { - priceAccounts, - metadata: parsed.product, - address: allPythAccounts[i].pubkey, - } - } - allPythAccounts[i] = allPythAccounts[allPythAccounts.length - 1] - allPythAccounts.pop() - break - default: - i += 1 - } - } + // Use the functional approach to parse the accounts + const parsedConfig = getConfig[ProgramType.PYTH_CORE]({ + accounts: allPythAccounts, + cluster: cluster as PythCluster, + }) - const rawConfig: RawConfig = { mappingAccounts: [] } - if (cancelled) return - /// Third pass, mapping accounts - i = 0 - while (i < allPythAccounts.length) { - const base = parseBaseData(allPythAccounts[i].account.data) - switch (base?.type) { - case AccountType.Mapping: - const parsed = parseMappingData(allPythAccounts[i].account.data) - rawConfig.mappingAccounts.push({ - next: parsed.nextMappingAccount, - address: allPythAccounts[i].pubkey, - products: parsed.productAccountKeys - .filter((key) => productRawConfigs[key.toBase58()]) - .map((key) => { - const toAdd = productRawConfigs[key.toBase58()] - delete productRawConfigs[key.toBase58()] - return toAdd - }), - }) - allPythAccounts[i] = allPythAccounts[allPythAccounts.length - 1] - allPythAccounts.pop() - break - case AccountType.Permission: - rawConfig.permissionAccount = parsePermissionData( - allPythAccounts[i].account.data - ) - allPythAccounts[i] = allPythAccounts[allPythAccounts.length - 1] - allPythAccounts.pop() - break - default: - i += 1 - } - } + // Verify all accounts were processed + const remainingAccounts = allPythAccounts.filter((account) => { + const base = parseBaseData(account.account.data) + return base && base.type !== AccountType.Test + }) - assert( - allPythAccounts.every( - (x) => - !parseBaseData(x.account.data) || - parseBaseData(x.account.data)?.type == AccountType.Test + if (remainingAccounts.length > 0) { + console.warn( + `${remainingAccounts.length} accounts were not processed` ) - ) + } - setRawConfig(rawConfig) + setRawConfig(parsedConfig as RawConfig) setIsLoading(false) } catch (e) { if (cancelled) return @@ -205,3 +98,6 @@ export const usePyth = (): PythHookData => { rawConfig, } } + +// Re-export the types for compatibility +export type { RawConfig, MappingRawConfig, ProductRawConfig, PriceRawConfig } diff --git a/governance/xc_admin/packages/xc_admin_frontend/pages/_app.tsx b/governance/xc_admin/packages/xc_admin_frontend/pages/_app.tsx index d0cbfebd27..6951ffde06 100644 --- a/governance/xc_admin/packages/xc_admin_frontend/pages/_app.tsx +++ b/governance/xc_admin/packages/xc_admin_frontend/pages/_app.tsx @@ -20,6 +20,7 @@ import Head from 'next/head' import { useMemo } from 'react' import { Toaster } from 'react-hot-toast' import { ClusterProvider } from '../contexts/ClusterContext' +import { ProgramProvider } from '../contexts/ProgramContext' import SEO from '../next-seo.config' import '../styles/globals.css' import { NuqsAdapter } from 'nuqs/adapters/next/pages' @@ -68,23 +69,25 @@ function MyApp({ Component, pageProps }: AppProps) { - - + + + + + + - - - - +