From 956f53e598b84a1eff5cff71f15e08c6d87eeefa Mon Sep 17 00:00:00 2001 From: Tejas Badadare Date: Thu, 19 Dec 2024 11:17:48 -0800 Subject: [PATCH] feat(solana-receiver-js-sdk): verify & post TWAPs (#2186) * feat: update IDL * feat: add postTwapUpdates function * refactor: clean up * feat: add priceFeedIdToTwapUpdateAccount and update docs * doc: update readme * Apply suggestions from code review Co-authored-by: guibescos <59208140+guibescos@users.noreply.github.com> * refactor: address pr comments * refactor: extract shared VAA instruction building into `generateVaaInstructionGroups`, allowing for flexible ix ordering and batching while sharing core logic * refactor: keep buildCloseEncodedVaaInstruction in PythSolanaReceiver for backward compat * fix: update compute budget for postTwapUpdate based on devnet runs * fix: fix comment * fix: imports, compute budget * fix: add reclaimTwapRent to recv contract * fix(cli): increase compute budget to avoid serde issues, add print statements * Apply suggestions from code review Co-authored-by: guibescos <59208140+guibescos@users.noreply.github.com> * doc: update docstring --------- Co-authored-by: guibescos <59208140+guibescos@users.noreply.github.com> --- pnpm-lock.yaml | 4 +- target_chains/solana/cli/src/main.rs | 11 +- .../programs/pyth-solana-receiver/src/lib.rs | 11 + .../sdk/js/pyth_solana_receiver/README.md | 44 +++ .../examples/post_twap_update.ts | 94 +++++ .../sdk/js/pyth_solana_receiver/package.json | 4 +- .../src/PythSolanaReceiver.ts | 306 +++++++++++----- .../src/compute_budget.ts | 4 + .../src/idl/pyth_solana_receiver.ts | 336 +++++++++++++++++- .../sdk/js/pyth_solana_receiver/src/vaa.ts | 297 +++++++++++++--- 10 files changed, 965 insertions(+), 146 deletions(-) create mode 100644 target_chains/solana/sdk/js/pyth_solana_receiver/examples/post_twap_update.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da396a3a08..89ad87f122 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2458,8 +2458,8 @@ importers: specifier: ^1.4.0 version: 1.4.0 '@pythnetwork/price-service-sdk': - specifier: '>=1.6.0' - version: 1.7.1 + specifier: workspace:* + version: link:../../../../../price_service/sdk/js '@pythnetwork/solana-utils': specifier: workspace:* version: link:../solana_utils diff --git a/target_chains/solana/cli/src/main.rs b/target_chains/solana/cli/src/main.rs index 2d458a1ab8..bf5b7568fb 100644 --- a/target_chains/solana/cli/src/main.rs +++ b/target_chains/solana/cli/src/main.rs @@ -431,7 +431,10 @@ pub fn process_write_encoded_vaa_and_post_price_update( update_instructions, &vec![payer, &price_update_keypair], )?; - + println!( + "Price update posted to account: {}", + price_update_keypair.pubkey() + ); Ok(price_update_keypair.pubkey()) } @@ -483,7 +486,7 @@ pub fn process_write_encoded_vaa_and_post_twap_update( )?; // Transaction 3: Write remaining VAA data and verify both VAAs - let mut verify_instructions = vec![ComputeBudgetInstruction::set_compute_unit_limit(400_000)]; + let mut verify_instructions = vec![ComputeBudgetInstruction::set_compute_unit_limit(850_000)]; verify_instructions.extend(write_remaining_data_and_verify_vaa_ixs( &payer.pubkey(), start_vaa, @@ -518,6 +521,10 @@ pub fn process_write_encoded_vaa_and_post_twap_update( post_instructions, &vec![payer, &twap_update_keypair], )?; + println!( + "TWAP update posted to account: {}", + twap_update_keypair.pubkey() + ); Ok(twap_update_keypair.pubkey()) } diff --git a/target_chains/solana/programs/pyth-solana-receiver/src/lib.rs b/target_chains/solana/programs/pyth-solana-receiver/src/lib.rs index d242f8bde9..4377f230f2 100644 --- a/target_chains/solana/programs/pyth-solana-receiver/src/lib.rs +++ b/target_chains/solana/programs/pyth-solana-receiver/src/lib.rs @@ -283,6 +283,9 @@ pub mod pyth_solana_receiver { pub fn reclaim_rent(_ctx: Context) -> Result<()> { Ok(()) } + pub fn reclaim_twap_rent(_ctx: Context) -> Result<()> { + Ok(()) + } } #[derive(Accounts)] @@ -393,6 +396,14 @@ pub struct ReclaimRent<'info> { pub price_update_account: Account<'info, PriceUpdateV2>, } +#[derive(Accounts)] +pub struct ReclaimTwapRent<'info> { + #[account(mut)] + pub payer: Signer<'info>, + #[account(mut, close = payer, constraint = twap_update_account.write_authority == payer.key() @ ReceiverError::WrongWriteAuthority)] + pub twap_update_account: Account<'info, TwapUpdate>, +} + fn deserialize_guardian_set_checked( account_info: &AccountInfo<'_>, wormhole: &Pubkey, diff --git a/target_chains/solana/sdk/js/pyth_solana_receiver/README.md b/target_chains/solana/sdk/js/pyth_solana_receiver/README.md index ff05ca7733..88b987668c 100644 --- a/target_chains/solana/sdk/js/pyth_solana_receiver/README.md +++ b/target_chains/solana/sdk/js/pyth_solana_receiver/README.md @@ -175,6 +175,50 @@ Price updates are relatively large and can take multiple transactions to post on You can reduce the size of the transaction payload by using `addPostPartiallyVerifiedPriceUpdates` instead of `addPostPriceUpdates`. This method does sacrifice some security however -- please see the method documentation for more details. +### Post a TWAP price update + +TWAP price updates are calculated using a pair of verifiable cumulative price updates per price feed (the "start" and "end" updates for the given time window), and then performing an averaging calculation on-chain to create the time-weighted average price. + +The flow of using, verifying, posting, and consuming these prices is the same as standard price updates. Get the binary update data from Hermes or Benchmarks, post and verify the VAAs via the Wormhole contract, and verify the updates against the VAAs via Pyth receiver contract. After this, you can consume the calculated TWAP posted to the TwapUpdate account. You can also optionally close these ephemeral accounts after the TWAP has been consumed to save on rent. + +```typescript +// Fetch the binary TWAP data from hermes or benchmarks. See Preliminaries section above for more info. +const binaryDataArray = ["UE5BV...khz609", "UE5BV...BAg8i6"]; + +// Pass `closeUpdateAccounts: true` to automatically close the TWAP update accounts +// after they're consumed +const transactionBuilder = pythSolanaReceiver.newTransactionBuilder({ + closeUpdateAccounts: false, +}); + +// Post the updates and calculate the TWAP +await transactionBuilder.addPostTwapUpdates(binaryDataArray); + +// You can now use the TWAP prices in subsequent instructions +await transactionBuilder.addTwapConsumerInstructions( + async ( + getTwapUpdateAccount: (priceFeedId: string) => PublicKey + ): Promise => { + // Generate instructions here that use the TWAP updates posted above. + // getTwapUpdateAccount() will give you the account for each TWAP update. + return []; + } +); + +// Send the instructions in the builder in 1 or more transactions. +// The builder will pack the instructions into transactions automatically. +sendTransactions( + await transactionBuilder.buildVersionedTransactions({ + computeUnitPriceMicroLamports: 100000, + tightComputeBudget: true, + }), + pythSolanaReceiver.connection, + pythSolanaReceiver.wallet +); +``` + +See `examples/post_twap_update.ts` for a runnable example of posting a TWAP price update. + ### Get Instructions The `PythTransactionBuilder` class used in the examples above helps craft transactions that update prices and then use them in successive instructions. diff --git a/target_chains/solana/sdk/js/pyth_solana_receiver/examples/post_twap_update.ts b/target_chains/solana/sdk/js/pyth_solana_receiver/examples/post_twap_update.ts new file mode 100644 index 0000000000..afe704ad1f --- /dev/null +++ b/target_chains/solana/sdk/js/pyth_solana_receiver/examples/post_twap_update.ts @@ -0,0 +1,94 @@ +import { Connection, Keypair, PublicKey } from "@solana/web3.js"; +import { InstructionWithEphemeralSigners, PythSolanaReceiver } from "../"; +import { Wallet } from "@coral-xyz/anchor"; +import fs from "fs"; +import os from "os"; +import { HermesClient } from "@pythnetwork/hermes-client"; +import { sendTransactions } from "@pythnetwork/solana-utils"; + +// Get price feed ids from https://pyth.network/developers/price-feed-ids#pyth-evm-stable +const SOL_PRICE_FEED_ID = + "0xef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d"; +const ETH_PRICE_FEED_ID = + "0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace"; + +let keypairFile = ""; +if (process.env["SOLANA_KEYPAIR"]) { + keypairFile = process.env["SOLANA_KEYPAIR"]; +} else { + keypairFile = `${os.homedir()}/.config/solana/id.json`; +} + +async function main() { + const connection = new Connection("https://api.devnet.solana.com"); + const keypair = await loadKeypairFromFile(keypairFile); + console.log( + `Sending transactions from account: ${keypair.publicKey.toBase58()}` + ); + const wallet = new Wallet(keypair); + const pythSolanaReceiver = new PythSolanaReceiver({ connection, wallet }); + + // Get the TWAP update from hermes + const twapUpdateData = await getTwapUpdateData(); + console.log(`Posting TWAP update: ${twapUpdateData}`); + + // Similar to price updates, we'll keep closeUpdateAccounts = false for easy exploration + const transactionBuilder = pythSolanaReceiver.newTransactionBuilder({ + closeUpdateAccounts: false, + }); + + // Post the TWAP updates to ephemeral accounts, one per price feed + await transactionBuilder.addPostTwapUpdates(twapUpdateData); + console.log( + "The SOL/USD TWAP update will get posted to:", + transactionBuilder.getTwapUpdateAccount(SOL_PRICE_FEED_ID).toBase58() + ); + + await transactionBuilder.addTwapConsumerInstructions( + async ( + getTwapUpdateAccount: (priceFeedId: string) => PublicKey + ): Promise => { + // You can generate instructions here that use the TWAP updates posted above. + // getTwapUpdateAccount() will give you the account you need. + return []; + } + ); + + // Send the instructions in the builder in 1 or more transactions + sendTransactions( + await transactionBuilder.buildVersionedTransactions({ + computeUnitPriceMicroLamports: 100000, + tightComputeBudget: true, + }), + pythSolanaReceiver.connection, + pythSolanaReceiver.wallet + ); +} + +// Fetch TWAP update data from Hermes +async function getTwapUpdateData() { + const hermesConnection = new HermesClient("https://hermes.pyth.network/", {}); + + // Request TWAP updates for the last hour (3600 seconds) + const response = await hermesConnection.getLatestTwapUpdates( + [SOL_PRICE_FEED_ID, ETH_PRICE_FEED_ID], + 3600, + { encoding: "base64" } + ); + + return response.binary.data; +} + +// Load a solana keypair from an id.json file +async function loadKeypairFromFile(filePath: string): Promise { + try { + const keypairData = JSON.parse( + await fs.promises.readFile(filePath, "utf8") + ); + return Keypair.fromSecretKey(Uint8Array.from(keypairData)); + } catch (error) { + throw new Error(`Error loading keypair from file: ${error}`); + } +} + +main(); diff --git a/target_chains/solana/sdk/js/pyth_solana_receiver/package.json b/target_chains/solana/sdk/js/pyth_solana_receiver/package.json index 12e2e54bb9..cb510cf492 100644 --- a/target_chains/solana/sdk/js/pyth_solana_receiver/package.json +++ b/target_chains/solana/sdk/js/pyth_solana_receiver/package.json @@ -1,6 +1,6 @@ { "name": "@pythnetwork/pyth-solana-receiver", - "version": "0.8.2", + "version": "0.9.0", "description": "Pyth solana receiver SDK", "homepage": "https://pyth.network", "main": "lib/index.js", @@ -45,7 +45,7 @@ "dependencies": { "@coral-xyz/anchor": "^0.29.0", "@noble/hashes": "^1.4.0", - "@pythnetwork/price-service-sdk": ">=1.6.0", + "@pythnetwork/price-service-sdk": "workspace:*", "@pythnetwork/solana-utils": "workspace:*", "@solana/web3.js": "^1.90.0" } diff --git a/target_chains/solana/sdk/js/pyth_solana_receiver/src/PythSolanaReceiver.ts b/target_chains/solana/sdk/js/pyth_solana_receiver/src/PythSolanaReceiver.ts index 929f719cc0..c6f4bd88e2 100644 --- a/target_chains/solana/sdk/js/pyth_solana_receiver/src/PythSolanaReceiver.ts +++ b/target_chains/solana/sdk/js/pyth_solana_receiver/src/PythSolanaReceiver.ts @@ -27,19 +27,19 @@ import { PublicKey, Keypair } from "@solana/web3.js"; import { parseAccumulatorUpdateData, parsePriceFeedMessage, + parseTwapMessage, } from "@pythnetwork/price-service-sdk"; import { - CLOSE_ENCODED_VAA_COMPUTE_BUDGET, - INIT_ENCODED_VAA_COMPUTE_BUDGET, + POST_TWAP_UPDATE_COMPUTE_BUDGET, POST_UPDATE_ATOMIC_COMPUTE_BUDGET, POST_UPDATE_COMPUTE_BUDGET, UPDATE_PRICE_FEED_COMPUTE_BUDGET, - VERIFY_ENCODED_VAA_COMPUTE_BUDGET, } from "./compute_budget"; import { Wallet } from "@coral-xyz/anchor"; import { - buildEncodedVaaCreateInstruction, - buildWriteEncodedVaaWithSplitInstructions, + buildCloseEncodedVaaInstruction, + buildPostEncodedVaaInstructions, + buildPostEncodedVaasForTwapInstructions, findEncodedVaaAccountsByWriteAuthority, getGuardianSetIndex, trimSignatures, @@ -56,6 +56,8 @@ import { export type PriceUpdateAccount = IdlAccounts["priceUpdateV2"]; +export type TwapUpdateAccount = + IdlAccounts["twapUpdate"]; /** * Configuration for the PythTransactionBuilder * @property closeUpdateAccounts (default: true) if true, the builder will add instructions to close the price update accounts and the encoded vaa accounts to recover the rent @@ -98,6 +100,7 @@ export class PythTransactionBuilder extends TransactionBuilder { readonly pythSolanaReceiver: PythSolanaReceiver; readonly closeInstructions: InstructionWithEphemeralSigners[]; readonly priceFeedIdToPriceUpdateAccount: Record; + readonly priceFeedIdToTwapUpdateAccount: Record; readonly closeUpdateAccounts: boolean; constructor( @@ -113,6 +116,7 @@ export class PythTransactionBuilder extends TransactionBuilder { this.pythSolanaReceiver = pythSolanaReceiver; this.closeInstructions = []; this.priceFeedIdToPriceUpdateAccount = {}; + this.priceFeedIdToTwapUpdateAccount = {}; this.closeUpdateAccounts = config.closeUpdateAccounts ?? true; } @@ -192,6 +196,42 @@ export class PythTransactionBuilder extends TransactionBuilder { this.addInstructions(postInstructions); } + /** + * Add instructions to post TWAP updates to the builder. + * Use this function to post fully verified TWAP updates from the present or from the past for your program to consume. + * + * @param twapUpdateDataArray the output of the `@pythnetwork/hermes-client`'s `getLatestTwaps`. This is an array of verifiable price updates. + * + * @example + * ```typescript + * // Get the price feed ids from https://pyth.network/developers/price-feed-ids#pyth-evm-stable + * const twapUpdateData = await hermesClient.getLatestTwaps([ + * SOL_PRICE_FEED_ID, + * ETH_PRICE_FEED_ID, + * ]); + * + * const transactionBuilder = pythSolanaReceiver.newTransactionBuilder({}); + * await transactionBuilder.addPostTwapUpdates(priceUpdateData); + * console.log("The SOL/USD price update will get posted to:", transactionBuilder.getTwapUpdateAccount(SOL_PRICE_FEED_ID).toBase58()) + * await transactionBuilder.addTwapConsumerInstructions(...) + * ``` + */ + async addPostTwapUpdates(twapUpdateDataArray: string[]) { + const { + postInstructions, + priceFeedIdToTwapUpdateAccount, + closeInstructions, + } = await this.pythSolanaReceiver.buildPostTwapUpdateInstructions( + twapUpdateDataArray + ); + this.closeInstructions.push(...closeInstructions); + Object.assign( + this.priceFeedIdToTwapUpdateAccount, + priceFeedIdToTwapUpdateAccount + ); + this.addInstructions(postInstructions); + } + /** * Add instructions to update price feed accounts to the builder. * Price feed accounts are fixed accounts per price feed id that can only be updated with a more recent price. @@ -270,6 +310,46 @@ export class PythTransactionBuilder extends TransactionBuilder { ); } + /** + * Add instructions that consume TWAP updates to the builder. + * + * @param getInstructions a function that given a mapping of price feed IDs to TWAP update accounts, generates a series of instructions. TWAP updates get posted to ephemeral accounts and this function allows the user to indicate which accounts in their instruction need to be "replaced" with each price update account. + * If multiple TWAP updates for the same price feed ID are posted with the same builder, the account corresponding to the last update to get posted will be used. + * + * @example + * ```typescript + * ... + * await transactionBuilder.addPostTwapUpdates(twapUpdateData); + * await transactionBuilder.addTwapConsumerInstructions( + * async ( + * getTwapUpdateAccount: ( priceFeedId: string) => PublicKey + * ): Promise => { + * return [ + * { + * instruction: await myFirstPythApp.methods + * .consume() + * .accounts({ + * solTwapUpdate: getTwapUpdateAccount(SOL_PRICE_FEED_ID), + * ethTwapUpdate: getTwapUpdateAccount(ETH_PRICE_FEED_ID), + * }) + * .instruction(), + * signers: [], + * }, + * ]; + * } + * ); + * ``` + */ + async addTwapConsumerInstructions( + getInstructions: ( + getTwapUpdateAccount: (priceFeedId: string) => PublicKey + ) => Promise + ) { + this.addInstructions( + await getInstructions(this.getTwapUpdateAccount.bind(this)) + ); + } + /** Add instructions to close encoded VAA accounts from previous actions. * If you have previously used the PythTransactionBuilder with closeUpdateAccounts set to false or if you posted encoded VAAs but the transaction to close them did not land on-chain, your wallet might own many encoded VAA accounts. * The rent cost for these accounts is 0.008 SOL per encoded VAA account. You can recover this rent calling this function when building a set of transactions. @@ -320,6 +400,20 @@ export class PythTransactionBuilder extends TransactionBuilder { } return priceUpdateAccount; } + + /** + * This method is used to retrieve the address of the TWAP update account where the TWAP update for a given price feed ID will be posted. + * If multiple updates for the same price feed ID will be posted with the same builder, the address of the account corresponding to the last update to get posted will be returned. + * */ + getTwapUpdateAccount(priceFeedId: string): PublicKey { + const twapUpdateAccount = this.priceFeedIdToTwapUpdateAccount[priceFeedId]; + if (!twapUpdateAccount) { + throw new Error( + `No TWAP update account found for the price feed ID ${priceFeedId}. Make sure to call addPostTwapUpdates before calling this function.` + ); + } + return twapUpdateAccount; + } } /** @@ -452,77 +546,6 @@ export class PythSolanaReceiver { }; } - /** - * Build a series of helper instructions that post a VAA in an encoded VAA account. This function is bespoke for posting Pyth VAAs and might not work for other usecases. - * - * @param vaa a Wormhole VAA - * @returns `postInstructions`: the instructions to post the VAA - * @returns `encodedVaaAddress`: the address of the encoded VAA account where the VAA will be posted - * @returns `closeInstructions`: the instructions to close the encoded VAA account - */ - async buildPostEncodedVaaInstructions(vaa: Buffer): Promise<{ - postInstructions: InstructionWithEphemeralSigners[]; - encodedVaaAddress: PublicKey; - closeInstructions: InstructionWithEphemeralSigners[]; - }> { - const trimmedVaa = trimSignatures(vaa, 13); - const postInstructions: InstructionWithEphemeralSigners[] = []; - const closeInstructions: InstructionWithEphemeralSigners[] = []; - const encodedVaaKeypair = new Keypair(); - const guardianSetIndex = getGuardianSetIndex(trimmedVaa); - - postInstructions.push( - await buildEncodedVaaCreateInstruction( - this.wormhole, - trimmedVaa, - encodedVaaKeypair - ) - ); - postInstructions.push({ - instruction: await this.wormhole.methods - .initEncodedVaa() - .accounts({ - encodedVaa: encodedVaaKeypair.publicKey, - }) - .instruction(), - signers: [], - computeUnits: INIT_ENCODED_VAA_COMPUTE_BUDGET, - }); - - postInstructions.push( - ...(await buildWriteEncodedVaaWithSplitInstructions( - this.wormhole, - trimmedVaa, - encodedVaaKeypair.publicKey - )) - ); - - postInstructions.push({ - instruction: await this.wormhole.methods - .verifyEncodedVaaV1() - .accounts({ - guardianSet: getGuardianSetPda( - guardianSetIndex, - this.wormhole.programId - ), - draftVaa: encodedVaaKeypair.publicKey, - }) - .instruction(), - signers: [], - computeUnits: VERIFY_ENCODED_VAA_COMPUTE_BUDGET, - }); - - closeInstructions.push( - await this.buildCloseEncodedVaaInstruction(encodedVaaKeypair.publicKey) - ); - - return { - postInstructions, - encodedVaaAddress: encodedVaaKeypair.publicKey, - closeInstructions, - }; - } - /** * Build a series of helper instructions that post price updates to the Pyth Solana Receiver program and another series to close the encoded vaa accounts and the price update accounts. * @@ -594,6 +617,98 @@ export class PythSolanaReceiver { }; } + /** + * Build a series of helper instructions that post TWAP updates to the Pyth Solana Receiver program and another series to close the encoded vaa accounts and the TWAP update accounts. + * + * @param twapUpdateDataArray the output of the `@pythnetwork/price-service-client`'s `PriceServiceConnection.getLatestTwaps`. This is an array of verifiable price updates. + * @returns `postInstructions`: the instructions to post the TWAP updates, these should be called before consuming the price updates + * @returns `priceFeedIdToTwapUpdateAccount`: this is a map of price feed IDs to Solana address. Given a price feed ID, you can use this map to find the account where `postInstructions` will post the TWAP update. + * @returns `closeInstructions`: the instructions to close the TWAP update accounts, these should be called after consuming the TWAP updates + */ + async buildPostTwapUpdateInstructions( + twapUpdateDataArray: string[] + ): Promise<{ + postInstructions: InstructionWithEphemeralSigners[]; + priceFeedIdToTwapUpdateAccount: Record; + closeInstructions: InstructionWithEphemeralSigners[]; + }> { + const postInstructions: InstructionWithEphemeralSigners[] = []; + const priceFeedIdToTwapUpdateAccount: Record = {}; + const closeInstructions: InstructionWithEphemeralSigners[] = []; + + const treasuryId = getRandomTreasuryId(); + + if (twapUpdateDataArray.length !== 2) { + throw new Error( + "twapUpdateDataArray must contain exactly two updates (start and end)" + ); + } + + const [startUpdateData, endUpdateData] = twapUpdateDataArray.map((data) => + parseAccumulatorUpdateData(Buffer.from(data, "base64")) + ); + + // Validate that the start and end updates contain the same number of price feeds + if (startUpdateData.updates.length !== endUpdateData.updates.length) { + throw new Error( + "Start and end updates must contain the same number of price feeds" + ); + } + + // Post encoded VAAs + const { + postInstructions: buildVaasInstructions, + closeInstructions: closeVaasInstructions, + startEncodedVaaAddress, + endEncodedVaaAddress, + } = await buildPostEncodedVaasForTwapInstructions( + this.wormhole, + startUpdateData, + endUpdateData + ); + postInstructions.push(...buildVaasInstructions); + closeInstructions.push(...closeVaasInstructions); + + // Post a TWAP update to the receiver contract for each price feed + for (let i = 0; i < startUpdateData.updates.length; i++) { + const startUpdate = startUpdateData.updates[i]; + const endUpdate = endUpdateData.updates[i]; + + const twapUpdateKeypair = new Keypair(); + postInstructions.push({ + instruction: await this.receiver.methods + .postTwapUpdate({ + startMerklePriceUpdate: startUpdate, + endMerklePriceUpdate: endUpdate, + treasuryId, + }) + .accounts({ + startEncodedVaa: startEncodedVaaAddress, + endEncodedVaa: endEncodedVaaAddress, + twapUpdateAccount: twapUpdateKeypair.publicKey, + treasury: getTreasuryPda(treasuryId, this.receiver.programId), + config: getConfigPda(this.receiver.programId), + }) + .instruction(), + signers: [twapUpdateKeypair], + computeUnits: POST_TWAP_UPDATE_COMPUTE_BUDGET, + }); + + priceFeedIdToTwapUpdateAccount[ + "0x" + parseTwapMessage(startUpdate.message).feedId.toString("hex") + ] = twapUpdateKeypair.publicKey; + closeInstructions.push( + await this.buildCloseTwapUpdateInstruction(twapUpdateKeypair.publicKey) + ); + } + + return { + postInstructions, + priceFeedIdToTwapUpdateAccount, + closeInstructions, + }; + } + /** * Build a series of helper instructions that update one or many price feed accounts and another series to close the encoded vaa accounts used to update the price feed accounts. * @@ -669,21 +784,29 @@ export class PythSolanaReceiver { }; } + /** + * Build a series of helper instructions that post a VAA in an encoded VAA account. This function is bespoke for posting Pyth VAAs and might not work for other usecases. + * + * @param vaa a Wormhole VAA + * @returns `encodedVaaAddress`: the address of the encoded VAA account where the VAA will be posted + * @returns `postInstructions`: the instructions to post the VAA + * @returns `closeInstructions`: the instructions to close the encoded VAA account + */ + async buildPostEncodedVaaInstructions(vaa: Buffer): Promise<{ + encodedVaaAddress: PublicKey; + postInstructions: InstructionWithEphemeralSigners[]; + closeInstructions: InstructionWithEphemeralSigners[]; + }> { + return buildPostEncodedVaaInstructions(this.wormhole, vaa); + } + /** * Build an instruction to close an encoded VAA account, recovering the rent. */ async buildCloseEncodedVaaInstruction( encodedVaa: PublicKey ): Promise { - const instruction = await this.wormhole.methods - .closeEncodedVaa() - .accounts({ encodedVaa }) - .instruction(); - return { - instruction, - signers: [], - computeUnits: CLOSE_ENCODED_VAA_COMPUTE_BUDGET, - }; + return buildCloseEncodedVaaInstruction(this.wormhole, encodedVaa); } /** @@ -713,6 +836,19 @@ export class PythSolanaReceiver { return { instruction, signers: [] }; } + /** + * Build an instruction to close a TWAP update account, recovering the rent. + */ + async buildCloseTwapUpdateInstruction( + twapUpdateAccount: PublicKey + ): Promise { + const instruction = await this.receiver.methods + .reclaimTwapRent() + .accounts({ twapUpdateAccount }) + .instruction(); + return { instruction, signers: [] }; + } + /** * Returns a set of versioned transactions that contain the provided instructions in the same order and with efficient batching */ diff --git a/target_chains/solana/sdk/js/pyth_solana_receiver/src/compute_budget.ts b/target_chains/solana/sdk/js/pyth_solana_receiver/src/compute_budget.ts index ff9398c13c..8c310a6718 100644 --- a/target_chains/solana/sdk/js/pyth_solana_receiver/src/compute_budget.ts +++ b/target_chains/solana/sdk/js/pyth_solana_receiver/src/compute_budget.ts @@ -10,6 +10,10 @@ export const POST_UPDATE_ATOMIC_COMPUTE_BUDGET = 170000; * A hard-coded budget for the compute units required for the `postUpdate` instruction in the Pyth Solana Receiver program. */ export const POST_UPDATE_COMPUTE_BUDGET = 35000; +/** + * A hard-coded budget for the compute units required for the `postTwapUpdate` instruction in the Pyth Solana Receiver program. + */ +export const POST_TWAP_UPDATE_COMPUTE_BUDGET = 50_000; /** * A hard-coded budget for the compute units required for the `updatePriceFeed` instruction in the Pyth Push Oracle program. */ diff --git a/target_chains/solana/sdk/js/pyth_solana_receiver/src/idl/pyth_solana_receiver.ts b/target_chains/solana/sdk/js/pyth_solana_receiver/src/idl/pyth_solana_receiver.ts index 4e92d15f1c..5d8e0de72a 100644 --- a/target_chains/solana/sdk/js/pyth_solana_receiver/src/idl/pyth_solana_receiver.ts +++ b/target_chains/solana/sdk/js/pyth_solana_receiver/src/idl/pyth_solana_receiver.ts @@ -1,5 +1,5 @@ export type PythSolanaReceiver = { - version: "0.1.0"; + version: "0.2.0"; name: "pyth_solana_receiver"; instructions: [ { @@ -273,6 +273,68 @@ export type PythSolanaReceiver = { } ]; }, + { + name: "postTwapUpdate"; + docs: [ + "Post a TWAP (time weighted average price) update for a given time window.", + "This should be called after the client has already verified the VAAs via the Wormhole contract.", + "Check out target_chains/solana/cli/src/main.rs for an example of how to do this." + ]; + accounts: [ + { + name: "payer"; + isMut: true; + isSigner: true; + }, + { + name: "startEncodedVaa"; + isMut: false; + isSigner: false; + }, + { + name: "endEncodedVaa"; + isMut: false; + isSigner: false; + }, + { + name: "config"; + isMut: false; + isSigner: false; + }, + { + name: "treasury"; + isMut: true; + isSigner: false; + }, + { + name: "twapUpdateAccount"; + isMut: true; + isSigner: true; + docs: [ + "The contraint is such that either the twap_update_account is uninitialized or the write_authority is the write_authority.", + "Pubkey::default() is the SystemProgram on Solana and it can't sign so it's impossible that twap_update_account.write_authority == Pubkey::default() once the account is initialized" + ]; + }, + { + name: "systemProgram"; + isMut: false; + isSigner: false; + }, + { + name: "writeAuthority"; + isMut: false; + isSigner: true; + } + ]; + args: [ + { + name: "params"; + type: { + defined: "PostTwapUpdateParams"; + }; + } + ]; + }, { name: "reclaimRent"; accounts: [ @@ -288,6 +350,22 @@ export type PythSolanaReceiver = { } ]; args: []; + }, + { + name: "reclaimTwapRent"; + accounts: [ + { + name: "payer"; + isMut: true; + isSigner: true; + }, + { + name: "twapUpdateAccount"; + isMut: true; + isSigner: false; + } + ]; + args: []; } ]; accounts: [ @@ -356,6 +434,24 @@ export type PythSolanaReceiver = { } ]; }; + }, + { + name: "twapUpdate"; + type: { + kind: "struct"; + fields: [ + { + name: "writeAuthority"; + type: "publicKey"; + }, + { + name: "twap"; + type: { + defined: "TwapPrice"; + }; + } + ]; + }; } ]; types: [ @@ -401,6 +497,52 @@ export type PythSolanaReceiver = { ]; }; }, + { + name: "TwapPrice"; + docs: [ + "The time weighted average price & conf for a feed over the window [start_time, end_time].", + "This type is used to persist the calculated TWAP in TwapUpdate accounts on Solana." + ]; + type: { + kind: "struct"; + fields: [ + { + name: "feedId"; + type: { + array: ["u8", 32]; + }; + }, + { + name: "startTime"; + type: "i64"; + }, + { + name: "endTime"; + type: "i64"; + }, + { + name: "price"; + type: "i64"; + }, + { + name: "conf"; + type: "u64"; + }, + { + name: "exponent"; + type: "i32"; + }, + { + name: "downSlotsRatio"; + docs: [ + "Ratio out of 1_000_000, where a value of 1_000_000 represents", + "all slots were missed and 0 represents no slots were missed." + ]; + type: "u32"; + } + ]; + }; + }, { name: "MerklePriceUpdate"; type: { @@ -477,6 +619,30 @@ export type PythSolanaReceiver = { ]; }; }, + { + name: "PostTwapUpdateParams"; + type: { + kind: "struct"; + fields: [ + { + name: "startMerklePriceUpdate"; + type: { + defined: "MerklePriceUpdate"; + }; + }, + { + name: "endMerklePriceUpdate"; + type: { + defined: "MerklePriceUpdate"; + }; + }, + { + name: "treasuryId"; + type: "u8"; + } + ]; + }; + }, { name: "VerificationLevel"; docs: [ @@ -616,7 +782,7 @@ export type PythSolanaReceiver = { }; export const IDL: PythSolanaReceiver = { - version: "0.1.0", + version: "0.2.0", name: "pyth_solana_receiver", instructions: [ { @@ -890,6 +1056,68 @@ export const IDL: PythSolanaReceiver = { }, ], }, + { + name: "postTwapUpdate", + docs: [ + "Post a TWAP (time weighted average price) update for a given time window.", + "This should be called after the client has already verified the VAAs via the Wormhole contract.", + "Check out target_chains/solana/cli/src/main.rs for an example of how to do this.", + ], + accounts: [ + { + name: "payer", + isMut: true, + isSigner: true, + }, + { + name: "startEncodedVaa", + isMut: false, + isSigner: false, + }, + { + name: "endEncodedVaa", + isMut: false, + isSigner: false, + }, + { + name: "config", + isMut: false, + isSigner: false, + }, + { + name: "treasury", + isMut: true, + isSigner: false, + }, + { + name: "twapUpdateAccount", + isMut: true, + isSigner: true, + docs: [ + "The contraint is such that either the twap_update_account is uninitialized or the write_authority is the write_authority.", + "Pubkey::default() is the SystemProgram on Solana and it can't sign so it's impossible that twap_update_account.write_authority == Pubkey::default() once the account is initialized", + ], + }, + { + name: "systemProgram", + isMut: false, + isSigner: false, + }, + { + name: "writeAuthority", + isMut: false, + isSigner: true, + }, + ], + args: [ + { + name: "params", + type: { + defined: "PostTwapUpdateParams", + }, + }, + ], + }, { name: "reclaimRent", accounts: [ @@ -906,6 +1134,22 @@ export const IDL: PythSolanaReceiver = { ], args: [], }, + { + name: "reclaimTwapRent", + accounts: [ + { + name: "payer", + isMut: true, + isSigner: true, + }, + { + name: "twapUpdateAccount", + isMut: true, + isSigner: false, + }, + ], + args: [], + }, ], accounts: [ { @@ -974,6 +1218,24 @@ export const IDL: PythSolanaReceiver = { ], }, }, + { + name: "twapUpdate", + type: { + kind: "struct", + fields: [ + { + name: "writeAuthority", + type: "publicKey", + }, + { + name: "twap", + type: { + defined: "TwapPrice", + }, + }, + ], + }, + }, ], types: [ { @@ -1018,6 +1280,52 @@ export const IDL: PythSolanaReceiver = { ], }, }, + { + name: "TwapPrice", + docs: [ + "The time weighted average price & conf for a feed over the window [start_time, end_time].", + "This type is used to persist the calculated TWAP in TwapUpdate accounts on Solana.", + ], + type: { + kind: "struct", + fields: [ + { + name: "feedId", + type: { + array: ["u8", 32], + }, + }, + { + name: "startTime", + type: "i64", + }, + { + name: "endTime", + type: "i64", + }, + { + name: "price", + type: "i64", + }, + { + name: "conf", + type: "u64", + }, + { + name: "exponent", + type: "i32", + }, + { + name: "downSlotsRatio", + docs: [ + "Ratio out of 1_000_000, where a value of 1_000_000 represents", + "all slots were missed and 0 represents no slots were missed.", + ], + type: "u32", + }, + ], + }, + }, { name: "MerklePriceUpdate", type: { @@ -1094,6 +1402,30 @@ export const IDL: PythSolanaReceiver = { ], }, }, + { + name: "PostTwapUpdateParams", + type: { + kind: "struct", + fields: [ + { + name: "startMerklePriceUpdate", + type: { + defined: "MerklePriceUpdate", + }, + }, + { + name: "endMerklePriceUpdate", + type: { + defined: "MerklePriceUpdate", + }, + }, + { + name: "treasuryId", + type: "u8", + }, + ], + }, + }, { name: "VerificationLevel", docs: [ diff --git a/target_chains/solana/sdk/js/pyth_solana_receiver/src/vaa.ts b/target_chains/solana/sdk/js/pyth_solana_receiver/src/vaa.ts index 0aa9c8277a..18164cb7b9 100644 --- a/target_chains/solana/sdk/js/pyth_solana_receiver/src/vaa.ts +++ b/target_chains/solana/sdk/js/pyth_solana_receiver/src/vaa.ts @@ -2,9 +2,16 @@ import { Connection, Keypair, PublicKey } from "@solana/web3.js"; import { WormholeCoreBridgeSolana } from "./idl/wormhole_core_bridge_solana"; import { Program } from "@coral-xyz/anchor"; import { InstructionWithEphemeralSigners } from "@pythnetwork/solana-utils"; -import { WRITE_ENCODED_VAA_COMPUTE_BUDGET } from "./compute_budget"; +import { + CLOSE_ENCODED_VAA_COMPUTE_BUDGET, + INIT_ENCODED_VAA_COMPUTE_BUDGET, + VERIFY_ENCODED_VAA_COMPUTE_BUDGET, + WRITE_ENCODED_VAA_COMPUTE_BUDGET, +} from "./compute_budget"; import { sha256 } from "@noble/hashes/sha256"; import { bs58 } from "@coral-xyz/anchor/dist/cjs/utils/bytes"; +import { AccumulatorUpdateData } from "@pythnetwork/price-service-sdk"; +import { getGuardianSetPda } from "./address"; /** * Get the index of the guardian set that signed a VAA */ @@ -25,6 +32,21 @@ export const DEFAULT_REDUCED_GUARDIAN_SET_SIZE = 5; */ export const VAA_SIGNATURE_SIZE = 66; +/** + * The start of the VAA bytes in an encoded VAA account. Before this offset, the account contains a header. + */ +export const VAA_START = 46; + +/** + * Writing the VAA to an encoded VAA account is done in 2 instructions. + * + * The first one writes the first `VAA_SPLIT_INDEX` bytes and the second one writes the rest. + * + * This number was chosen as the biggest number such that one can still call `createInstruction`, `initEncodedVaa` and `writeEncodedVaa` in a single Solana transaction. + * This way, the packing of the instructions to post an encoded vaa is more efficient. + */ +export const VAA_SPLIT_INDEX = 755; + /** * Trim the number of signatures of a VAA. * @@ -54,54 +76,45 @@ export function trimSignatures( } /** - * The start of the VAA bytes in an encoded VAA account. Before this offset, the account contains a header. + * Groups of instructions for posting a VAA to the Wormhole program. + * The instructions are split into logical groups to allow flexible ordering and batching: + * - initInstructions: Create and initialize the encoded VAA account + * - writeFirstPartInstructions: Write the first part of the VAA data (up to VAA_SPLIT_INDEX) + * - writeSecondPartAndVerifyInstructions: Write remaining VAA data and verify signatures + * - closeInstructions: Close the encoded VAA account to recover rent */ -export const VAA_START = 46; +interface VaaInstructionGroups { + initInstructions: InstructionWithEphemeralSigners[]; + writeFirstPartInstructions: InstructionWithEphemeralSigners[]; + writeSecondPartAndVerifyInstructions: InstructionWithEphemeralSigners[]; + closeInstructions: InstructionWithEphemeralSigners[]; + encodedVaaAddress: PublicKey; +} -/** - * Build an instruction to create an encoded VAA account. - * - * This is the first step to post a VAA to the Wormhole program. - */ -export async function buildEncodedVaaCreateInstruction( +// Core function to generate VAA instruction groups +async function generateVaaInstructionGroups( wormhole: Program, - vaa: Buffer, - encodedVaaKeypair: Keypair -): Promise { - const encodedVaaSize = vaa.length + VAA_START; - return { - instruction: await wormhole.account.encodedVaa.createInstruction( - encodedVaaKeypair, - encodedVaaSize - ), - signers: [encodedVaaKeypair], - }; -} + vaa: Buffer +): Promise { + const encodedVaaKeypair = new Keypair(); -/** - * Writing the VAA to an encoded VAA account is done in 2 instructions. - * - * The first one writes the first `VAA_SPLIT_INDEX` bytes and the second one writes the rest. - * - * This number was chosen as the biggest number such that one can still call `createInstruction`, `initEncodedVaa` and `writeEncodedVaa` in a single Solana transaction. - * This way, the packing of the instructions to post an encoded vaa is more efficient. - */ -export const VAA_SPLIT_INDEX = 755; + // Create and init instructions + const initInstructions: InstructionWithEphemeralSigners[] = [ + await buildEncodedVaaCreateInstruction(wormhole, vaa, encodedVaaKeypair), + { + instruction: await wormhole.methods + .initEncodedVaa() + .accounts({ + encodedVaa: encodedVaaKeypair.publicKey, + }) + .instruction(), + signers: [], + computeUnits: INIT_ENCODED_VAA_COMPUTE_BUDGET, + }, + ]; -/** - * Build a set of instructions to write a VAA to an encoded VAA account - * This functions returns 2 instructions and splits the VAA in an opinionated way, so that the whole process of posting a VAA can be efficiently packed in the 2 transactions: - * - * TX 1 : `createInstruction` + `initEncodedVaa` + `writeEncodedVaa_1` - * - * TX 2 : `writeEncodedVaa_2` + `verifyEncodedVaaV1` - */ -export async function buildWriteEncodedVaaWithSplitInstructions( - wormhole: Program, - vaa: Buffer, - draftVaa: PublicKey -): Promise { - return [ + // First write instruction + const writeFirstPartInstructions: InstructionWithEphemeralSigners[] = [ { instruction: await wormhole.methods .writeEncodedVaa({ @@ -109,26 +122,204 @@ export async function buildWriteEncodedVaaWithSplitInstructions( data: vaa.subarray(0, VAA_SPLIT_INDEX), }) .accounts({ - draftVaa, + draftVaa: encodedVaaKeypair.publicKey, }) .instruction(), signers: [], computeUnits: WRITE_ENCODED_VAA_COMPUTE_BUDGET, }, + ]; + + // Second write and verify instructions + const writeSecondPartAndVerifyInstructions: InstructionWithEphemeralSigners[] = + [ + { + instruction: await wormhole.methods + .writeEncodedVaa({ + index: VAA_SPLIT_INDEX, + data: vaa.subarray(VAA_SPLIT_INDEX), + }) + .accounts({ + draftVaa: encodedVaaKeypair.publicKey, + }) + .instruction(), + signers: [], + computeUnits: WRITE_ENCODED_VAA_COMPUTE_BUDGET, + }, + { + instruction: await wormhole.methods + .verifyEncodedVaaV1() + .accounts({ + guardianSet: getGuardianSetPda( + getGuardianSetIndex(vaa), + wormhole.programId + ), + draftVaa: encodedVaaKeypair.publicKey, + }) + .instruction(), + signers: [], + computeUnits: VERIFY_ENCODED_VAA_COMPUTE_BUDGET, + }, + ]; + + // Close instructions + const closeInstructions: InstructionWithEphemeralSigners[] = [ { instruction: await wormhole.methods - .writeEncodedVaa({ - index: VAA_SPLIT_INDEX, - data: vaa.subarray(VAA_SPLIT_INDEX), - }) - .accounts({ - draftVaa, - }) + .closeEncodedVaa() + .accounts({ encodedVaa: encodedVaaKeypair.publicKey }) .instruction(), signers: [], - computeUnits: WRITE_ENCODED_VAA_COMPUTE_BUDGET, + computeUnits: CLOSE_ENCODED_VAA_COMPUTE_BUDGET, }, ]; + + return { + initInstructions, + writeFirstPartInstructions, + writeSecondPartAndVerifyInstructions, + closeInstructions, + encodedVaaAddress: encodedVaaKeypair.publicKey, + }; +} + +/** + * Build instructions to post a single VAA to the Wormhole program. + * The instructions can be packed efficiently into 2 transactions: + * - TX1: Create, init the encoded VAA account and write the first part of the VAA + * - TX2: Write the second part of the VAA and verify it + * + * @param wormhole - The Wormhole program instance + * @param vaa - The VAA buffer to post + * @returns {Object} Result containing: + * - encodedVaaAddress: Public key of the encoded VAA account + * - postInstructions: Instructions to post and verify the VAA + * - closeInstructions: Instructions to close the encoded VAA account and recover rent + */ +export async function buildPostEncodedVaaInstructions( + wormhole: Program, + vaa: Buffer +): Promise<{ + encodedVaaAddress: PublicKey; + postInstructions: InstructionWithEphemeralSigners[]; + closeInstructions: InstructionWithEphemeralSigners[]; +}> { + const groups = await generateVaaInstructionGroups(wormhole, vaa); + + // Pack instructions for optimal 2-transaction pattern: + // TX1: init + first write + // TX2: second write + verify + return { + encodedVaaAddress: groups.encodedVaaAddress, + postInstructions: [ + ...groups.initInstructions, + ...groups.writeFirstPartInstructions, + ...groups.writeSecondPartAndVerifyInstructions, + ], + closeInstructions: groups.closeInstructions, + }; +} + +/** + * Build instructions to post two VAAs for TWAP (Time-Weighted Average Price) calculations, + * optimized for 3 transactions. This is specifically designed for posting start and end + * accumulator update VAAs efficiently. + * The instructions are packed into 3 transactions: + * - TX1: Initialize and write first part of start VAA + * - TX2: Initialize and write first part of end VAA + * - TX3: Write second part and verify both VAAs + * + * @param wormhole - The Wormhole program instance + * @param startUpdateData - Accumulator update data containing the start VAA + * @param endUpdateData - Accumulator update data containing the end VAA + * @returns {Object} Result containing: + * - startEncodedVaaAddress: Public key of the start VAA account + * - endEncodedVaaAddress: Public key of the end VAA account + * - postInstructions: Instructions to post and verify both VAAs + * - closeInstructions: Instructions to close both encoded VAA accounts + */ +export async function buildPostEncodedVaasForTwapInstructions( + wormhole: Program, + startUpdateData: AccumulatorUpdateData, + endUpdateData: AccumulatorUpdateData +): Promise<{ + startEncodedVaaAddress: PublicKey; + endEncodedVaaAddress: PublicKey; + postInstructions: InstructionWithEphemeralSigners[]; + closeInstructions: InstructionWithEphemeralSigners[]; +}> { + const startGroups = await generateVaaInstructionGroups( + wormhole, + startUpdateData.vaa + ); + const endGroups = await generateVaaInstructionGroups( + wormhole, + endUpdateData.vaa + ); + + // Pack instructions for optimal 3-transaction pattern: + // TX1: start VAA init + first write + // TX2: end VAA init + first write + // TX3: both VAAs second write + verify + const postInstructions = [ + // TX1 + ...startGroups.initInstructions, + ...startGroups.writeFirstPartInstructions, + // TX2 + ...endGroups.initInstructions, + ...endGroups.writeFirstPartInstructions, + // TX3 + ...startGroups.writeSecondPartAndVerifyInstructions, + ...endGroups.writeSecondPartAndVerifyInstructions, + ]; + + return { + startEncodedVaaAddress: startGroups.encodedVaaAddress, + endEncodedVaaAddress: endGroups.encodedVaaAddress, + postInstructions, + closeInstructions: [ + ...startGroups.closeInstructions, + ...endGroups.closeInstructions, + ], + }; +} + +/** + * Build an instruction to close an encoded VAA account, recovering the rent. + */ +export async function buildCloseEncodedVaaInstruction( + wormhole: Program, + encodedVaa: PublicKey +): Promise { + const instruction = await wormhole.methods + .closeEncodedVaa() + .accounts({ encodedVaa }) + .instruction(); + return { + instruction, + signers: [], + computeUnits: CLOSE_ENCODED_VAA_COMPUTE_BUDGET, + }; +} + +/** + * Build an instruction to create an encoded VAA account. + * + * This is the first step to post a VAA to the Wormhole program. + */ +export async function buildEncodedVaaCreateInstruction( + wormhole: Program, + vaa: Buffer, + encodedVaaKeypair: Keypair +): Promise { + const encodedVaaSize = vaa.length + VAA_START; + return { + instruction: await wormhole.account.encodedVaa.createInstruction( + encodedVaaKeypair, + encodedVaaSize + ), + signers: [encodedVaaKeypair], + }; } /**