diff --git a/package-lock.json b/package-lock.json index 99d42ab6db..8d4f976aae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59334,6 +59334,107 @@ "typescript": "^4.6.3" } }, + "target_chains/solana/sdk/js/pyth_solana_receiver": { + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "@coral-xyz/anchor": "^0.29.0", + "@pythnetwork/price-service-sdk": "*", + "@pythnetwork/solana-utils": "*", + "@solana/web3.js": "^1.90.0" + }, + "devDependencies": { + "@types/jest": "^29.4.0", + "@typescript-eslint/eslint-plugin": "^5.20.0", + "@typescript-eslint/parser": "^5.20.0", + "eslint": "^8.13.0", + "jest": "^29.4.0", + "prettier": "^2.6.2", + "quicktype": "^23.0.76", + "ts-jest": "^29.0.5", + "typescript": "^4.6.3" + } + }, + "target_chains/solana/sdk/js/pyth_solana_receiver/node_modules/@coral-xyz/anchor": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@coral-xyz/anchor/-/anchor-0.29.0.tgz", + "integrity": "sha512-eny6QNG0WOwqV0zQ7cs/b1tIuzZGmP7U7EcH+ogt4Gdbl8HDmIYVMh/9aTmYZPaFWjtUaI8qSn73uYEXWfATdA==", + "dependencies": { + "@coral-xyz/borsh": "^0.29.0", + "@noble/hashes": "^1.3.1", + "@solana/web3.js": "^1.68.0", + "bn.js": "^5.1.2", + "bs58": "^4.0.1", + "buffer-layout": "^1.2.2", + "camelcase": "^6.3.0", + "cross-fetch": "^3.1.5", + "crypto-hash": "^1.3.0", + "eventemitter3": "^4.0.7", + "pako": "^2.0.3", + "snake-case": "^3.0.4", + "superstruct": "^0.15.4", + "toml": "^3.0.0" + }, + "engines": { + "node": ">=11" + } + }, + "target_chains/solana/sdk/js/pyth_solana_receiver/node_modules/@coral-xyz/borsh": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@coral-xyz/borsh/-/borsh-0.29.0.tgz", + "integrity": "sha512-s7VFVa3a0oqpkuRloWVPdCK7hMbAMY270geZOGfCnaqexrP5dTIpbEHL33req6IYPPJ0hYa71cdvJ1h6V55/oQ==", + "dependencies": { + "bn.js": "^5.1.2", + "buffer-layout": "^1.2.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@solana/web3.js": "^1.68.0" + } + }, + "target_chains/solana/sdk/js/pyth_solana_receiver/node_modules/@noble/hashes": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.3.tgz", + "integrity": "sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA==", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "target_chains/solana/sdk/js/pyth_solana_receiver/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "target_chains/solana/sdk/js/solana_utils": { + "name": "@pythnetwork/solana-utils", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "@solana/web3.js": "^1.90.0" + }, + "devDependencies": { + "@types/jest": "^29.4.0", + "@typescript-eslint/eslint-plugin": "^5.20.0", + "@typescript-eslint/parser": "^5.20.0", + "eslint": "^8.13.0", + "jest": "^29.4.0", + "prettier": "^2.6.2", + "quicktype": "^23.0.76", + "ts-jest": "^29.0.5", + "typescript": "^4.6.3" + } + }, "target_chains/sui/cli": { "name": "pyth-sui-cli", "version": "0.0.1", @@ -71060,6 +71161,66 @@ "typescript": "^4.6.3" } }, + "@pythnetwork/pyth-solana-receiver": { + "version": "file:target_chains/solana/sdk/js/pyth_solana_receiver", + "requires": { + "@coral-xyz/anchor": "^0.29.0", + "@pythnetwork/price-service-sdk": "*", + "@pythnetwork/solana-utils": "*", + "@solana/web3.js": "^1.90.0", + "@types/jest": "^29.4.0", + "@typescript-eslint/eslint-plugin": "^5.20.0", + "@typescript-eslint/parser": "^5.20.0", + "eslint": "^8.13.0", + "jest": "^29.4.0", + "prettier": "^2.6.2", + "quicktype": "^23.0.76", + "ts-jest": "^29.0.5", + "typescript": "^4.6.3" + }, + "dependencies": { + "@coral-xyz/anchor": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@coral-xyz/anchor/-/anchor-0.29.0.tgz", + "integrity": "sha512-eny6QNG0WOwqV0zQ7cs/b1tIuzZGmP7U7EcH+ogt4Gdbl8HDmIYVMh/9aTmYZPaFWjtUaI8qSn73uYEXWfATdA==", + "requires": { + "@coral-xyz/borsh": "^0.29.0", + "@noble/hashes": "^1.3.1", + "@solana/web3.js": "^1.68.0", + "bn.js": "^5.1.2", + "bs58": "^4.0.1", + "buffer-layout": "^1.2.2", + "camelcase": "^6.3.0", + "cross-fetch": "^3.1.5", + "crypto-hash": "^1.3.0", + "eventemitter3": "^4.0.7", + "pako": "^2.0.3", + "snake-case": "^3.0.4", + "superstruct": "^0.15.4", + "toml": "^3.0.0" + } + }, + "@coral-xyz/borsh": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@coral-xyz/borsh/-/borsh-0.29.0.tgz", + "integrity": "sha512-s7VFVa3a0oqpkuRloWVPdCK7hMbAMY270geZOGfCnaqexrP5dTIpbEHL33req6IYPPJ0hYa71cdvJ1h6V55/oQ==", + "requires": { + "bn.js": "^5.1.2", + "buffer-layout": "^1.2.0" + } + }, + "@noble/hashes": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.3.tgz", + "integrity": "sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA==" + }, + "camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==" + } + } + }, "@pythnetwork/pyth-sui-js": { "version": "file:target_chains/sui/sdk/js", "requires": { diff --git a/target_chains/solana/README.md b/target_chains/solana/README.md index 720a06630b..8ef2164ca6 100644 --- a/target_chains/solana/README.md +++ b/target_chains/solana/README.md @@ -2,19 +2,19 @@ This folder contains: -- A Pyth receiver program to receive Pythnet price feeds on Solana in `programs/pyth-solana-receiver` +- A Pyth receiver program to receive Pyth price updates on Solana in `programs/pyth-solana-receiver` - A Cli that acts as a simple client to interact with the Pyth receiver program in `cli/` # Overview of the design -Receiving a price update from Pythnet involves two steps: +Posting a Pyth price update involves two steps: - First, verifying the VAA i.e. verifying the Wormhole guardians' signatures on the accumulator root that contains all the price updates for a given Pythnet slot. - Second, verifying the price update by providing an inclusion proof that proves the price update is part of the accumulator root that was verified in the first step. # Implementation -This contract offers two ways to post a price update from Pythnet onto Solana: +This contract offers two ways to post a Pyth price update onto Solana: - `post_update` allows you to do it in 2 transactions and checks all the Wormhole guardian signatures (the quorum is currently 13 signatures). It relies on the Wormhole contract to verify the signatures. - `post_update_atomic` allows you to do it in 1 transaction but only partially checks the Wormhole guardian signatures (5 signatures seems like the best it can currently do). Therefore it is less secure. It relies on a guardian set account from the Wormhole contract to check the signatures against the guardian keys. diff --git a/target_chains/solana/sdk/js/pyth_solana_receiver/README.md b/target_chains/solana/sdk/js/pyth_solana_receiver/README.md new file mode 100644 index 0000000000..d93752f3ec --- /dev/null +++ b/target_chains/solana/sdk/js/pyth_solana_receiver/README.md @@ -0,0 +1,83 @@ +# Pyth Solana Receiver JS SDK + +This is a Javascript SDK to interact with the Pyth Solana Receiver contract whose code lives [here](/target_chains/solana). + +## Pull model + +The Pyth Solana Receiver allows users to consume Pyth price updates on a pull basis. This means that the user is responsible for submitting the price data on-chain whenever they want to interact with an app that requires a price update. + +Price updates get posted into price update accounts, owned by the Receiver contract. Once an update has been posted to a price update account, it can be used by anyone by simply passing the price update account as one of the accounts in a Solana instruction. +Price update accounts can be closed by whoever wrote them to recover the rent. + +## Example use + +```ts +import { Connection, PublicKey } from '@solana/web3.js'; +import { PriceServiceConnection } from '@pythnetwork/price-service-client'; +import { PythSolanaReceiver } from '@pythnetwork/pyth-solana-receiver'; +import { MyFirstPythApp, IDL } from './idl/my_first_pyth_app'; + + +const SOL_PRICE_FEED_ID = "0xef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d" +const ETH_PRICE_FEED_ID = "0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace" + +const priceServiceConnection = new PriceServiceConnection("https://hermes.pyth.network/", { priceFeedRequestConfig: { binary: true } }); +const priceUpdateData = await priceServiceConnection.getLatestVaas([SOL_PRICE_FEED_ID, ETH_PRICE_FEED_ID]); // Fetch off-chain price update data + + +const myFirstPythApp = new Program(IDL as MyFirstPythApp, , PublicKey.unique(), {}) +const getInstructions = async (priceFeedIdToPriceUpdateAccount: Record) => { return [{ instruction: await myFirstApp.methods.consume().accounts({ solPriceUpdate: priceFeedIdToPriceUpdateAccount[SOL_PRICE_FEED_ID], ethPriceUpdate: priceFeedIdToPriceUpdateAccount[ETH_PRICE_FEED_ID] }).instruction(), signers: [] }] }; + +const pythSolanaReceiver = new PythSolanaReceiver({ connection, wallet }); +const transactions = await pythSolanaReceiver.withPriceUpdate(priceUpdateData, getInstructions, {}) +await pythSolanaReceiver.provider.sendAll(transactions); +``` + +Or, alternatively: + +```ts +import { PublicKey } from "@solana/web3.js"; +import { PriceServiceConnection } from "@pythnetwork/price-service-client"; +import { PythSolanaReceiver } from "@pythnetwork/pyth-solana-receiver"; +import { MyFirstPythApp, IDL } from "./idl/my_first_pyth_app"; + +const SOL_PRICE_FEED_ID = + "0xef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d"; +const ETH_PRICE_FEED_ID = + "0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace"; + +const priceServiceConnection = new PriceServiceConnection( + "https://hermes.pyth.network/", + { priceFeedRequestConfig: { binary: true } } +); +const priceUpdateData = await priceServiceConnection.getLatestVaas([ + SOL_PRICE_FEED_ID, + ETH_PRICE_FEED_ID, +]); // Fetch off-chain price update data + +const pythSolanaReceiver = new PythSolanaReceiver({ connection, wallet }); +const { postInstructions, closeInstructions, priceFeedIdToPriceUpdateAccount } = + await pythSolanaReceiver.buildPostPriceUpdateInstructions(priceUpdateData); // Get instructions to post the price update data and to close the accounts later + +const myFirstPythApp = new Program( + IDL as MyFirstPythApp, + PublicKey.unique(), + {} +); +const consumerInstruction: InstructionWithEphemeralSigners = { + instruction: await myFirstPythApp.methods + .consume() + .accounts({ + solPriceUpdate: priceFeedIdToPriceUpdateAccount[SOL_PRICE_FEED_ID], + ethPriceUpdate: priceFeedIdToPriceUpdateAccount[ETH_PRICE_FEED_ID], + }) + .instruction(), + signers: [], +}; + +const transactions = pythSolanaReceiver.batchIntoVersionedTransactions( + [...postInstructions, consumerInstruction, ...closeInstructions], + {} +); // Put all the instructions together +await pythSolanaReceiver.provider.sendAll(transactions); +``` 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 88cde47818..70523d71bd 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 @@ -10,6 +10,7 @@ import { } from "./idl/wormhole_core_bridge_solana"; import { DEFAULT_RECEIVER_PROGRAM_ID, + DEFAULT_TREASURY_ID, DEFAULT_WORMHOLE_PROGRAM_ID, getConfigPda, getGuardianSetPda, @@ -28,7 +29,7 @@ import { import { Wallet } from "@coral-xyz/anchor/dist/cjs/provider"; import { buildEncodedVaaCreateInstruction, - buildWriteEncodedVaaWithSplit, + buildWriteEncodedVaaWithSplitInstructions, getGuardianSetIndex, trimSignatures, } from "./vaa"; @@ -38,8 +39,14 @@ import { PriorityFeeConfig, } from "@pythnetwork/solana-utils"; -export const DEFAULT_TREASURY_ID = 0; - +/** + * A class to interact with the Pyth Solana Receiver program. + * + * This class provides helpful methods to: + * - Post price updates from Pythnet to the Pyth Solana Receiver program + * - Consume price updates in a consumer program + * - Close price update and encoded vaa accounts to recover rent + */ export class PythSolanaReceiver { readonly connection: Connection; readonly wallet: Wallet; @@ -75,6 +82,13 @@ export class PythSolanaReceiver { ); } + /** + * Build a series of transactions that post price updates to the Pyth Solana Receiver program, consume them in a consumer program and close the encoded vaa accounts and price update accounts. + * @param priceUpdateDataArray the output of the `@pythnetwork/price-service-client`'s `PriceServiceConnection.getLatestVaas`. This is an array of verifiable price updates. + * @param getInstructions a function that given a map of price feed IDs to price update accounts, returns a series of instructions to consume the price updates in a consumer program. This function is a way for the user to indicate which accounts in their instruction need to be "replaced" with price update accounts. + * @param priorityFeeConfig a configuration for the compute unit price to use for the transactions. + * @returns an array of transactions and their corresponding ephemeral signers + */ async withPriceUpdate( priceUpdateDataArray: string[], getInstructions: ( @@ -85,20 +99,29 @@ export class PythSolanaReceiver { const { postInstructions, priceFeedIdToPriceUpdateAccount: priceFeedIdToPriceUpdateAccount, - cleanupInstructions, + closeInstructions, } = await this.buildPostPriceUpdateInstructions(priceUpdateDataArray); - return TransactionBuilder.batchIntoVersionedTransactions( - this.wallet.publicKey, - this.connection, + return this.batchIntoVersionedTransactions( [ ...postInstructions, ...(await getInstructions(priceFeedIdToPriceUpdateAccount)), - ...cleanupInstructions, + ...closeInstructions, ], priorityFeeConfig ?? {} ); } + /** + * Build a series of transactions that post partially verified price updates to the Pyth Solana Receiver program, consume them in a consumer program and close the price update accounts. + * + * Partially verified price updates are price updates where not all the guardian signatures have been verified. By default this methods checks `DEFAULT_REDUCED_GUARDIAN_SET_SIZE` signatures when posting the VAA. + * If you are a on-chain program developer, make sure you understand the risks of consuming partially verified price updates here: {@link https://github.com/pyth-network/pyth-crosschain/blob/main/target_chains/solana/pyth_solana_receiver_state/src/price_update.rs}. + * + * @param priceUpdateDataArray the output of the `@pythnetwork/price-service-client`'s `PriceServiceConnection.getLatestVaas`. This is an array of verifiable price updates. + * @param getInstructions a function that given a map of price feed IDs to price update accounts, returns a series of instructions to consume the price updates in a consumer program. This function is a way for the user to indicate which accounts in their instruction need to be "replaced" with price update accounts. + * @param priorityFeeConfig a configuration for the compute unit price to use for the transactions. + * @returns an array of transactions and their corresponding ephemeral signers + */ async withPartiallyVerifiedPriceUpdate( priceUpdateDataArray: string[], getInstructions: ( @@ -109,30 +132,39 @@ export class PythSolanaReceiver { const { postInstructions, priceFeedIdToPriceUpdateAccount, - cleanupInstructions, + closeInstructions, } = await this.buildPostPriceUpdateAtomicInstructions(priceUpdateDataArray); - return TransactionBuilder.batchIntoVersionedTransactions( - this.wallet.publicKey, - this.connection, + return this.batchIntoVersionedTransactions( [ ...postInstructions, ...(await getInstructions(priceFeedIdToPriceUpdateAccount)), - ...cleanupInstructions, + ...closeInstructions, ], priorityFeeConfig ?? {} ); } + /** + * Build a series of helper instructions that post price updates to the Pyth Solana Receiver program and another series to close the price update accounts. + * + * This function uses partially verified price updates. Partially verified price updates are price updates where not all the guardian signatures have been verified. By default this methods checks `DEFAULT_REDUCED_GUARDIAN_SET_SIZE` signatures when posting the VAA. + * If you are a on-chain program developer, make sure you understand the risks of consuming partially verified price updates here: {@link https://github.com/pyth-network/pyth-crosschain/blob/main/target_chains/solana/pyth_solana_receiver_state/src/price_update.rs}. + * + * @param priceUpdateDataArray the output of the `@pythnetwork/price-service-client`'s `PriceServiceConnection.getLatestVaas`. This is an array of verifiable price updates. + * @returns `postInstructions`: the instructions to post the price updates, these should be called before consuming the price updates + * @returns `priceFeedIdToPriceUpdateAccount`: 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 price update. + * @returns `closeInstructions`: the instructions to close the price update accounts, these should be called after consuming the price updates + */ async buildPostPriceUpdateAtomicInstructions( priceUpdateDataArray: string[] ): Promise<{ postInstructions: InstructionWithEphemeralSigners[]; priceFeedIdToPriceUpdateAccount: Record; - cleanupInstructions: InstructionWithEphemeralSigners[]; + closeInstructions: InstructionWithEphemeralSigners[]; }> { const postInstructions: InstructionWithEphemeralSigners[] = []; const priceFeedIdToPriceUpdateAccount: Record = {}; - const cleanupInstructions: InstructionWithEphemeralSigners[] = []; + const closeInstructions: InstructionWithEphemeralSigners[] = []; for (const priceUpdateData of priceUpdateDataArray) { const accumulatorUpdateData = parseAccumulatorUpdateData( @@ -152,9 +184,15 @@ export class PythSolanaReceiver { }) .accounts({ priceUpdateAccount: priceUpdateKeypair.publicKey, - treasury: getTreasuryPda(DEFAULT_TREASURY_ID), - config: getConfigPda(), - guardianSet: getGuardianSetPda(guardianSetIndex), + treasury: getTreasuryPda( + DEFAULT_TREASURY_ID, + this.receiver.programId + ), + config: getConfigPda(this.receiver.programId), + guardianSet: getGuardianSetPda( + guardianSetIndex, + this.wormhole.programId + ), }) .instruction(), signers: [priceUpdateKeypair], @@ -164,7 +202,7 @@ export class PythSolanaReceiver { "0x" + parsePriceFeedMessage(update.message).feedId.toString("hex") ] = priceUpdateKeypair.publicKey; - cleanupInstructions.push( + closeInstructions.push( await this.buildClosePriceUpdateInstruction( priceUpdateKeypair.publicKey ) @@ -174,17 +212,25 @@ export class PythSolanaReceiver { return { postInstructions, priceFeedIdToPriceUpdateAccount, - cleanupInstructions, + closeInstructions, }; } + /** + * 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; - cleanupInstructions: InstructionWithEphemeralSigners[]; + closeInstructions: InstructionWithEphemeralSigners[]; }> { const postInstructions: InstructionWithEphemeralSigners[] = []; - const cleanupInstructions: InstructionWithEphemeralSigners[] = []; + const closeInstructions: InstructionWithEphemeralSigners[] = []; const encodedVaaKeypair = new Keypair(); const guardianSetIndex = getGuardianSetIndex(vaa); @@ -206,7 +252,7 @@ export class PythSolanaReceiver { }); postInstructions.push( - ...(await buildWriteEncodedVaaWithSplit( + ...(await buildWriteEncodedVaaWithSplitInstructions( this.wormhole, vaa, encodedVaaKeypair.publicKey @@ -217,7 +263,10 @@ export class PythSolanaReceiver { instruction: await this.wormhole.methods .verifyEncodedVaaV1() .accounts({ - guardianSet: getGuardianSetPda(guardianSetIndex), + guardianSet: getGuardianSetPda( + guardianSetIndex, + this.wormhole.programId + ), draftVaa: encodedVaaKeypair.publicKey, }) .instruction(), @@ -225,27 +274,35 @@ export class PythSolanaReceiver { computeUnits: VERIFY_ENCODED_VAA_COMPUTE_BUDGET, }); - cleanupInstructions.push( + closeInstructions.push( await this.buildCloseEncodedVaaInstruction(encodedVaaKeypair.publicKey) ); return { postInstructions, encodedVaaAddress: encodedVaaKeypair.publicKey, - cleanupInstructions, + 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. + * + * @param priceUpdateDataArray the output of the `@pythnetwork/price-service-client`'s `PriceServiceConnection.getLatestVaas`. This is an array of verifiable price updates. + * @returns `postInstructions`: the instructions to post the price updates, these should be called before consuming the price updates + * @returns `priceFeedIdToPriceUpdateAccount`: 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 price update. + * @returns `closeInstructions`: the instructions to close the price update accounts, these should be called after consuming the price updates + */ async buildPostPriceUpdateInstructions( priceUpdateDataArray: string[] ): Promise<{ postInstructions: InstructionWithEphemeralSigners[]; priceFeedIdToPriceUpdateAccount: Record; - cleanupInstructions: InstructionWithEphemeralSigners[]; + closeInstructions: InstructionWithEphemeralSigners[]; }> { const postInstructions: InstructionWithEphemeralSigners[] = []; const priceFeedIdToPriceUpdateAccount: Record = {}; - const cleanupInstructions: InstructionWithEphemeralSigners[] = []; + const closeInstructions: InstructionWithEphemeralSigners[] = []; for (const priceUpdateData of priceUpdateDataArray) { const accumulatorUpdateData = parseAccumulatorUpdateData( @@ -255,10 +312,10 @@ export class PythSolanaReceiver { const { postInstructions: postEncodedVaaInstructions, encodedVaaAddress: encodedVaa, - cleanupInstructions: postEncodedVaaCleanupInstructions, + closeInstructions: postEncodedVaacloseInstructions, } = await this.buildPostEncodedVaaInstructions(accumulatorUpdateData.vaa); postInstructions.push(...postEncodedVaaInstructions); - cleanupInstructions.push(...postEncodedVaaCleanupInstructions); + closeInstructions.push(...postEncodedVaacloseInstructions); for (const update of accumulatorUpdateData.updates) { const priceUpdateKeypair = new Keypair(); @@ -271,8 +328,11 @@ export class PythSolanaReceiver { .accounts({ encodedVaa, priceUpdateAccount: priceUpdateKeypair.publicKey, - treasury: getTreasuryPda(DEFAULT_TREASURY_ID), - config: getConfigPda(), + treasury: getTreasuryPda( + DEFAULT_TREASURY_ID, + this.receiver.programId + ), + config: getConfigPda(this.receiver.programId), }) .instruction(), signers: [priceUpdateKeypair], @@ -282,7 +342,7 @@ export class PythSolanaReceiver { priceFeedIdToPriceUpdateAccount[ "0x" + parsePriceFeedMessage(update.message).feedId.toString("hex") ] = priceUpdateKeypair.publicKey; - cleanupInstructions.push( + closeInstructions.push( await this.buildClosePriceUpdateInstruction( priceUpdateKeypair.publicKey ) @@ -293,10 +353,13 @@ export class PythSolanaReceiver { return { postInstructions, priceFeedIdToPriceUpdateAccount, - cleanupInstructions, + closeInstructions, }; } + /** + * Build an instruction to close an encoded VAA account, recovering the rent. + */ async buildCloseEncodedVaaInstruction( encodedVaa: PublicKey ): Promise { @@ -307,6 +370,9 @@ export class PythSolanaReceiver { return { instruction, signers: [] }; } + /** + * Build an instruction to close a price update account, recovering the rent. + */ async buildClosePriceUpdateInstruction( priceUpdateAccount: PublicKey ): Promise { @@ -316,4 +382,19 @@ export class PythSolanaReceiver { .instruction(); return { instruction, signers: [] }; } + + /** + * Returns a set of versioned transactions that contain the provided instructions in the same order and with efficient batching + */ + async batchIntoVersionedTransactions( + instructions: InstructionWithEphemeralSigners[], + priorityFeeConfig: PriorityFeeConfig + ): Promise<{ tx: VersionedTransaction; signers: Signer[] }[]> { + return TransactionBuilder.batchIntoVersionedTransactions( + this.wallet.publicKey, + this.connection, + instructions, + priorityFeeConfig + ); + } } diff --git a/target_chains/solana/sdk/js/pyth_solana_receiver/src/address.ts b/target_chains/solana/sdk/js/pyth_solana_receiver/src/address.ts index d5f417b82d..5c99376d83 100644 --- a/target_chains/solana/sdk/js/pyth_solana_receiver/src/address.ts +++ b/target_chains/solana/sdk/js/pyth_solana_receiver/src/address.ts @@ -1,31 +1,60 @@ import { PublicKey } from "@solana/web3.js"; +/** + * The default Pyth Solana Receiver program ID. + * The program is deployed at this address on all SVM networks. + */ export const DEFAULT_RECEIVER_PROGRAM_ID = new PublicKey( "rec5EKMGg6MxZYaMdyBfgwp4d5rB9T1VQH5pJv5LtFJ" ); +/** + * The default Wormhole program ID. + * The program is deployed at this address on all SVM networks. + */ export const DEFAULT_WORMHOLE_PROGRAM_ID = new PublicKey( "HDwcJBJXjL9FpJ7UBsYBtaDjsBUhuLCUYoz3zr8SWWaQ" ); -export const getGuardianSetPda = (guardianSetIndex: number) => { +/** + * Returns the address of a guardian set account from the Wormhole program. + */ +export const getGuardianSetPda = ( + guardianSetIndex: number, + wormholeProgramId: PublicKey +) => { const guardianSetIndexBuf = Buffer.alloc(4); guardianSetIndexBuf.writeUInt32BE(guardianSetIndex, 0); return PublicKey.findProgramAddressSync( [Buffer.from("GuardianSet"), guardianSetIndexBuf], - DEFAULT_WORMHOLE_PROGRAM_ID + wormholeProgramId )[0]; }; -export const getTreasuryPda = (treasuryId: number) => { +/** + * The Pyth Solana Receiver has one treasury account for each u8 `treasuryId`. + * This is meant to avoid write-locks on the treasury account by load-balancing the writes across multiple accounts. + */ +export const DEFAULT_TREASURY_ID = 0; + +/** + * Returns the address of a treasury account from the Pyth Solana Receiver program. + */ +export const getTreasuryPda = ( + treasuryId: number, + receiverProgramId: PublicKey +) => { return PublicKey.findProgramAddressSync( [Buffer.from("treasury"), Buffer.from([treasuryId])], - DEFAULT_RECEIVER_PROGRAM_ID + receiverProgramId )[0]; }; -export const getConfigPda = () => { +/** + * Returns the address of the config account from the Pyth Solana Receiver program. + */ +export const getConfigPda = (receiverProgramId: PublicKey) => { return PublicKey.findProgramAddressSync( [Buffer.from("config")], - DEFAULT_RECEIVER_PROGRAM_ID + receiverProgramId )[0]; }; 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 914f4df7bd..93d5b9f339 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 @@ -1,3 +1,12 @@ +/** + * A hard-coded budget for the compute units required for the `verifyEncodedVaa` instruction in the Wormhole program. + */ export const VERIFY_ENCODED_VAA_COMPUTE_BUDGET = 400000; +/** + * A hard-coded budget for the compute units required for the `postUpdateAtomic` instruction in the Pyth Solana Receiver program. + */ export const POST_UPDATE_ATOMIC_COMPUTE_BUDGET = 400000; +/** + * 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 = 200000; diff --git a/target_chains/solana/sdk/js/pyth_solana_receiver/src/index.ts b/target_chains/solana/sdk/js/pyth_solana_receiver/src/index.ts index f4f83959ae..842396a89a 100644 --- a/target_chains/solana/sdk/js/pyth_solana_receiver/src/index.ts +++ b/target_chains/solana/sdk/js/pyth_solana_receiver/src/index.ts @@ -1,2 +1,5 @@ export { PythSolanaReceiver } from "./PythSolanaReceiver"; -export { TransactionBuilder } from "@pythnetwork/solana-utils"; +export { + TransactionBuilder, + InstructionWithEphemeralSigners, +} from "@pythnetwork/solana-utils"; 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 b7a08ac7bf..13163fba20 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 @@ -3,15 +3,34 @@ import { WormholeCoreBridgeSolana } from "./idl/wormhole_core_bridge_solana"; import { Program } from "@coral-xyz/anchor"; import { InstructionWithEphemeralSigners } from "@pythnetwork/solana-utils"; -export const VAA_START = 46; -export const VAA_SIGNATURE_SIZE = 66; -export const VAA_SPLIT_INDEX = 792; -export const DEFAULT_REDUCED_GUARDIAN_SET_SIZE = 5; - +/** + * Get the index of the guardian set that signed a VAA + */ export function getGuardianSetIndex(vaa: Buffer) { return vaa.readUInt32BE(1); } +/** + * The default number of signatures to keep in a VAA when using `trimSignatures`. + * This number was chosen as the maximum number of signatures so that the VAA's contents can be posted in a single Solana transaction. + */ +export const DEFAULT_REDUCED_GUARDIAN_SET_SIZE = 5; + +/** + * The size of a guardian signature in a VAA. + * + * It is 66 bytes long, the first byte is the guardian index and the next 65 bytes are the signature (including a recovery id). + */ +export const VAA_SIGNATURE_SIZE = 66; + +/** + * Trim the number of signatures of a VAA. + * + * @returns the same VAA as the input, but with `n` signatures instead of the original number of signatures. + * + * A Wormhole VAA typically has a number of signatures equal to two thirds of the number of guardians. However, + * this function is useful to make VAAs smaller to post their contents in a single Solana transaction. + */ export function trimSignatures( vaa: Buffer, n = DEFAULT_REDUCED_GUARDIAN_SET_SIZE @@ -32,11 +51,21 @@ export function trimSignatures( return trimmedVaa; } +/** + * The start of the VAA bytes in an encoded VAA account. Before this offset, the account contains a header. + */ +export const VAA_START = 46; + +/** + * 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( @@ -47,7 +76,25 @@ export async function buildEncodedVaaCreateInstruction( }; } -export async function buildWriteEncodedVaaWithSplit( +/** + * Writing the VAA to 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 = 792; + +/** + * 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 diff --git a/target_chains/solana/sdk/js/solana_utils/src/transaction.ts b/target_chains/solana/sdk/js/solana_utils/src/transaction.ts index bd8f82c0ba..8169573cea 100644 --- a/target_chains/solana/sdk/js/solana_utils/src/transaction.ts +++ b/target_chains/solana/sdk/js/solana_utils/src/transaction.ts @@ -12,26 +12,62 @@ import { VersionedTransaction, } from "@solana/web3.js"; +/** + * If the transaction doesn't contain a `setComputeUnitLimit` instruction, the default compute budget is 200,000 units per instruction. + */ export const DEFAULT_COMPUTE_BUDGET_UNITS = 200000; +/** + * The maximum size of a Solana transaction, leaving some room for the compute budget instructions. + */ export const PACKET_DATA_SIZE_WITH_ROOM_FOR_COMPUTE_BUDGET = PACKET_DATA_SIZE - 52; +/** + * An instruction with some extra information that will be used to build transactions. + */ export type InstructionWithEphemeralSigners = { + /** The instruction */ instruction: TransactionInstruction; + /** The ephemeral signers that need to sign the transaction where this instruction will be */ signers: Signer[]; + /** The compute units that this instruction requires, useful if greater than `DEFAULT_COMPUTE_BUDGET_UNITS` */ computeUnits?: number; }; +/** + * The priority fee configuration for transactions + */ export type PriorityFeeConfig = { + /** This is the priority fee in micro lamports, it gets passed down to `setComputeUnitPrice` */ computeUnitPriceMicroLamports?: number; }; +/** + * A default priority fee configuration. Using a priority fee is helpful even when you're not writing to hot accounts. + */ export const DEFAULT_PRIORITY_FEE_CONFIG: PriorityFeeConfig = { computeUnitPriceMicroLamports: 50000, }; /** * Get the size of a transaction that would contain the provided array of instructions + * This is based on {@link https://solana.com/docs/core/transactions}. + * + * Each transaction has the following layout : + * + * - A compact array of all signatures + * - A 3-bytes message header + * - A compact array with all the account addresses + * - A recent blockhash + * - A compact array of instructions + * + * If the transaction is a `VersionedTransaction`, it also contains an extra byte at the beginning, indicating the version and an array of `MessageAddressTableLookup` at the end. + * We don't support Account Lookup Tables, so that array has a size of 0. + * + * Each instruction has the following layout : + * - One byte indicating the index of the program in the account addresses array + * - A compact array of indices into the account addresses array, indicating which accounts are used by the instruction + * - A compact array of serialized instruction data */ export function getSizeOfTransaction( instructions: TransactionInstruction[], @@ -62,20 +98,20 @@ export function getSizeOfTransaction( .reduce((a, b) => a + b, 0); return ( - 1 + - signers.size * 64 + + getSizeOfCompressedU16(signers.size) + + signers.size * 64 + // array of signatures 3 + getSizeOfCompressedU16(accounts.size) + - 32 * accounts.size + - 32 + + 32 * accounts.size + // array of account addresses + 32 + // recent blockhash getSizeOfCompressedU16(instructions.length) + - instruction_sizes + - (versionedTransaction ? 1 + getSizeOfCompressedU16(0) : 0) + instruction_sizes + // array of instructions + (versionedTransaction ? 1 + getSizeOfCompressedU16(0) : 0) // we don't support Account Lookup Tables ); } /** - * Get the size of n in bytes when serialized as a CompressedU16 + * Get the size of n in bytes when serialized as a CompressedU16. Compact arrays use a CompactU16 to store the length of the array. */ export function getSizeOfCompressedU16(n: number) { return 1 + Number(n >= 128) + Number(n >= 16384); @@ -83,7 +119,7 @@ export function getSizeOfCompressedU16(n: number) { /** * This class is helpful for batching instructions into transactions in an efficient way. - * As you add instructions, it adds them to the current transactions until it's full, then it starts a new transaction. + * As you add instructions, it adds them to the current transaction until it's full, then it starts a new transaction. */ export class TransactionBuilder { readonly transactionInstructions: { @@ -94,14 +130,14 @@ export class TransactionBuilder { readonly payer: PublicKey; readonly connection: Connection; + /** Make a new `TransactionBuilder`. It requires a `payer` to populate the `payerKey` field and a connection to populate `recentBlockhash` in the versioned transactions. */ constructor(payer: PublicKey, connection: Connection) { this.payer = payer; this.connection = connection; } /** - * Add an instruction to the builder, the signers argument can be used to specify ephemeral signers that need to sign the transaction - * where this instruction appears + * Add an `InstructionWithEphemeralSigners` to the builder. */ addInstruction(args: InstructionWithEphemeralSigners) { const { instruction, signers, computeUnits } = args; @@ -135,6 +171,9 @@ export class TransactionBuilder { }); } + /** + * Add multiple `InstructionWithEphemeralSigners` to the builder. + */ addInstructions(instructions: InstructionWithEphemeralSigners[]) { for (const { instruction, signers, computeUnits } of instructions) { this.addInstruction({ instruction, signers, computeUnits }); @@ -142,7 +181,7 @@ export class TransactionBuilder { } /** - * Returns all the added instructions batched into transactions, plus for each transaction the ephemeral signers that need to sign it + * Returns all the added instructions batched into versioned transactions, plus for each transaction the ephemeral signers that need to sign it */ async getVersionedTransactions( args: PriorityFeeConfig @@ -204,6 +243,9 @@ export class TransactionBuilder { }); } + /** + * Returns a set of transactions that contain the provided instructions in the same order and with efficient batching + */ static batchIntoLegacyTransactions( instructions: TransactionInstruction[], priorityFeeConfig: PriorityFeeConfig @@ -222,6 +264,9 @@ export class TransactionBuilder { }); } + /** + * Returns a set of versioned transactions that contain the provided instructions in the same order and with efficient batching + */ static async batchIntoVersionedTransactions( payer: PublicKey, connection: Connection, @@ -233,6 +278,9 @@ export class TransactionBuilder { return transactionBuilder.getVersionedTransactions(priorityFeeConfig); } + /** + * Add a priority fee to a legacy transaction + */ static addPriorityFee( transaction: Transaction, priorityFeeConfig: PriorityFeeConfig @@ -247,6 +295,9 @@ export class TransactionBuilder { } } +/** + * Send a set of transactions to the network + */ export async function sendTransactions( transactions: { tx: VersionedTransaction | Transaction;