From 63d0c78b20c9feedf70c46bf2bcda6ee461fe1cf Mon Sep 17 00:00:00 2001 From: Josh Date: Mon, 22 Mar 2021 10:22:59 -0700 Subject: [PATCH] web3.js: add support for batch getParsedConfirmedTransactions (#16001) * feat: add support for batch requests * feat: get confirmed transactions batch * feat: test get parsed confirmed transactions * fix: run prettier * fix: test uses one signature * fix: fix docs and return type on ParsedConfirmedTransactions * fix: null values in test --- web3.js/src/connection.ts | 69 ++++++++- web3.js/test/connection.test.ts | 266 ++++++++++++++++++++++++++++++++ web3.js/test/mocks/rpc-http.ts | 36 ++++- 3 files changed, 367 insertions(+), 4 deletions(-) diff --git a/web3.js/src/connection.ts b/web3.js/src/connection.ts index 99c5d1ab0f..b289392a36 100644 --- a/web3.js/src/connection.ts +++ b/web3.js/src/connection.ts @@ -63,6 +63,16 @@ export const BLOCKHASH_CACHE_TIMEOUT_MS = 30 * 1000; type RpcRequest = (methodName: string, args: Array) => any; +type RpcBatchRequest = (requests: RpcParams[]) => any; + +/** + * @internal + */ +export type RpcParams = { + methodName: string; + args: Array; +}; + export type TokenAccountsFilter = | { mint: PublicKey; @@ -642,7 +652,7 @@ export type PerfSample = { samplePeriodSecs: number; }; -function createRpcRequest(url: string, useHttps: boolean): RpcRequest { +function createRpcClient(url: string, useHttps: boolean): RpcClient { let agentManager: AgentManager | undefined; if (!process.env.BROWSER) { agentManager = new AgentManager(useHttps); @@ -692,9 +702,31 @@ function createRpcRequest(url: string, useHttps: boolean): RpcRequest { } }, {}); + return clientBrowser; +} + +function createRpcRequest(client: RpcClient): RpcRequest { return (method, args) => { return new Promise((resolve, reject) => { - clientBrowser.request(method, args, (err: any, response: any) => { + client.request(method, args, (err: any, response: any) => { + if (err) { + reject(err); + return; + } + resolve(response); + }); + }); + }; +} + +function createRpcBatchRequest(client: RpcClient): RpcBatchRequest { + return (requests: RpcParams[]) => { + return new Promise((resolve, reject) => { + const batch = requests.map((params: RpcParams) => { + return client.request(params.methodName, params.args); + }); + + client.request(batch, (err: any, response: any) => { if (err) { reject(err); return; @@ -1591,7 +1623,9 @@ export type ConfirmedSignatureInfo = { export class Connection { /** @internal */ _commitment?: Commitment; /** @internal */ _rpcEndpoint: string; + /** @internal */ _rpcClient: RpcClient; /** @internal */ _rpcRequest: RpcRequest; + /** @internal */ _rpcBatchRequest: RpcBatchRequest; /** @internal */ _rpcWebSocket: RpcWebSocketClient; /** @internal */ _rpcWebSocketConnected: boolean = false; /** @internal */ _rpcWebSocketHeartbeat: ReturnType< @@ -1647,7 +1681,9 @@ export class Connection { let url = urlParse(endpoint); const useHttps = url.protocol === 'https:'; - this._rpcRequest = createRpcRequest(url.href, useHttps); + this._rpcClient = createRpcClient(url.href, useHttps); + this._rpcRequest = createRpcRequest(this._rpcClient); + this._rpcBatchRequest = createRpcBatchRequest(this._rpcClient); this._commitment = commitment; this._blockhashInfo = { recentBlockhash: null, @@ -2503,6 +2539,33 @@ export class Connection { return res.result; } + /** + * Fetch parsed transaction details for a batch of confirmed transactions + */ + async getParsedConfirmedTransactions( + signatures: TransactionSignature[], + ): Promise<(ParsedConfirmedTransaction | null)[]> { + const batch = signatures.map(signature => { + return { + methodName: 'getConfirmedTransaction', + args: [signature, 'jsonParsed'], + }; + }); + + const unsafeRes = await this._rpcBatchRequest(batch); + const res = unsafeRes.map((unsafeRes: any) => { + const res = create(unsafeRes, GetParsedConfirmedTransactionRpcResult); + if ('error' in res) { + throw new Error( + 'failed to get confirmed transactions: ' + res.error.message, + ); + } + return res.result; + }); + + return res; + } + /** * Fetch a list of all the confirmed signatures for transactions involving an address * within a specified slot range. Max range allowed is 10,000 slots. diff --git a/web3.js/test/connection.test.ts b/web3.js/test/connection.test.ts index 36e19174a4..9ec697ed6e 100644 --- a/web3.js/test/connection.test.ts +++ b/web3.js/test/connection.test.ts @@ -32,6 +32,7 @@ import { helpers, mockErrorMessage, mockErrorResponse, + mockRpcBatchResponse, mockRpcResponse, mockServer, } from './mocks/rpc-http'; @@ -649,6 +650,271 @@ describe('Connection', () => { } }); + it('get parsed confirmed transactions', async () => { + await mockRpcResponse({ + method: 'getSlot', + params: [], + value: 1, + }); + + while ((await connection.getSlot()) <= 0) { + continue; + } + + await mockRpcResponse({ + method: 'getConfirmedBlock', + params: [1], + value: { + blockTime: 1614281964, + blockhash: '57zQNBZBEiHsCZFqsaY6h176ioXy5MsSLmcvHkEyaLGy', + previousBlockhash: 'H5nJ91eGag3B5ZSRHZ7zG5ZwXJ6ywCt2hyR8xCsV7xMo', + parentSlot: 0, + transactions: [ + { + meta: { + fee: 10000, + postBalances: [499260347380, 15298080, 1, 1, 1], + preBalances: [499260357380, 15298080, 1, 1, 1], + status: {Ok: null}, + err: null, + }, + transaction: { + message: { + accountKeys: [ + 'va12u4o9DipLEB2z4fuoHszroq1U9NcAB9aooFDPJSf', + '57zQNBZBEiHsCZFqsaY6h176ioXy5MsSLmcvHkEyaLGy', + 'SysvarS1otHashes111111111111111111111111111', + 'SysvarC1ock11111111111111111111111111111111', + 'Vote111111111111111111111111111111111111111', + ], + header: { + numReadonlySignedAccounts: 0, + numReadonlyUnsignedAccounts: 3, + numRequiredSignatures: 2, + }, + instructions: [ + { + accounts: [1, 2, 3], + data: + '37u9WtQpcm6ULa3VtWDFAWoQc1hUvybPrA3dtx99tgHvvcE7pKRZjuGmn7VX2tC3JmYDYGG7', + programIdIndex: 4, + }, + ], + recentBlockhash: 'GeyAFFRY3WGpmam2hbgrKw4rbU2RKzfVLm5QLSeZwTZE', + }, + signatures: [ + 'w2Zeq8YkpyB463DttvfzARD7k9ZxGEwbsEw4boEK7jDp3pfoxZbTdLFSsEPhzXhpCcjGi2kHtHFobgX49MMhbWt', + '4oCEqwGrMdBeMxpzuWiukCYqSfV4DsSKXSiVVCh1iJ6pS772X7y219JZP3mgqBz5PhsvprpKyhzChjYc3VSBQXzG', + ], + }, + }, + ], + }, + }); + + // Find a block that has a transaction, usually Block 1 + let slot = 0; + let confirmedTransaction: string | undefined; + while (!confirmedTransaction) { + slot++; + const block = await connection.getConfirmedBlock(slot); + for (const tx of block.transactions) { + if (tx.transaction.signature) { + confirmedTransaction = bs58.encode(tx.transaction.signature); + } + } + } + + await mockRpcBatchResponse({ + batch: [ + { + methodName: 'getConfirmedTransaction', + args: [], + }, + ], + result: [ + { + blockTime: 1616102519, + meta: { + err: null, + fee: 5000, + innerInstructions: [], + logMessages: [ + 'Program Vote111111111111111111111111111111111111111 invoke [1]', + 'Program Vote111111111111111111111111111111111111111 success', + ], + postBalances: [499999995000, 26858640, 1, 1, 1], + postTokenBalances: [], + preBalances: [500000000000, 26858640, 1, 1, 1], + preTokenBalances: [], + status: { + Ok: null, + }, + }, + slot: 2, + transaction: { + message: { + accountKeys: [ + { + pubkey: 'jcU4R7JccGEvDpe1i6bahvHpe47XahMXacG73EzE198', + signer: true, + writable: true, + }, + { + pubkey: 'GfBcnCAU7kWfAYqKRCNyWEHjdEJZmzRZvEcX5bbzEQqt', + signer: false, + writable: true, + }, + { + pubkey: 'SysvarS1otHashes111111111111111111111111111', + signer: false, + writable: false, + }, + { + pubkey: 'SysvarC1ock11111111111111111111111111111111', + signer: false, + writable: false, + }, + { + pubkey: 'Vote111111111111111111111111111111111111111', + signer: false, + writable: false, + }, + ], + instructions: [ + { + parsed: { + info: { + clockSysvar: + 'SysvarC1ock11111111111111111111111111111111', + slotHashesSysvar: + 'SysvarS1otHashes111111111111111111111111111', + vote: { + hash: 'GuCya3AAGxn1qhoqxqy3WEdZdZUkXKpa9pthQ3tqvbpx', + slots: [1], + timestamp: 1616102669, + }, + voteAccount: + 'GfBcnCAU7kWfAYqKRCNyWEHjdEJZmzRZvEcX5bbzEQqt', + voteAuthority: + 'jcU4R7JccGEvDpe1i6bahvHpe47XahMXacG73EzE198', + }, + type: 'vote', + }, + program: 'vote', + programId: 'Vote111111111111111111111111111111111111111', + }, + ], + recentBlockhash: 'G9ywjV5CVgMtLXruXtrE7af4QgFKYNXgDTw4jp7SWcSo', + }, + signatures: [ + '4G4rTqnUdzrmBHsdKJSiMtonpQLWSw1avJ8YxWQ95jE6iFFHFsEkBnoYycxnkBS9xHWRc6EarDsrFG9USFBbjfjx', + ], + }, + }, + { + blockTime: 1616102519, + meta: { + err: null, + fee: 5000, + innerInstructions: [], + logMessages: [ + 'Program Vote111111111111111111111111111111111111111 invoke [1]', + 'Program Vote111111111111111111111111111111111111111 success', + ], + postBalances: [499999995000, 26858640, 1, 1, 1], + postTokenBalances: [], + preBalances: [500000000000, 26858640, 1, 1, 1], + preTokenBalances: [], + status: { + Ok: null, + }, + }, + slot: 2, + transaction: { + message: { + accountKeys: [ + { + pubkey: 'jcU4R7JccGEvDpe1i6bahvHpe47XahMXacG73EzE198', + signer: true, + writable: true, + }, + { + pubkey: 'GfBcnCAU7kWfAYqKRCNyWEHjdEJZmzRZvEcX5bbzEQqt', + signer: false, + writable: true, + }, + { + pubkey: 'SysvarS1otHashes111111111111111111111111111', + signer: false, + writable: false, + }, + { + pubkey: 'SysvarC1ock11111111111111111111111111111111', + signer: false, + writable: false, + }, + { + pubkey: 'Vote111111111111111111111111111111111111111', + signer: false, + writable: false, + }, + ], + instructions: [ + { + parsed: { + info: { + clockSysvar: + 'SysvarC1ock11111111111111111111111111111111', + slotHashesSysvar: + 'SysvarS1otHashes111111111111111111111111111', + vote: { + hash: 'GuCya3AAGxn1qhoqxqy3WEdZdZUkXKpa9pthQ3tqvbpx', + slots: [1], + timestamp: 1616102669, + }, + voteAccount: + 'GfBcnCAU7kWfAYqKRCNyWEHjdEJZmzRZvEcX5bbzEQqt', + voteAuthority: + 'jcU4R7JccGEvDpe1i6bahvHpe47XahMXacG73EzE198', + }, + type: 'vote', + }, + program: 'vote', + programId: 'Vote111111111111111111111111111111111111111', + }, + ], + recentBlockhash: 'G9ywjV5CVgMtLXruXtrE7af4QgFKYNXgDTw4jp7SWcSo', + }, + signatures: [ + '4G4rTqnUdzrmBHsdKJSiMtonpQLWSw1avJ8YxWQ95jE6iFFHFsEkBnoYycxnkBS9xHWRc6EarDsrFG9USFBbjfjx', + ], + }, + }, + ], + }); + + const result = await connection.getParsedConfirmedTransactions([ + confirmedTransaction, + confirmedTransaction, + ]); + + if (!result) { + expect(result).to.be.ok; + return; + } + + expect(result).to.be.length(2); + expect(result[0]).to.not.be.null; + expect(result[1]).to.not.be.null; + if (result[0] !== null) { + expect(result[0].transaction.signatures).not.to.be.null; + } + if (result[1] !== null) { + expect(result[1].transaction.signatures).not.to.be.null; + } + }); + it('get confirmed transaction', async () => { await mockRpcResponse({ method: 'getSlot', diff --git a/web3.js/test/mocks/rpc-http.ts b/web3.js/test/mocks/rpc-http.ts index 60fec16b64..695fa7e7fc 100644 --- a/web3.js/test/mocks/rpc-http.ts +++ b/web3.js/test/mocks/rpc-http.ts @@ -5,7 +5,7 @@ import * as mockttp from 'mockttp'; import {mockRpcMessage} from './rpc-websockets'; import {Account, Connection, PublicKey, Transaction} from '../../src'; -import type {Commitment} from '../../src/connection'; +import type {Commitment, RpcParams} from '../../src/connection'; export const mockServer: mockttp.Mockttp | undefined = process.env.TEST_LIVE === undefined ? mockttp.getLocal() : undefined; @@ -24,6 +24,40 @@ export const mockErrorResponse = { message: mockErrorMessage, }; +export const mockRpcBatchResponse = async ({ + batch, + result, + error, +}: { + batch: RpcParams[]; + result: any[]; + error?: string; +}) => { + if (!mockServer) return; + + const request = batch.map((batch: RpcParams) => { + return { + jsonrpc: '2.0', + method: batch.methodName, + params: batch.args, + }; + }); + + const response = result.map((result: any) => { + return { + jsonrpc: '2.0', + id: '', + result, + error, + }; + }); + + await mockServer + .post('/') + .withJsonBodyIncluding(request) + .thenReply(200, JSON.stringify(response)); +}; + export const mockRpcResponse = async ({ method, params,