From ae53742e1a223454b05e70f5f34131ee3ea6e1ad Mon Sep 17 00:00:00 2001 From: Justin Starry Date: Tue, 21 Apr 2020 15:40:44 +0800 Subject: [PATCH] feat: add getConfirmedTransaction and getConfirmedSignaturesForAddress --- web3.js/module.d.ts | 19 ++ web3.js/module.flow.js | 19 ++ web3.js/src/connection.js | 165 +++++++++++++--- web3.js/test/connection.test.js | 328 ++++++++++++++++++++++++++++++++ 4 files changed, 500 insertions(+), 31 deletions(-) diff --git a/web3.js/module.d.ts b/web3.js/module.d.ts index 7cfff99fbf..93afab340d 100644 --- a/web3.js/module.d.ts +++ b/web3.js/module.d.ts @@ -98,6 +98,17 @@ declare module '@solana/web3.js' { }>; }; + export type ConfirmedTransaction = { + slot: number; + transaction: Transaction; + meta: { + fee: number; + preBalances: Array; + postBalances: Array; + err: TransactionError | null; + } | null; + }; + export type KeyedAccountInfo = { accountId: PublicKey; accountInfo: AccountInfo; @@ -184,6 +195,14 @@ declare module '@solana/web3.js' { getBalance(publicKey: PublicKey, commitment?: Commitment): Promise; getClusterNodes(): Promise>; getConfirmedBlock(slot: number): Promise; + getConfirmedTransaction( + signature: TransactionSignature, + ): Promise; + getConfirmedSignaturesForAddress( + address: PublicKey, + startSlot: number, + endSlot: number, + ): Promise>; getVoteAccounts(commitment?: Commitment): Promise; confirmTransactionAndContext( signature: TransactionSignature, diff --git a/web3.js/module.flow.js b/web3.js/module.flow.js index 7a8b1ce4b5..016da03e26 100644 --- a/web3.js/module.flow.js +++ b/web3.js/module.flow.js @@ -111,6 +111,17 @@ declare module '@solana/web3.js' { }>, }; + declare export type ConfirmedTransaction = { + slot: number, + transaction: Transaction, + meta: { + fee: number, + preBalances: Array, + postBalances: Array, + err: TransactionError | null, + } | null, + }; + declare export type KeyedAccountInfo = { accountId: PublicKey, accountInfo: AccountInfo, @@ -197,6 +208,14 @@ declare module '@solana/web3.js' { getBalance(publicKey: PublicKey, commitment: ?Commitment): Promise; getClusterNodes(): Promise>; getConfirmedBlock(slot: number): Promise; + getConfirmedTransaction( + signature: TransactionSignature, + ): Promise; + getConfirmedSignaturesForAddress( + address: PublicKey, + startSlot: number, + endSlot: number, + ): Promise>; getVoteAccounts(commitment: ?Commitment): Promise; confirmTransactionAndContext( signature: TransactionSignature, diff --git a/web3.js/src/connection.js b/web3.js/src/connection.js index fe4a36c721..a4ad386c65 100644 --- a/web3.js/src/connection.js +++ b/web3.js/src/connection.js @@ -233,6 +233,25 @@ const Version = struct({ 'solana-core': 'string', }); +/** + * A confirmed transaction on the ledger + * + * @typedef {Object} ConfirmedTransaction + * @property {number} slot The slot during which the transaction was processed + * @property {Transaction} transaction The details of the transaction + * @property {object} meta Slot index of this block's parent + */ +type ConfirmedTransaction = { + slot: number, + transaction: Transaction, + meta: { + fee: number, + err: TransactionError | null, + preBalances: Array, + postBalances: Array, + } | null, +}; + /** * A ConfirmedBlock on the ledger * @@ -357,6 +376,13 @@ const GetAccountInfoAndContextRpcResult = jsonRpcResultAndContext( struct.union(['null', AccountInfoResult]), ); +/** + * @private + */ +const GetConfirmedSignaturesForAddressRpcResult = jsonRpcResult( + struct.array(['string']), +); + /*** * Expected JSON RPC response for the "accountNotification" message */ @@ -519,6 +545,45 @@ const GetTotalSupplyRpcResult = jsonRpcResult('number'); */ const GetMinimumBalanceForRentExemptionRpcResult = jsonRpcResult('number'); +/** + * @private + */ +const ConfirmedTransactionResult = struct({ + signatures: struct.array(['string']), + message: struct({ + accountKeys: struct.array(['string']), + header: struct({ + numRequiredSignatures: 'number', + numReadonlySignedAccounts: 'number', + numReadonlyUnsignedAccounts: 'number', + }), + instructions: struct.array([ + struct.union([ + struct.array(['number']), + struct({ + accounts: struct.array(['number']), + data: 'string', + programIdIndex: 'number', + }), + ]), + ]), + recentBlockhash: 'string', + }), +}); + +/** + * @private + */ +const ConfirmedTransactionMetaResult = struct.union([ + 'null', + struct.pick({ + err: TransactionErrorResult, + fee: 'number', + preBalances: struct.array(['number']), + postBalances: struct.array(['number']), + }), +]); + /** * Expected JSON RPC response for the "getConfirmedBlock" message */ @@ -531,37 +596,8 @@ export const GetConfirmedBlockRpcResult = jsonRpcResult( parentSlot: 'number', transactions: struct.array([ struct({ - transaction: struct({ - signatures: struct.array(['string']), - message: struct({ - accountKeys: struct.array(['string']), - header: struct({ - numRequiredSignatures: 'number', - numReadonlySignedAccounts: 'number', - numReadonlyUnsignedAccounts: 'number', - }), - instructions: struct.array([ - struct.union([ - struct.array(['number']), - struct({ - accounts: struct.array(['number']), - data: 'string', - programIdIndex: 'number', - }), - ]), - ]), - recentBlockhash: 'string', - }), - }), - meta: struct.union([ - 'null', - struct.pick({ - err: TransactionErrorResult, - fee: 'number', - preBalances: struct.array(['number']), - postBalances: struct.array(['number']), - }), - ]), + transaction: ConfirmedTransactionResult, + meta: ConfirmedTransactionMetaResult, }), ]), rewards: struct.union([ @@ -577,6 +613,20 @@ export const GetConfirmedBlockRpcResult = jsonRpcResult( ]), ); +/** + * Expected JSON RPC response for the "getConfirmedTransaction" message + */ +const GetConfirmedTransactionRpcResult = jsonRpcResult( + struct.union([ + 'null', + struct({ + slot: 'number', + transaction: ConfirmedTransactionResult, + meta: ConfirmedTransactionMetaResult, + }), + ]), +); + /** * Expected JSON RPC response for the "getRecentBlockhash" message */ @@ -1266,6 +1316,59 @@ export class Connection { }; } + /** + * Fetch a transaction details for a confirmed transaction + */ + async getConfirmedTransaction( + signature: TransactionSignature, + ): Promise { + const unsafeRes = await this._rpcRequest('getConfirmedTransaction', [ + signature, + ]); + const {result, error} = GetConfirmedTransactionRpcResult(unsafeRes); + if (error) { + throw new Error('failed to get confirmed transaction: ' + error.message); + } + assert(typeof result !== 'undefined'); + if (result === null) { + return result; + } + + return { + slot: result.slot, + transaction: Transaction.fromRpcResult(result.transaction), + meta: result.meta, + }; + } + + /** + * 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. + * + * @param address queried address + * @param startSlot start slot, inclusive + * @param endSlot end slot, inclusive + */ + async getConfirmedSignaturesForAddress( + address: PublicKey, + startSlot: number, + endSlot: number, + ): Promise> { + const unsafeRes = await this._rpcRequest( + 'getConfirmedSignaturesForAddress', + [address.toBase58(), startSlot, endSlot], + ); + const result = GetConfirmedSignaturesForAddressRpcResult(unsafeRes); + if (result.error) { + throw new Error( + 'failed to get confirmed signatures for address: ' + + result.error.message, + ); + } + assert(typeof result.result !== 'undefined'); + return result.result; + } + /** * Fetch the contents of a Nonce account from the cluster, return with context */ diff --git a/web3.js/test/connection.test.js b/web3.js/test/connection.test.js index decf23ecea..3f29ee8518 100644 --- a/web3.js/test/connection.test.js +++ b/web3.js/test/connection.test.js @@ -1,10 +1,13 @@ // @flow +import bs58 from 'bs58'; + import { Account, Connection, SystemProgram, sendAndConfirmTransaction, LAMPORTS_PER_SOL, + PublicKey, } from '../src'; import {DEFAULT_TICKS_PER_SLOT, NUM_TICKS_PER_SECOND} from '../src/timing'; import {mockRpc, mockRpcEnabled} from './__mocks__/node-fetch'; @@ -570,6 +573,331 @@ test('get minimum balance for rent exemption', async () => { expect(count).toBeGreaterThanOrEqual(0); }); +test('get confirmed signatures for address', async () => { + const connection = new Connection(url); + + mockRpc.push([ + url, + { + method: 'getSlot', + params: [], + }, + { + error: null, + result: 1, + }, + ]); + + while ((await connection.getSlot()) <= 0) { + continue; + } + + mockRpc.push([ + url, + { + method: 'getConfirmedBlock', + params: [1], + }, + { + error: null, + result: { + 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 address: ?PublicKey; + let expectedSignature: ?string; + while (!address || !expectedSignature) { + slot++; + const block = await connection.getConfirmedBlock(slot); + if (block.transactions.length > 0) { + const { + signature, + publicKey, + } = block.transactions[0].transaction.signatures[0]; + if (signature) { + address = publicKey; + expectedSignature = bs58.encode(signature); + } + } + } + + mockRpc.push([ + url, + { + method: 'getConfirmedSignaturesForAddress', + params: [address.toBase58(), slot, slot + 1], + }, + { + error: null, + result: [expectedSignature], + }, + ]); + + const confirmedSignatures = await connection.getConfirmedSignaturesForAddress( + address, + slot, + slot + 1, + ); + expect(confirmedSignatures.includes(expectedSignature)).toBe(true); + + const badSlot = Number.MAX_SAFE_INTEGER - 1; + mockRpc.push([ + url, + { + method: 'getConfirmedSignaturesForAddress', + params: [address.toBase58(), badSlot, badSlot + 1], + }, + { + error: null, + result: [], + }, + ]); + + const emptySignatures = await connection.getConfirmedSignaturesForAddress( + address, + badSlot, + badSlot + 1, + ); + expect(emptySignatures.length).toBe(0); +}); + +test('get confirmed transaction', async () => { + const connection = new Connection(url); + + mockRpc.push([ + url, + { + method: 'getSlot', + params: [], + }, + { + error: null, + result: 1, + }, + ]); + + while ((await connection.getSlot()) <= 0) { + continue; + } + + mockRpc.push([ + url, + { + method: 'getConfirmedBlock', + params: [1], + }, + { + error: null, + result: { + 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; + 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); + } + } + } + + mockRpc.push([ + url, + { + method: 'getConfirmedTransaction', + params: [confirmedTransaction], + }, + { + error: null, + result: { + slot, + 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', + ], + }, + meta: { + fee: 10000, + postBalances: [499260347380, 15298080, 1, 1, 1], + preBalances: [499260357380, 15298080, 1, 1, 1], + status: {Ok: null}, + err: null, + }, + }, + }, + ]); + + const result = await connection.getConfirmedTransaction(confirmedTransaction); + + if (!result) { + expect(result).toBeDefined(); + expect(result).not.toBeNull(); + return; + } + + if (result.transaction.signature === null) { + expect(result.transaction.signature).not.toBeNull(); + return; + } + + const resultSignature = bs58.encode(result.transaction.signature); + expect(resultSignature).toEqual(confirmedTransaction); + + const newAddress = new Account().publicKey; + mockRpc.push([ + url, + { + method: 'requestAirdrop', + params: [newAddress.toBase58(), 1, {commitment: 'recent'}], + }, + { + error: null, + result: + '1WE5w4B7v59x6qjyC4FbG2FEKYKQfvsJwqSxNVmtMjT8TQ31hsZieDHcSgqzxiAoTL56n2w5TncjqEKjLhtF4Vk', + }, + ]); + + const recentSignature = await connection.requestAirdrop( + newAddress, + 1, + 'recent', + ); + mockRpc.push([ + url, + { + method: 'getConfirmedTransaction', + params: [recentSignature], + }, + { + error: null, + result: null, + }, + ]); + + const nullResponse = await connection.getConfirmedTransaction( + recentSignature, + ); + expect(nullResponse).toBeNull(); +}); + test('get confirmed block', async () => { const connection = new Connection(url);