From 5527782be8597b6bc0522d1ceec7436457884e85 Mon Sep 17 00:00:00 2001 From: Pavel Strakhov Date: Mon, 16 Sep 2024 12:15:59 +0100 Subject: [PATCH] feat(xc-admin): add price store instructions and executor support (#1900) * feat(xc-admin): add price store instructions and executor support * refactor(governance/xc_admin): use switch, verify data and accounts --- .../PriceStoreProgramInstruction.test.ts | 40 +++ .../packages/xc_admin_common/src/executor.ts | 27 ++ .../src/multisig_transaction/index.ts | 22 +- .../xc_admin_common/src/price_store.ts | 277 ++++++++++++++++++ 4 files changed, 364 insertions(+), 2 deletions(-) create mode 100644 governance/xc_admin/packages/xc_admin_common/src/__tests__/PriceStoreProgramInstruction.test.ts create mode 100644 governance/xc_admin/packages/xc_admin_common/src/price_store.ts diff --git a/governance/xc_admin/packages/xc_admin_common/src/__tests__/PriceStoreProgramInstruction.test.ts b/governance/xc_admin/packages/xc_admin_common/src/__tests__/PriceStoreProgramInstruction.test.ts new file mode 100644 index 0000000000..7336ba44d4 --- /dev/null +++ b/governance/xc_admin/packages/xc_admin_common/src/__tests__/PriceStoreProgramInstruction.test.ts @@ -0,0 +1,40 @@ +import { PublicKey } from "@solana/web3.js"; +import { + createPriceStoreInstruction, + parsePriceStoreInstruction, + PriceStoreInstruction, +} from "../price_store"; + +test("Price store instruction parse: roundtrip", (done) => { + const items: PriceStoreInstruction[] = [ + { + type: "Initialize", + data: { + payerKey: new PublicKey("Fe9vtgwRhbMSUsAjwUzupzRoJKofyyk1Rz8ZUrPmGHMr"), + authorityKey: new PublicKey( + "D9rnZSLjdYboFGDGHk5Qre2yBS8HYbc6374Zm6AeC1PB" + ), + }, + }, + { + type: "InitializePublisher", + data: { + authorityKey: new PublicKey( + "D9rnZSLjdYboFGDGHk5Qre2yBS8HYbc6374Zm6AeC1PB" + ), + publisherKey: new PublicKey( + "EXAyN9UVu1x163PQkVzyNm4YunNkMGu5Ry7ntoyyQGTe" + ), + bufferKey: new PublicKey( + "7q6SS575jGDjE8bWsx4PiLVqS7cHJhjJBhysvRoP53WJ" + ), + }, + }, + ]; + for (const data of items) { + const instruction = createPriceStoreInstruction(data); + const parsed = parsePriceStoreInstruction(instruction); + expect(parsed).toStrictEqual(data); + } + done(); +}); diff --git a/governance/xc_admin/packages/xc_admin_common/src/executor.ts b/governance/xc_admin/packages/xc_admin_common/src/executor.ts index bedce6b51d..c33614cd31 100644 --- a/governance/xc_admin/packages/xc_admin_common/src/executor.ts +++ b/governance/xc_admin/packages/xc_admin_common/src/executor.ts @@ -25,6 +25,12 @@ import { TransactionBuilder, PriorityFeeConfig, } from "@pythnetwork/solana-utils"; +import { + findDetermisticPublisherBufferAddress, + PRICE_STORE_BUFFER_SPACE, + PRICE_STORE_PROGRAM_ID, + PriceStoreMultisigInstruction, +} from "./price_store"; /** * Returns the instruction to pay the fee for a wormhole postMessage instruction @@ -134,6 +140,27 @@ export async function executeProposal( } else { throw Error("Product account not found"); } + } else if ( + parsedInstruction instanceof PriceStoreMultisigInstruction && + parsedInstruction.name == "InitializePublisher" + ) { + const [bufferKey, bufferSeed] = + await findDetermisticPublisherBufferAddress( + parsedInstruction.args.publisherKey + ); + transaction.add( + SystemProgram.createAccountWithSeed({ + fromPubkey: squad.wallet.publicKey, + basePubkey: squad.wallet.publicKey, + newAccountPubkey: bufferKey, + seed: bufferSeed, + space: PRICE_STORE_BUFFER_SPACE, + lamports: await squad.connection.getMinimumBalanceForRentExemption( + PRICE_STORE_BUFFER_SPACE + ), + programId: PRICE_STORE_PROGRAM_ID, + }) + ); } TransactionBuilder.addPriorityFee(transaction, priorityFeeConfig); diff --git a/governance/xc_admin/packages/xc_admin_common/src/multisig_transaction/index.ts b/governance/xc_admin/packages/xc_admin_common/src/multisig_transaction/index.ts index abd7a7fd87..51a69938a8 100644 --- a/governance/xc_admin/packages/xc_admin_common/src/multisig_transaction/index.ts +++ b/governance/xc_admin/packages/xc_admin_common/src/multisig_transaction/index.ts @@ -23,6 +23,10 @@ import { BPF_UPGRADABLE_LOADER } from "../bpf_upgradable_loader"; import { AnchorAccounts } from "./anchor"; import { SolanaStakingMultisigInstruction } from "./SolanaStakingMultisigInstruction"; import { DEFAULT_RECEIVER_PROGRAM_ID } from "@pythnetwork/pyth-solana-receiver"; +import { + PRICE_STORE_PROGRAM_ID, + PriceStoreMultisigInstruction, +} from "../price_store"; export const UNRECOGNIZED_INSTRUCTION = "unrecognizedInstruction"; export enum MultisigInstructionProgram { @@ -36,6 +40,7 @@ export enum MultisigInstructionProgram { SolanaStakingProgram, SolanaReceiver, UnrecognizedProgram, + PythPriceStore, } export function getProgramName(program: MultisigInstructionProgram) { @@ -58,6 +63,8 @@ export function getProgramName(program: MultisigInstructionProgram) { return "Pyth Staking Program"; case MultisigInstructionProgram.SolanaReceiver: return "Pyth Solana Receiver"; + case MultisigInstructionProgram.PythPriceStore: + return "Pyth Price Store"; case MultisigInstructionProgram.UnrecognizedProgram: return "Unknown"; } @@ -99,18 +106,22 @@ export class UnrecognizedProgram implements MultisigInstruction { export class MultisigParser { readonly pythOracleAddress: PublicKey; readonly wormholeBridgeAddress: PublicKey | undefined; + readonly pythPriceStoreAddress: PublicKey | undefined; constructor( pythOracleAddress: PublicKey, - wormholeBridgeAddress: PublicKey | undefined + wormholeBridgeAddress: PublicKey | undefined, + pythPriceStoreAddress: PublicKey | undefined ) { this.pythOracleAddress = pythOracleAddress; this.wormholeBridgeAddress = wormholeBridgeAddress; + this.pythPriceStoreAddress = pythPriceStoreAddress; } static fromCluster(cluster: PythCluster): MultisigParser { return new MultisigParser( getPythProgramKeyForCluster(cluster), - WORMHOLE_ADDRESS[cluster] + WORMHOLE_ADDRESS[cluster], + PRICE_STORE_PROGRAM_ID ); } @@ -124,6 +135,13 @@ export class MultisigParser { ); } else if (instruction.programId.equals(this.pythOracleAddress)) { return PythMultisigInstruction.fromTransactionInstruction(instruction); + } else if ( + this.pythPriceStoreAddress && + instruction.programId.equals(this.pythPriceStoreAddress) + ) { + return PriceStoreMultisigInstruction.fromTransactionInstruction( + instruction + ); } else if ( instruction.programId.equals(MESSAGE_BUFFER_PROGRAM_ID) || instruction.programId.equals(MESH_PROGRAM_ID) || diff --git a/governance/xc_admin/packages/xc_admin_common/src/price_store.ts b/governance/xc_admin/packages/xc_admin_common/src/price_store.ts new file mode 100644 index 0000000000..67f7974220 --- /dev/null +++ b/governance/xc_admin/packages/xc_admin_common/src/price_store.ts @@ -0,0 +1,277 @@ +import { + AccountMeta, + MAX_SEED_LENGTH, + PublicKey, + SystemProgram, + TransactionInstruction, +} from "@solana/web3.js"; +import { + MultisigInstruction, + MultisigInstructionProgram, + UNRECOGNIZED_INSTRUCTION, +} from "./multisig_transaction"; +import { AnchorAccounts } from "./multisig_transaction/anchor"; +import { PRICE_FEED_OPS_KEY } from "./multisig"; + +export const PRICE_STORE_PROGRAM_ID: PublicKey = new PublicKey( + "3m6sv6HGqEbuyLV84mD7rJn4MAC9LhUa1y1AUNVqcPfr" +); + +export type PriceStoreInitializeInstruction = { + payerKey: PublicKey; + authorityKey: PublicKey; +}; + +export type PriceStoreInitializePublisherInstruction = { + authorityKey: PublicKey; + publisherKey: PublicKey; + bufferKey: PublicKey; +}; + +// No need to support SubmitPrices instruction. +export type PriceStoreInstruction = + | { + type: "Initialize"; + data: PriceStoreInitializeInstruction; + } + | { + type: "InitializePublisher"; + data: PriceStoreInitializePublisherInstruction; + }; + +enum InstructionId { + Initialize = 0, + SubmitPrices = 1, + InitializePublisher = 2, +} + +export function createPriceStoreInstruction( + data: PriceStoreInstruction +): TransactionInstruction { + switch (data.type) { + case "Initialize": { + const [configKey, configBump] = PublicKey.findProgramAddressSync( + [Buffer.from("CONFIG")], + PRICE_STORE_PROGRAM_ID + ); + const instructionData = Buffer.concat([ + Buffer.from([InstructionId.Initialize, configBump]), + data.data.authorityKey.toBuffer(), + ]); + + return new TransactionInstruction({ + keys: [ + { + pubkey: data.data.payerKey, + isSigner: true, + isWritable: true, + }, + { + pubkey: configKey, + isSigner: false, + isWritable: true, + }, + { + pubkey: SystemProgram.programId, + isSigner: false, + isWritable: false, + }, + ], + programId: PRICE_STORE_PROGRAM_ID, + data: instructionData, + }); + } + case "InitializePublisher": { + const [configKey, configBump] = PublicKey.findProgramAddressSync( + [Buffer.from("CONFIG")], + PRICE_STORE_PROGRAM_ID + ); + const [publisherConfigKey, publisherConfigBump] = + PublicKey.findProgramAddressSync( + [Buffer.from("PUBLISHER_CONFIG"), data.data.publisherKey.toBuffer()], + PRICE_STORE_PROGRAM_ID + ); + const instructionData = Buffer.concat([ + Buffer.from([ + InstructionId.InitializePublisher, + configBump, + publisherConfigBump, + ]), + data.data.publisherKey.toBuffer(), + ]); + return new TransactionInstruction({ + keys: [ + { + pubkey: data.data.authorityKey, + isSigner: true, + isWritable: true, + }, + { + pubkey: configKey, + isSigner: false, + isWritable: false, + }, + { + pubkey: publisherConfigKey, + isSigner: false, + isWritable: true, + }, + { + pubkey: data.data.bufferKey, + isSigner: false, + isWritable: true, + }, + { + pubkey: SystemProgram.programId, + isSigner: false, + isWritable: false, + }, + ], + programId: PRICE_STORE_PROGRAM_ID, + data: instructionData, + }); + } + default: { + // No need to support SubmitPrices instruction. + throw new Error("invalid type"); + } + } +} + +export function parsePriceStoreInstruction( + instruction: TransactionInstruction +): PriceStoreInstruction { + if (instruction.programId != PRICE_STORE_PROGRAM_ID) { + throw new Error("program ID mismatch"); + } + if (instruction.data.length < 1) { + throw new Error("instruction data is too short"); + } + const instructionId = instruction.data.readInt8(0); + let data: PriceStoreInstruction; + switch (instructionId) { + case InstructionId.Initialize: { + if (instruction.data.length < 34) { + throw new Error("instruction data is too short"); + } + const authorityKey = new PublicKey(instruction.data.subarray(2, 34)); + if (instruction.keys.length != 3) { + throw new Error("invalid number of accounts"); + } + data = { + type: "Initialize", + data: { + payerKey: instruction.keys[0].pubkey, + authorityKey, + }, + }; + break; + } + case InstructionId.InitializePublisher: { + if (instruction.data.length < 35) { + throw new Error("instruction data is too short"); + } + const publisherKey = new PublicKey(instruction.data.subarray(3, 35)); + if (instruction.keys.length != 5) { + throw new Error("invalid number of accounts"); + } + data = { + type: "InitializePublisher", + data: { + authorityKey: instruction.keys[0].pubkey, + bufferKey: instruction.keys[3].pubkey, + publisherKey, + }, + }; + break; + } + case InstructionId.SubmitPrices: { + throw new Error("SubmitPrices instruction is not supported"); + } + default: { + throw new Error("unrecognized instruction id"); + } + } + + const expected = createPriceStoreInstruction(data); + + if (!expected.data.equals(instruction.data)) { + const expectedJson = JSON.stringify(expected.data); + const actualJson = JSON.stringify(instruction.data); + throw new Error( + `invalid instruction data: expected ${expectedJson}, got ${actualJson}` + ); + } + + const accountEquals = (a: AccountMeta, b: AccountMeta) => + a.isSigner == b.isSigner && + a.isWritable == b.isWritable && + a.pubkey.equals(b.pubkey); + + const accountMismatch = expected.keys.some( + (ex, index) => !accountEquals(ex, instruction.keys[index]) + ); + if (accountMismatch) { + const expectedJson = JSON.stringify(expected.keys); + const actualJson = JSON.stringify(instruction.keys); + throw new Error( + `invalid accounts: expected ${expectedJson}, got ${actualJson}` + ); + } + return data; +} + +export class PriceStoreMultisigInstruction implements MultisigInstruction { + readonly program = MultisigInstructionProgram.PythPriceStore; + readonly name: string; + readonly args: { [key: string]: any }; + readonly accounts: AnchorAccounts; + + constructor( + name: string, + args: { [key: string]: any }, + accounts: AnchorAccounts + ) { + this.name = name; + this.args = args; + this.accounts = accounts; + } + + static fromTransactionInstruction( + instruction: TransactionInstruction + ): PriceStoreMultisigInstruction { + let result; + try { + result = parsePriceStoreInstruction(instruction); + } catch (e) { + return new PriceStoreMultisigInstruction( + UNRECOGNIZED_INSTRUCTION, + { data: instruction.data, error: (e as Error).toString() }, + { named: {}, remaining: instruction.keys } + ); + } + + return new PriceStoreMultisigInstruction(result.type, result.data, { + named: {}, + remaining: instruction.keys, + }); + } +} + +export async function findDetermisticPublisherBufferAddress( + publisher: PublicKey +): Promise<[PublicKey, string]> { + const seedPrefix = "Buffer"; + const seed = + seedPrefix + + publisher.toBase58().substring(0, MAX_SEED_LENGTH - seedPrefix.length); + const address: PublicKey = await PublicKey.createWithSeed( + PRICE_FEED_OPS_KEY, + seed, + PRICE_STORE_PROGRAM_ID + ); + return [address, seed]; +} + +// Recommended buffer size, enough to hold 5000 prices. +export const PRICE_STORE_BUFFER_SPACE = 100048;