From 7192f3d37e6cf65e144c8cd920fa20a71b2b1fe0 Mon Sep 17 00:00:00 2001 From: Callum McIntyre Date: Fri, 25 Aug 2023 19:21:33 +0100 Subject: [PATCH] refactor(experimental): add simulateTransaction RPC call (#1526) * refactor(experimental): add simulateTransaction RPC call * Change @default to @defaultValue See https://typedoc.org/tags/defaultValue/ --- ...response-patcher-allowed-numeric-values.ts | 1 + .../__tests__/simulate-transaction-test.ts | 829 ++++++++++++++++++ .../src/rpc-methods/getProgramAccounts.ts | 2 +- .../rpc-methods/getTokenAccountsByDelegate.ts | 2 +- .../rpc-methods/getTokenAccountsByOwner.ts | 2 +- packages/rpc-core/src/rpc-methods/index.ts | 4 +- .../src/rpc-methods/simulateTransaction.ts | 160 ++++ 7 files changed, 996 insertions(+), 4 deletions(-) create mode 100644 packages/rpc-core/src/rpc-methods/__tests__/simulate-transaction-test.ts create mode 100644 packages/rpc-core/src/rpc-methods/simulateTransaction.ts diff --git a/packages/rpc-core/src/response-patcher-allowed-numeric-values.ts b/packages/rpc-core/src/response-patcher-allowed-numeric-values.ts index f64db21204c7..14543bc955e7 100644 --- a/packages/rpc-core/src/response-patcher-allowed-numeric-values.ts +++ b/packages/rpc-core/src/response-patcher-allowed-numeric-values.ts @@ -149,6 +149,7 @@ export function getAllowedNumericKeypaths(): AllowedNumericKeypaths { ['current', KEYPATH_WILDCARD, 'commission'], ['delinquent', KEYPATH_WILDCARD, 'commission'], ], + simulateTransaction: jsonParsedAccountsConfigs.map(c => ['value', 'accounts', KEYPATH_WILDCARD, ...c]), }; } return memoizedKeypaths; diff --git a/packages/rpc-core/src/rpc-methods/__tests__/simulate-transaction-test.ts b/packages/rpc-core/src/rpc-methods/__tests__/simulate-transaction-test.ts new file mode 100644 index 000000000000..1db36b9a4306 --- /dev/null +++ b/packages/rpc-core/src/rpc-methods/__tests__/simulate-transaction-test.ts @@ -0,0 +1,829 @@ +import { base58, fixSerializer } from '@metaplex-foundation/umi-serializers'; +import { Base58EncodedAddress } from '@solana/addresses'; +import { createHttpTransport, createJsonRpc } from '@solana/rpc-transport'; +import { SolanaJsonRpcErrorCode } from '@solana/rpc-transport/dist/types/json-rpc-errors'; +import type { Rpc } from '@solana/rpc-transport/dist/types/json-rpc-types'; +import { Base64EncodedWireTransaction } from '@solana/transactions'; +import fetchMock from 'jest-fetch-mock-fork'; + +import { Base58EncodedBytes, Commitment } from '../common'; +import { createSolanaRpcApi, SolanaRpcMethods } from '../index'; + +function getMockTransactionMessage({ + blockhash, + feePayerAddressBytes, + memoString, + version = 0x80, // 0 + version mask +}: { + blockhash: string; + feePayerAddressBytes: Uint8Array; + memoString: string; + version?: number; +}) { + const blockhashBytes = fixSerializer(base58, 32).serialize(blockhash); + // prettier-ignore + return new Uint8Array([ + /** VERSION HEADER */ + version, + + /** MESSAGE HEADER */ + 0x01, // numSignerAccounts + 0x00, // numReadonlySignerAccount + 0x01, // numReadonlyNonSignerAccounts + + /** STATIC ADDRESSES */ + 0x02, // Number of static accounts + ...feePayerAddressBytes, + 0x05, 0x4a, 0x53, 0x5a, 0x99, 0x29, 0x21, 0x06, 0x4d, 0x24, 0xe8, 0x71, 0x60, 0xda, 0x38, 0x7c, 0x7c, 0x35, 0xb5, 0xdd, 0xbc, 0x92, 0xbb, 0x81, 0xe4, 0x1f, 0xa8, 0x40, 0x41, 0x05, 0x44, 0x8d, // MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr + + /** TRANSACTION LIFETIME TOKEN (ie. the blockhash) */ + ...blockhashBytes, + + /* INSTRUCTIONS */ + 0x01, // Number of instructions + + // First instruction + 0x01, // Program address index + 0x00, // Number of address indices + memoString.length, // Length of instruction data + ...new TextEncoder().encode(memoString), + + /** ADDRESS TABLE LOOKUPS */ + 0x00, // Number of address table lookups + ]); +} + +function getMockTransactionMessageWithAdditionalAccount({ + blockhash, + feePayerAddressBytes, + accountAddressBytes, + memoString, + version = 0x80, // 0 + version mask +}: { + blockhash: string; + feePayerAddressBytes: Uint8Array; + accountAddressBytes: Uint8Array; + memoString: string; + version?: number; +}) { + const blockhashBytes = fixSerializer(base58, 32).serialize(blockhash); + // prettier-ignore + return new Uint8Array([ + /** VERSION HEADER */ + version, + + /** MESSAGE HEADER */ + 0x01, // numSignerAccounts + 0x00, // numReadonlySignerAccount + 0x01, // numReadonlyNonSignerAccounts + + /** STATIC ADDRESSES */ + 0x03, // Number of static accounts + ...feePayerAddressBytes, + 0x05, 0x4a, 0x53, 0x5a, 0x99, 0x29, 0x21, 0x06, 0x4d, 0x24, 0xe8, 0x71, 0x60, 0xda, 0x38, 0x7c, 0x7c, 0x35, 0xb5, 0xdd, 0xbc, 0x92, 0xbb, 0x81, 0xe4, 0x1f, 0xa8, 0x40, 0x41, 0x05, 0x44, 0x8d, // MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr + ...accountAddressBytes, + + /** TRANSACTION LIFETIME TOKEN (ie. the blockhash) */ + ...blockhashBytes, + + /* INSTRUCTIONS */ + 0x01, // Number of instructions + + // First instruction + 0x01, // Program address index + 0x00, // Number of address indices + memoString.length, // Length of instruction data + ...new TextEncoder().encode(memoString), + + /** ADDRESS TABLE LOOKUPS */ + 0x00, // Number of address table lookups + ]); +} + +const MOCK_PKCS8_PRIVATE_KEY = + // prettier-ignore + new Uint8Array([ + /** + * PKCS#8 header + */ + 0x30, // ASN.1 sequence tag + 0x2e, // Length of sequence (46 more bytes) + + 0x02, // ASN.1 integer tag + 0x01, // Length of integer + 0x00, // Version number + + 0x30, // ASN.1 sequence tag + 0x05, // Length of sequence + 0x06, // ASN.1 object identifier tag + 0x03, // Length of object identifier + // Edwards curve algorithms identifier https://oid-rep.orange-labs.fr/get/1.3.101.112 + 0x2b, // iso(1) / identified-organization(3) (The first node is multiplied by the decimal 40 and the result is added to the value of the second node) + 0x65, // thawte(101) + // Ed25519 identifier + 0x70, // id-Ed25519(112) + + /** + * Private key payload + */ + 0x04, // ASN.1 octet string tag + 0x22, // String length (34 more bytes) + + // Private key bytes as octet string + 0x04, // ASN.1 octet string tag + 0x20, // String length (32 bytes) + 16, 192, 67, 187, 170, 210, 152, 95, + 180, 204, 123, 21, 81, 45, 171, 85, + 188, 91, 164, 34, 8, 0, 244, 56, + 209, 190, 255, 201, 212, 94, 45, 186, + ]); +// See scripts/fixtures/send-transaction-fee-payer.json +const MOCK_PUBLIC_KEY_BYTES = // DRtXHDgC312wpNdNCSb8vCoXDcofCJcPHdAw4VkJ8L9i + // prettier-ignore + new Uint8Array([ + 0xb8, 0xac, 0x70, 0x4f, 0xaf, 0xc7, 0xa5, 0xfc, 0x8c, 0x5d, 0x1f, 0x0a, 0xc8, 0xcf, 0xaa, 0xe0, + 0x42, 0xfa, 0x3b, 0xb8, 0x25, 0xf0, 0xec, 0xfc, 0xe2, 0x27, 0x4d, 0x7d, 0xad, 0xad, 0x51, 0x2d, + ]); + +async function getSecretKey() { + return await crypto.subtle.importKey('pkcs8', MOCK_PKCS8_PRIVATE_KEY, 'Ed25519', /* extractable */ false, ['sign']); +} + +describe('simulateTransaction', () => { + let rpc: Rpc; + beforeEach(() => { + fetchMock.resetMocks(); + fetchMock.dontMock(); + rpc = createJsonRpc({ + api: createSolanaRpcApi(), + transport: createHttpTransport({ url: 'http://127.0.0.1:8899' }), + }); + }); + + (['confirmed', 'finalized', 'processed'] as Commitment[]).forEach(commitment => { + describe(`when called with \`${commitment}\` preflight commitment`, () => { + if (commitment === 'finalized') { + it.todo( + 'returns the transaction information (test broken; see https://discord.com/channels/428295358100013066/560496939779620864/1132048104728825926)' + ); + return; + } + it('returns the transaction information', async () => { + expect.assertions(1); + const [secretKey, { value: latestBlockhash }] = await Promise.all([ + getSecretKey(), + rpc.getLatestBlockhash().send(), + ]); + const message = getMockTransactionMessage({ + blockhash: latestBlockhash.blockhash, + feePayerAddressBytes: MOCK_PUBLIC_KEY_BYTES, + memoString: `Hello from the web3.js tests! [${performance.now()}]`, + }); + const signature = new Uint8Array(await crypto.subtle.sign('Ed25519', secretKey, message)); + const resultPromise = rpc + .simulateTransaction( + Buffer.from( + new Uint8Array([ + 0x01, // Length of signatures + ...signature, + ...message, + ]) + ).toString('base64') as Base64EncodedWireTransaction, + { commitment, encoding: 'base64' } + ) + .send(); + + await expect(resultPromise).resolves.toMatchObject({ + value: expect.objectContaining({ + accounts: null, + err: null, + logs: expect.any(Array), + returnData: null, + unitsConsumed: expect.any(BigInt), + }), + }); + }); + }); + }); + + it('throws when called with a `minContextSlot` higher than the highest slot available', async () => { + expect.assertions(1); + const [secretKey, { value: latestBlockhash }] = await Promise.all([ + getSecretKey(), + rpc.getLatestBlockhash().send(), + ]); + const message = getMockTransactionMessage({ + blockhash: latestBlockhash.blockhash, + feePayerAddressBytes: MOCK_PUBLIC_KEY_BYTES, + memoString: `Hello from the web3.js tests! [${performance.now()}]`, + }); + const signature = new Uint8Array(await crypto.subtle.sign('Ed25519', secretKey, message)); + const resultPromise = rpc + .simulateTransaction( + Buffer.from( + new Uint8Array([ + 0x01, // Length of signatures + ...signature, + ...message, + ]) + ).toString('base64') as Base64EncodedWireTransaction, + { + commitment: 'processed', + encoding: 'base64', + minContextSlot: 2n ** 63n - 1n, // u64:MAX; safe bet it'll be too high. + } + ) + .send(); + await expect(resultPromise).rejects.toMatchObject({ + code: -32016 satisfies (typeof SolanaJsonRpcErrorCode)['JSON_RPC_SERVER_ERROR_MIN_CONTEXT_SLOT_NOT_REACHED'], + message: expect.any(String), + name: 'SolanaJsonRpcError', + }); + }); + + it('throws when called with an invalid signature if `sigVerify` is true', async () => { + expect.assertions(1); + const { value: latestBlockhash } = await rpc.getLatestBlockhash().send(); + const message = getMockTransactionMessage({ + blockhash: latestBlockhash.blockhash, + feePayerAddressBytes: MOCK_PUBLIC_KEY_BYTES, + memoString: `Hello from the web3.js tests! [${performance.now()}]`, + }); + const signature = new Uint8Array(Array(64).fill(0)); + const resultPromise = rpc + .simulateTransaction( + Buffer.from( + new Uint8Array([ + 0x01, // Length of signatures + ...signature, + ...message, + ]) + ).toString('base64') as Base64EncodedWireTransaction, + { + commitment: 'processed', + encoding: 'base64', + sigVerify: true, + } + ) + .send(); + + await expect(resultPromise).rejects.toMatchObject({ + code: -32003 satisfies (typeof SolanaJsonRpcErrorCode)['JSON_RPC_SERVER_ERROR_TRANSACTION_SIGNATURE_VERIFICATION_FAILURE'], + message: expect.stringContaining('Transaction signature verification failure'), + name: 'SolanaJsonRpcError', + }); + }); + + it('does not throw when called with an invalid signature when `sigVerify` is false', async () => { + expect.assertions(1); + const { value: latestBlockhash } = await rpc.getLatestBlockhash().send(); + const message = getMockTransactionMessage({ + blockhash: latestBlockhash.blockhash, + feePayerAddressBytes: MOCK_PUBLIC_KEY_BYTES, + memoString: `Hello from the web3.js tests! [${performance.now()}]`, + }); + const signature = new Uint8Array(Array(64).fill(0)); + const resultPromise = rpc + .simulateTransaction( + Buffer.from( + new Uint8Array([ + 0x01, // Length of signatures + ...signature, + ...message, + ]) + ).toString('base64') as Base64EncodedWireTransaction, + { + commitment: 'processed', + encoding: 'base64', + sigVerify: false, + } + ) + .send(); + + await expect(resultPromise).resolves.toMatchObject({ + value: expect.objectContaining({ + accounts: null, + err: null, + logs: expect.any(Array), + returnData: null, + unitsConsumed: expect.any(BigInt), + }), + }); + }); + + it('returns a BlockhashNotFound error when the blockhash does not exist when `replaceRecentBlockhash` is false', async () => { + expect.assertions(1); + const secretKey = await getSecretKey(); + const message = getMockTransactionMessage({ + blockhash: base58.deserialize(new Uint8Array(Array(32).fill(0)))[0], + feePayerAddressBytes: MOCK_PUBLIC_KEY_BYTES, + memoString: `Hello from the web3.js tests! [${performance.now()}]`, + }); + const signature = new Uint8Array(await crypto.subtle.sign('Ed25519', secretKey, message)); + const resultPromise = rpc + .simulateTransaction( + Buffer.from( + new Uint8Array([ + 0x01, // Length of signatures + ...signature, + ...message, + ]) + ).toString('base64') as Base64EncodedWireTransaction, + { + commitment: 'processed', + encoding: 'base64', + replaceRecentBlockhash: false, + } + ) + .send(); + + await expect(resultPromise).resolves.toMatchObject({ + value: expect.objectContaining({ + accounts: null, + err: 'BlockhashNotFound', + logs: expect.any(Array), + returnData: null, + unitsConsumed: expect.any(BigInt), + }), + }); + }); + + it('replaces the invalid blockhash when `replaceRecentBlockhash` is true', async () => { + expect.assertions(1); + const secretKey = await getSecretKey(); + const message = getMockTransactionMessage({ + blockhash: base58.deserialize(new Uint8Array(Array(32).fill(0)))[0], + feePayerAddressBytes: MOCK_PUBLIC_KEY_BYTES, + memoString: `Hello from the web3.js tests! [${performance.now()}]`, + }); + const signature = new Uint8Array(await crypto.subtle.sign('Ed25519', secretKey, message)); + const resultPromise = rpc + .simulateTransaction( + Buffer.from( + new Uint8Array([ + 0x01, // Length of signatures + ...signature, + ...message, + ]) + ).toString('base64') as Base64EncodedWireTransaction, + { + commitment: 'processed', + encoding: 'base64', + replaceRecentBlockhash: true, + } + ) + .send(); + + await expect(resultPromise).resolves.toMatchObject({ + value: expect.objectContaining({ + accounts: null, + err: null, + logs: expect.any(Array), + returnData: null, + unitsConsumed: expect.any(BigInt), + }), + }); + }); + + it('throws when called with a transaction having an unsupported version', async () => { + expect.assertions(1); + const [secretKey, { value: latestBlockhash }] = await Promise.all([ + getSecretKey(), + rpc.getLatestBlockhash().send(), + ]); + const message = getMockTransactionMessage({ + blockhash: latestBlockhash.blockhash, + feePayerAddressBytes: MOCK_PUBLIC_KEY_BYTES, + memoString: `Hello from the web3.js tests! [${performance.now()}]`, + version: 0xfe, // Version 126 + }); + const signature = new Uint8Array(await crypto.subtle.sign('Ed25519', secretKey, message)); + const resultPromise = rpc + .simulateTransaction( + Buffer.from( + new Uint8Array([ + 0x01, // Length of signatures + ...signature, + ...message, + ]) + ).toString('base64') as Base64EncodedWireTransaction, + { commitment: 'processed', encoding: 'base64' } + ) + .send(); + + await expect(resultPromise).rejects.toMatchObject({ + code: -32602 satisfies (typeof SolanaJsonRpcErrorCode)['JSON_RPC_INVALID_PARAMS'], + message: expect.stringContaining('invalid value: integer `126`, expected supported versions: [0]'), + name: 'SolanaJsonRpcError', + }); + }); + + it('throws when called with a malformed transaction message', async () => { + expect.assertions(1); + const secretKey = await getSecretKey(); + const message = new Uint8Array([4, 5, 6]); + const signature = new Uint8Array(await crypto.subtle.sign('Ed25519', secretKey, message)); + const resultPromise = rpc + .simulateTransaction( + Buffer.from( + new Uint8Array([ + 0x01, // Length of signatures + ...signature, + ...message, + ]) + ).toString('base64') as Base64EncodedWireTransaction, + { commitment: 'processed', encoding: 'base64' } + ) + .send(); + + await expect(resultPromise).rejects.toMatchObject({ + code: -32602 satisfies (typeof SolanaJsonRpcErrorCode)['JSON_RPC_INVALID_PARAMS'], + message: expect.stringContaining('failed to fill whole buffer'), + name: 'SolanaJsonRpcError', + }); + }); + + it('returns an AccountNotFound error when the fee payer is an unknown account', async () => { + expect.assertions(1); + const [[secretKey, publicKeyBytes], { value: latestBlockhash }] = await Promise.all([ + (async () => { + const keyPair = (await crypto.subtle.generateKey('Ed25519', /* extractable */ false, [ + 'sign', + 'verify', + ])) as CryptoKeyPair; + return [keyPair.privateKey, new Uint8Array(await crypto.subtle.exportKey('raw', keyPair.publicKey))]; + })(), + rpc.getLatestBlockhash().send(), + ]); + const message = getMockTransactionMessage({ + blockhash: latestBlockhash.blockhash, + feePayerAddressBytes: publicKeyBytes, + memoString: `Hello from the web3.js tests! [${performance.now()}]`, + }); + const signature = new Uint8Array(await crypto.subtle.sign('Ed25519', secretKey, message)); + const resultPromise = rpc + .simulateTransaction( + Buffer.from( + new Uint8Array([ + 0x01, // Length of signatures + ...signature, + ...message, + ]) + ).toString('base64') as Base64EncodedWireTransaction, + { commitment: 'processed', encoding: 'base64' } + ) + .send(); + + await expect(resultPromise).resolves.toMatchObject({ + value: expect.objectContaining({ + accounts: null, + err: 'AccountNotFound', + logs: expect.any(Array), + returnData: null, + unitsConsumed: expect.any(BigInt), + }), + }); + }); + + it('returns account data for a transaction account with base64 encoding', async () => { + expect.assertions(1); + const [secretKey, { value: latestBlockhash }] = await Promise.all([ + getSecretKey(), + rpc.getLatestBlockhash().send(), + ]); + const message = getMockTransactionMessage({ + blockhash: latestBlockhash.blockhash, + feePayerAddressBytes: MOCK_PUBLIC_KEY_BYTES, + memoString: `Hello from the web3.js tests! [${performance.now()}]`, + }); + const signature = new Uint8Array(await crypto.subtle.sign('Ed25519', secretKey, message)); + const resultPromise = rpc + .simulateTransaction( + Buffer.from( + new Uint8Array([ + 0x01, // Length of signatures + ...signature, + ...message, + ]) + ).toString('base64') as Base64EncodedWireTransaction, + { + accounts: { + addresses: ['DRtXHDgC312wpNdNCSb8vCoXDcofCJcPHdAw4VkJ8L9i' as Base58EncodedAddress], + encoding: 'base64', + }, + commitment: 'processed', + encoding: 'base64', + } + ) + .send(); + + await expect(resultPromise).resolves.toMatchObject({ + value: expect.objectContaining({ + accounts: [ + expect.objectContaining({ + data: ['', 'base64'], + }), + ], + err: null, + logs: expect.any(Array), + returnData: null, + unitsConsumed: expect.any(BigInt), + }), + }); + }); + + it('returns account data for a transaction account with base64+zstd encoding', async () => { + expect.assertions(1); + const [secretKey, { value: latestBlockhash }] = await Promise.all([ + getSecretKey(), + rpc.getLatestBlockhash().send(), + ]); + const message = getMockTransactionMessage({ + blockhash: latestBlockhash.blockhash, + feePayerAddressBytes: MOCK_PUBLIC_KEY_BYTES, + memoString: `Hello from the web3.js tests! [${performance.now()}]`, + }); + const signature = new Uint8Array(await crypto.subtle.sign('Ed25519', secretKey, message)); + const resultPromise = rpc + .simulateTransaction( + Buffer.from( + new Uint8Array([ + 0x01, // Length of signatures + ...signature, + ...message, + ]) + ).toString('base64') as Base64EncodedWireTransaction, + { + accounts: { + addresses: ['DRtXHDgC312wpNdNCSb8vCoXDcofCJcPHdAw4VkJ8L9i' as Base58EncodedAddress], + encoding: 'base64+zstd', + }, + commitment: 'processed', + encoding: 'base64', + } + ) + .send(); + + await expect(resultPromise).resolves.toMatchObject({ + value: expect.objectContaining({ + accounts: [ + expect.objectContaining({ + data: [expect.any(String), 'base64+zstd'], + }), + ], + err: null, + logs: expect.any(Array), + returnData: null, + unitsConsumed: expect.any(BigInt), + }), + }); + }); + + it('returns account data for a transaction account with jsonParsed encoding', async () => { + expect.assertions(1); + const [secretKey, { value: latestBlockhash }] = await Promise.all([ + getSecretKey(), + rpc.getLatestBlockhash().send(), + ]); + const message = getMockTransactionMessageWithAdditionalAccount({ + accountAddressBytes: base58.serialize('4QUZQ4c7bZuJ4o4L8tYAEGnePFV27SUFEVmC7BYfsXRp'), // see scripts/fixtures/vote-account.json + blockhash: latestBlockhash.blockhash, + feePayerAddressBytes: MOCK_PUBLIC_KEY_BYTES, + memoString: `Hello from the web3.js tests! [${performance.now()}]`, + }); + const signature = new Uint8Array(await crypto.subtle.sign('Ed25519', secretKey, message)); + const resultPromise = rpc + .simulateTransaction( + Buffer.from( + new Uint8Array([ + 0x01, // Length of signatures + ...signature, + ...message, + ]) + ).toString('base64') as Base64EncodedWireTransaction, + { + accounts: { + addresses: ['4QUZQ4c7bZuJ4o4L8tYAEGnePFV27SUFEVmC7BYfsXRp' as Base58EncodedAddress], + encoding: 'jsonParsed', + }, + commitment: 'processed', + encoding: 'base64', + } + ) + .send(); + + await expect(resultPromise).resolves.toMatchObject({ + value: expect.objectContaining({ + accounts: [ + expect.objectContaining({ + data: expect.objectContaining({ + parsed: expect.objectContaining({ + info: { + authorizedVoters: expect.any(Array), + authorizedWithdrawer: expect.any(String), + commission: expect.any(Number), + epochCredits: expect.any(Array), + lastTimestamp: expect.any(Object), + nodePubkey: expect.any(String), + priorVoters: expect.any(Array), + rootSlot: expect.any(BigInt), + votes: expect.any(Array), + }, + type: 'vote', + }), + program: 'vote', + }), + }), + ], + err: null, + logs: expect.any(Array), + returnData: null, + unitsConsumed: expect.any(BigInt), + }), + }); + }); + + it('returns account data for a transaction account with jsonParsed encoding (fallback to base64)', async () => { + expect.assertions(1); + const [secretKey, { value: latestBlockhash }] = await Promise.all([ + getSecretKey(), + rpc.getLatestBlockhash().send(), + ]); + const message = getMockTransactionMessage({ + blockhash: latestBlockhash.blockhash, + feePayerAddressBytes: MOCK_PUBLIC_KEY_BYTES, + memoString: `Hello from the web3.js tests! [${performance.now()}]`, + }); + const signature = new Uint8Array(await crypto.subtle.sign('Ed25519', secretKey, message)); + const resultPromise = rpc + .simulateTransaction( + Buffer.from( + new Uint8Array([ + 0x01, // Length of signatures + ...signature, + ...message, + ]) + ).toString('base64') as Base64EncodedWireTransaction, + { + accounts: { + addresses: ['DRtXHDgC312wpNdNCSb8vCoXDcofCJcPHdAw4VkJ8L9i' as Base58EncodedAddress], + encoding: 'jsonParsed', + }, + commitment: 'processed', + encoding: 'base64', + } + ) + .send(); + + await expect(resultPromise).resolves.toMatchObject({ + value: expect.objectContaining({ + accounts: [ + expect.objectContaining({ + // falls back to base64 + data: ['', 'base64'], + }), + ], + err: null, + logs: expect.any(Array), + returnData: null, + unitsConsumed: expect.any(BigInt), + }), + }); + }); + + it('returns account data for a transaction account with base64 encoding when encoding is not specified', async () => { + expect.assertions(1); + const [secretKey, { value: latestBlockhash }] = await Promise.all([ + getSecretKey(), + rpc.getLatestBlockhash().send(), + ]); + const message = getMockTransactionMessage({ + blockhash: latestBlockhash.blockhash, + feePayerAddressBytes: MOCK_PUBLIC_KEY_BYTES, + memoString: `Hello from the web3.js tests! [${performance.now()}]`, + }); + const signature = new Uint8Array(await crypto.subtle.sign('Ed25519', secretKey, message)); + const resultPromise = rpc + .simulateTransaction( + Buffer.from( + new Uint8Array([ + 0x01, // Length of signatures + ...signature, + ...message, + ]) + ).toString('base64') as Base64EncodedWireTransaction, + { + accounts: { + addresses: ['DRtXHDgC312wpNdNCSb8vCoXDcofCJcPHdAw4VkJ8L9i' as Base58EncodedAddress], + }, + commitment: 'processed', + encoding: 'base64', + } + ) + .send(); + + await expect(resultPromise).resolves.toMatchObject({ + value: expect.objectContaining({ + accounts: [ + expect.objectContaining({ + data: ['', 'base64'], + }), + ], + err: null, + logs: expect.any(Array), + returnData: null, + unitsConsumed: expect.any(BigInt), + }), + }); + }); + + it('returns null array entries for accounts that are not part of the transaction', async () => { + expect.assertions(1); + const [secretKey, { value: latestBlockhash }] = await Promise.all([ + getSecretKey(), + rpc.getLatestBlockhash().send(), + ]); + const message = getMockTransactionMessage({ + blockhash: latestBlockhash.blockhash, + feePayerAddressBytes: MOCK_PUBLIC_KEY_BYTES, + memoString: `Hello from the web3.js tests! [${performance.now()}]`, + }); + const signature = new Uint8Array(await crypto.subtle.sign('Ed25519', secretKey, message)); + const resultPromise = rpc + .simulateTransaction( + Buffer.from( + new Uint8Array([ + 0x01, // Length of signatures + ...signature, + ...message, + ]) + ).toString('base64') as Base64EncodedWireTransaction, + { + accounts: { + addresses: [ + // Randomly generated + 'CsJGwBvZrmMheK7cgMXh7ZHmKLL5w76X7pmofUG3cUWB' as Base58EncodedAddress, + 'DRtXHDgC312wpNdNCSb8vCoXDcofCJcPHdAw4VkJ8L9i' as Base58EncodedAddress, + ], + encoding: 'base64', + }, + commitment: 'processed', + encoding: 'base64', + } + ) + .send(); + + await expect(resultPromise).resolves.toMatchObject({ + value: expect.objectContaining({ + accounts: [ + null, + expect.objectContaining({ + data: ['', 'base64'], + }), + ], + err: null, + logs: expect.any(Array), + returnData: null, + unitsConsumed: expect.any(BigInt), + }), + }); + }); + + it('returns transaction information for a base58 encoded transaction', async () => { + expect.assertions(1); + const [secretKey, { value: latestBlockhash }] = await Promise.all([ + getSecretKey(), + rpc.getLatestBlockhash().send(), + ]); + const message = getMockTransactionMessage({ + blockhash: latestBlockhash.blockhash, + feePayerAddressBytes: MOCK_PUBLIC_KEY_BYTES, + memoString: `Hello from the web3.js tests! [${performance.now()}]`, + }); + const signature = new Uint8Array(await crypto.subtle.sign('Ed25519', secretKey, message)); + const [base58WireTransaction] = base58.deserialize( + Buffer.from( + new Uint8Array([ + 0x01, // Length of signatures + ...signature, + ...message, + ]) + ) + ); + const resultPromise = rpc + .simulateTransaction(base58WireTransaction as Base58EncodedBytes, { + commitment: 'processed', + }) + .send(); + + await expect(resultPromise).resolves.toMatchObject({ + value: expect.objectContaining({ + accounts: null, + err: null, + logs: expect.any(Array), + returnData: null, + unitsConsumed: expect.any(BigInt), + }), + }); + }); +}); diff --git a/packages/rpc-core/src/rpc-methods/getProgramAccounts.ts b/packages/rpc-core/src/rpc-methods/getProgramAccounts.ts index 53c4fdcc3657..cfd5f9eceb3d 100644 --- a/packages/rpc-core/src/rpc-methods/getProgramAccounts.ts +++ b/packages/rpc-core/src/rpc-methods/getProgramAccounts.ts @@ -26,7 +26,7 @@ type GetProgramAccountsDatasizeFilter = Readonly<{ }>; type GetProgramAccountsApiCommonConfig = Readonly<{ - /** @default "finalized" */ + /** @defaultValue "finalized" */ commitment?: Commitment; /** The minimum slot that the request can be evaluated at */ minContextSlot?: Slot; diff --git a/packages/rpc-core/src/rpc-methods/getTokenAccountsByDelegate.ts b/packages/rpc-core/src/rpc-methods/getTokenAccountsByDelegate.ts index 9a67a5f6d21c..eea1601237b7 100644 --- a/packages/rpc-core/src/rpc-methods/getTokenAccountsByDelegate.ts +++ b/packages/rpc-core/src/rpc-methods/getTokenAccountsByDelegate.ts @@ -40,7 +40,7 @@ type ProgramIdFilter = Readonly<{ type AccountsFilter = MintFilter | ProgramIdFilter; type GetTokenAccountsByDelegateApiCommonConfig = Readonly<{ - /** @default "finalized" */ + /** @defaultValue "finalized" */ commitment?: Commitment; /** The minimum slot that the request can be evaluated at */ minContextSlot?: Slot; diff --git a/packages/rpc-core/src/rpc-methods/getTokenAccountsByOwner.ts b/packages/rpc-core/src/rpc-methods/getTokenAccountsByOwner.ts index ca562f3847e6..c244d37a16b8 100644 --- a/packages/rpc-core/src/rpc-methods/getTokenAccountsByOwner.ts +++ b/packages/rpc-core/src/rpc-methods/getTokenAccountsByOwner.ts @@ -40,7 +40,7 @@ type ProgramIdFilter = Readonly<{ type AccountsFilter = MintFilter | ProgramIdFilter; type GetTokenAccountsByOwnerApiCommonConfig = Readonly<{ - /** @default "finalized" */ + /** @defaultValue "finalized" */ commitment?: Commitment; /** The minimum slot that the request can be evaluated at */ minContextSlot?: Slot; diff --git a/packages/rpc-core/src/rpc-methods/index.ts b/packages/rpc-core/src/rpc-methods/index.ts index a88d451c8cce..4b55675189f0 100644 --- a/packages/rpc-core/src/rpc-methods/index.ts +++ b/packages/rpc-core/src/rpc-methods/index.ts @@ -53,6 +53,7 @@ import { IsBlockhashValidApi } from './isBlockhashValid'; import { MinimumLedgerSlotApi } from './minimumLedgerSlot'; import { RequestAirdropApi } from './requestAirdrop'; import { SendTransactionApi } from './sendTransaction'; +import { SimulateTransactionApi } from './simulateTransaction'; type Config = Readonly<{ onIntegerOverflow?: (methodName: string, keyPath: (number | string)[], value: bigint) => void; @@ -108,7 +109,8 @@ export type SolanaRpcMethods = GetAccountInfoApi & IsBlockhashValidApi & MinimumLedgerSlotApi & RequestAirdropApi & - SendTransactionApi; + SendTransactionApi & + SimulateTransactionApi; export type { Commitment } from './common'; diff --git a/packages/rpc-core/src/rpc-methods/simulateTransaction.ts b/packages/rpc-core/src/rpc-methods/simulateTransaction.ts new file mode 100644 index 000000000000..e79e9507722f --- /dev/null +++ b/packages/rpc-core/src/rpc-methods/simulateTransaction.ts @@ -0,0 +1,160 @@ +import { Base58EncodedAddress } from '@solana/addresses'; +import { Base64EncodedWireTransaction } from '@solana/transactions'; + +import { TransactionError } from '../transaction-error'; +import { + AccountInfoBase, + AccountInfoWithBase64EncodedData, + AccountInfoWithBase64EncodedZStdCompressedData, + AccountInfoWithJsonData, + Base58EncodedBytes, + Base64EncodedDataResponse, + Commitment, + Slot, + U64UnsafeBeyond2Pow53Minus1, +} from './common'; + +type SimulateTransactionConfigBase = Readonly<{ + /** + * Commitment level to simulate the transaction at + * @defaultValue finalized + * */ + commitment?: Commitment; + /** The minimum slot that the request can be evaluated at */ + minContextSlot?: Slot; +}>; + +// Both are optional booleans, but conflict - so cannot both be true +type SigVerifyAndReplaceRecentBlockhashConfig = + | Readonly<{ + /** if `true` the transaction signatures will be verified (conflicts with `replaceRecentBlockhash`) */ + sigVerify: true; + /** if `true` the transaction recent blockhash will be replaced with the most recent blockhash. (conflicts with `sigVerify`) */ + replaceRecentBlockhash?: false; + }> + | Readonly<{ + /** if `true` the transaction recent blockhash will be replaced with the most recent blockhash. (conflicts with `sigVerify`) */ + replaceRecentBlockhash: true; + /** if `true` the transaction signatures will be verified (conflicts with `replaceRecentBlockhash`) */ + sigVerify?: false; + }> + | Readonly<{ + /** if `true` the transaction signatures will be verified (conflicts with `replaceRecentBlockhash`) */ + sigVerify?: false; + /** if `true` the transaction recent blockhash will be replaced with the most recent blockhash. (conflicts with `sigVerify`) */ + replaceRecentBlockhash?: false; + }>; + +type AccountsConfigWithBase64EncodingZstdCompression = Readonly<{ + accounts: { + /** An `array` of accounts to return */ + addresses: Base58EncodedAddress[]; + /** Encoding for returned Account data */ + encoding: 'base64+zstd'; + }; +}>; + +type AccountsConfigWithJsonParsedEncoding = Readonly<{ + accounts: { + /** An `array` of accounts to return */ + addresses: Base58EncodedAddress[]; + /** Encoding for returned Account data */ + encoding: 'jsonParsed'; + }; +}>; + +type AccountsConfigWithBase64Encoding = Readonly<{ + accounts: { + /** An `array` of accounts to return */ + addresses: Base58EncodedAddress[]; + // Optional because this is the default encoding + /** Encoding for returned Account data */ + encoding?: 'base64'; + }; +}>; + +type SimulateTransactionApiResponseBase = Readonly<{ + /** Error if transaction failed, null if transaction succeeded. */ + err: TransactionError | null; + /** Array of log messages the transaction instructions output during execution, null if simulation failed before the transaction was able to execute (for example due to an invalid blockhash or signature verification failure) */ + logs: string[] | null; + /** The number of compute budget units consumed during the processing of this transaction */ + unitsConsumed?: U64UnsafeBeyond2Pow53Minus1; + /** The most-recent return data generated by an instruction in the transaction */ + returnData: Readonly<{ + /** The program that generated the return data */ + programId: Base58EncodedAddress; + /** The return data itself, as base-64 encoded binary data */ + data: Base64EncodedDataResponse; + }> | null; +}>; + +type SimulateTransactionApiResponseWithAccounts = Readonly<{ + /** Array of accounts with the same length as the `accounts.addresses` array in the request */ + accounts: (T | null)[]; +}>; + +export interface SimulateTransactionApi { + /** @deprecated Set `encoding` to `'base64'` when calling this method */ + simulateTransaction( + base58EncodedWireTransaction: Base58EncodedBytes, + config: SimulateTransactionConfigBase & + SigVerifyAndReplaceRecentBlockhashConfig & + AccountsConfigWithBase64Encoding + ): SimulateTransactionApiResponseBase & + SimulateTransactionApiResponseWithAccounts; + + /** @deprecated Set `encoding` to `'base64'` when calling this method */ + simulateTransaction( + base58EncodedWireTransaction: Base58EncodedBytes, + config: SimulateTransactionConfigBase & + SigVerifyAndReplaceRecentBlockhashConfig & + AccountsConfigWithBase64EncodingZstdCompression + ): SimulateTransactionApiResponseBase & + SimulateTransactionApiResponseWithAccounts; + + /** @deprecated Set `encoding` to `'base64'` when calling this method */ + simulateTransaction( + base58EncodedWireTransaction: Base58EncodedBytes, + config: SimulateTransactionConfigBase & + SigVerifyAndReplaceRecentBlockhashConfig & + AccountsConfigWithJsonParsedEncoding + ): SimulateTransactionApiResponseBase & + SimulateTransactionApiResponseWithAccounts; + + /** @deprecated Set `encoding` to `'base64'` when calling this method */ + simulateTransaction( + base58EncodedWireTransaction: Base58EncodedBytes, + config?: SimulateTransactionConfigBase & SigVerifyAndReplaceRecentBlockhashConfig + ): SimulateTransactionApiResponseBase & { accounts: null }; + + /** Simulate sending a transaction */ + simulateTransaction( + base64EncodedWireTransaction: Base64EncodedWireTransaction, + config: SimulateTransactionConfigBase & { encoding: 'base64' } & SigVerifyAndReplaceRecentBlockhashConfig & + AccountsConfigWithBase64Encoding + ): SimulateTransactionApiResponseBase & + SimulateTransactionApiResponseWithAccounts; + + /** Simulate sending a transaction */ + simulateTransaction( + base64EncodedWireTransaction: Base64EncodedWireTransaction, + config: SimulateTransactionConfigBase & { encoding: 'base64' } & SigVerifyAndReplaceRecentBlockhashConfig & + AccountsConfigWithBase64EncodingZstdCompression + ): SimulateTransactionApiResponseBase & + SimulateTransactionApiResponseWithAccounts; + + /** Simulate sending a transaction */ + simulateTransaction( + base64EncodedWireTransaction: Base64EncodedWireTransaction, + config: SimulateTransactionConfigBase & { encoding: 'base64' } & SigVerifyAndReplaceRecentBlockhashConfig & + AccountsConfigWithJsonParsedEncoding + ): SimulateTransactionApiResponseBase & + SimulateTransactionApiResponseWithAccounts; + + /** Simulate sending a transaction */ + simulateTransaction( + base64EncodedWireTransaction: Base64EncodedWireTransaction, + config: SimulateTransactionConfigBase & { encoding: 'base64' } & SigVerifyAndReplaceRecentBlockhashConfig + ): SimulateTransactionApiResponseBase & { accounts: null }; +}