From c7a2fbe7eba18772266f3b4b02ccfb66353ffa3e Mon Sep 17 00:00:00 2001 From: Justin Starry Date: Thu, 6 Aug 2020 23:47:22 +0800 Subject: [PATCH] feat: add parsed account data APIs --- web3.js/flow-typed/superstruct.js | 1 + web3.js/module.d.ts | 46 ++++- web3.js/module.flow.js | 37 +++- web3.js/src/connection.js | 302 ++++++++++++++++++++++++++---- web3.js/test/connection.test.js | 125 ++++++++++++- 5 files changed, 452 insertions(+), 59 deletions(-) diff --git a/web3.js/flow-typed/superstruct.js b/web3.js/flow-typed/superstruct.js index 43c8e5b173..f95d4c9f8f 100644 --- a/web3.js/flow-typed/superstruct.js +++ b/web3.js/flow-typed/superstruct.js @@ -1,6 +1,7 @@ declare module 'superstruct' { declare type StructFunc = { (any): any, + object(schema: any): any; union(schema: any): any; array(schema: any): any; literal(schema: any): any; diff --git a/web3.js/module.d.ts b/web3.js/module.d.ts index a99afdd225..0352cc82ac 100644 --- a/web3.js/module.d.ts +++ b/web3.js/module.d.ts @@ -104,16 +104,16 @@ declare module '@solana/web3.js' { feeCalculator: FeeCalculator; }; - export type PublicKeyAndAccount = { + export type PublicKeyAndAccount = { pubkey: PublicKey; - account: AccountInfo; + account: AccountInfo; }; - export type AccountInfo = { + export type AccountInfo = { executable: boolean; owner: PublicKey; lamports: number; - data: Buffer; + data: T; rentEpoch?: number; }; @@ -181,9 +181,14 @@ declare module '@solana/web3.js' { meta: ConfirmedTransactionMeta | null; }; + export type ParsedAccountData = { + program: string; + parsed: any; + }; + export type KeyedAccountInfo = { accountId: PublicKey; - accountInfo: AccountInfo; + accountInfo: AccountInfo; }; export type Version = { @@ -210,7 +215,7 @@ declare module '@solana/web3.js' { }; export type AccountChangeCallback = ( - accountInfo: AccountInfo, + accountInfo: AccountInfo, context: Context, ) => void; export type ProgramAccountChangeCallback = ( @@ -280,15 +285,25 @@ declare module '@solana/web3.js' { getAccountInfoAndContext( publicKey: PublicKey, commitment?: Commitment, - ): Promise>; + ): Promise | null>>; getAccountInfo( publicKey: PublicKey, commitment?: Commitment, - ): Promise; + ): Promise | null>; + getParsedAccountInfo( + publicKey: PublicKey, + commitment?: Commitment, + ): Promise< + RpcResponseAndContext | null> + >; getProgramAccounts( programId: PublicKey, commitment?: Commitment, - ): Promise>; + ): Promise>>; + getParsedProgramAccounts( + programId: PublicKey, + commitment?: Commitment, + ): Promise>>; getBalanceAndContext( publicKey: PublicKey, commitment?: Commitment, @@ -311,7 +326,18 @@ declare module '@solana/web3.js' { filter: TokenAccountsFilter, commitment?: Commitment, ): Promise< - RpcResponseAndContext> + RpcResponseAndContext< + Array<{pubkey: PublicKey; account: AccountInfo}> + > + >; + getParsedTokenAccountsByOwner( + ownerAddress: PublicKey, + filter: TokenAccountsFilter, + commitment?: Commitment, + ): Promise< + RpcResponseAndContext< + Array<{pubkey: PublicKey; account: AccountInfo}> + > >; getLargestAccounts( config?: GetLargestAccountsConfig, diff --git a/web3.js/module.flow.js b/web3.js/module.flow.js index 7b7745ed0c..42875b4aeb 100644 --- a/web3.js/module.flow.js +++ b/web3.js/module.flow.js @@ -125,16 +125,16 @@ declare module '@solana/web3.js' { feeCalculator: FeeCalculator, }; - declare export type PublicKeyAndAccount = { + declare export type PublicKeyAndAccount = { pubkey: PublicKey, - account: AccountInfo, + account: AccountInfo, }; - declare export type AccountInfo = { + declare export type AccountInfo = { executable: boolean, owner: PublicKey, lamports: number, - data: Buffer, + data: T, rentEpoch: number | null, }; @@ -169,6 +169,11 @@ declare module '@solana/web3.js' { meta: ConfirmedTransactionMeta | null, }; + declare export type ParsedAccountData = { + program: string, + parsed: any, + }; + declare export type ParsedMessageAccount = { pubkey: PublicKey, signer: boolean, @@ -204,7 +209,7 @@ declare module '@solana/web3.js' { declare export type KeyedAccountInfo = { accountId: PublicKey, - accountInfo: AccountInfo, + accountInfo: AccountInfo, }; declare export type Version = { @@ -231,7 +236,7 @@ declare module '@solana/web3.js' { }; declare type AccountChangeCallback = ( - accountInfo: AccountInfo, + accountInfo: AccountInfo, context: Context, ) => void; declare type ProgramAccountChangeCallback = ( @@ -301,15 +306,25 @@ declare module '@solana/web3.js' { getAccountInfoAndContext( publicKey: PublicKey, commitment: ?Commitment, - ): Promise>; + ): Promise | null>>; getAccountInfo( publicKey: PublicKey, commitment: ?Commitment, - ): Promise; + ): Promise | null>; + getParsedAccountInfo( + publicKey: PublicKey, + commitment: ?Commitment, + ): Promise< + RpcResponseAndContext | null>, + >; getProgramAccounts( programId: PublicKey, commitment: ?Commitment, - ): Promise>; + ): Promise>>; + getParsedProgramAccounts( + programId: PublicKey, + commitment: ?Commitment, + ): Promise>>; getBalanceAndContext( publicKey: PublicKey, commitment: ?Commitment, @@ -332,7 +347,9 @@ declare module '@solana/web3.js' { filter: TokenAccountsFilter, commitment: ?Commitment, ): Promise< - RpcResponseAndContext>, + RpcResponseAndContext< + Array<{pubkey: PublicKey, account: AccountInfo}>, + >, >; getLargestAccounts( config: ?GetLargestAccountsConfig, diff --git a/web3.js/src/connection.js b/web3.js/src/connection.js index 7ced11ef60..14de68dbac 100644 --- a/web3.js/src/connection.js +++ b/web3.js/src/connection.js @@ -25,12 +25,12 @@ export const BLOCKHASH_CACHE_TIMEOUT_MS = 30 * 1000; type RpcRequest = (methodName: string, args: Array) => any; type TokenAccountsFilter = - | { + | {| mint: PublicKey, - } - | { + |} + | {| programId: PublicKey, - }; + |}; /** * Extra contextual information for RPC responses @@ -634,13 +634,34 @@ const GetTokenSupplyRpcResult = jsonRpcResultAndContext(TokenAmountResult); */ const GetTokenAccountsByOwner = jsonRpcResultAndContext( struct.array([ - struct({ + struct.object({ pubkey: 'string', - account: struct({ + account: struct.object({ executable: 'boolean', owner: 'string', lamports: 'number', - data: 'any', + data: 'string', + rentEpoch: 'number?', + }), + }), + ]), +); + +/** + * Expected JSON RPC response for the "getTokenAccountsByOwner" message with parsed data + */ +const GetParsedTokenAccountsByOwner = jsonRpcResultAndContext( + struct.array([ + struct.object({ + pubkey: 'string', + account: struct.object({ + executable: 'boolean', + owner: 'string', + lamports: 'number', + data: struct.object({ + program: 'string', + parsed: 'any', + }), rentEpoch: 'number?', }), }), @@ -692,6 +713,23 @@ const AccountInfoResult = struct({ rentEpoch: 'number?', }); +/** + * @private + */ +const ParsedAccountInfoResult = struct.object({ + executable: 'boolean', + owner: 'string', + lamports: 'number', + data: struct.union([ + 'string', + struct.object({ + program: 'string', + parsed: 'any', + }), + ]), + rentEpoch: 'number?', +}); + /** * Expected JSON RPC response for the "getAccountInfo" message */ @@ -699,6 +737,13 @@ const GetAccountInfoAndContextRpcResult = jsonRpcResultAndContext( struct.union(['null', AccountInfoResult]), ); +/** + * Expected JSON RPC response for the "getAccountInfo" message with jsonParsed param + */ +const GetParsedAccountInfoResult = jsonRpcResultAndContext( + struct.union(['null', ParsedAccountInfoResult]), +); + /** * Expected JSON RPC response for the "getConfirmedSignaturesForAddress" message */ @@ -737,6 +782,14 @@ const ProgramAccountInfoResult = struct({ account: AccountInfoResult, }); +/** + * @private + */ +const ParsedProgramAccountInfoResult = struct({ + pubkey: 'string', + account: ParsedAccountInfoResult, +}); + /*** * Expected JSON RPC response for the "programNotification" message */ @@ -785,6 +838,13 @@ const GetProgramAccountsRpcResult = jsonRpcResult( struct.array([ProgramAccountInfoResult]), ); +/** + * Expected JSON RPC response for the "getProgramAccounts" message + */ +const GetParsedProgramAccountsRpcResult = jsonRpcResult( + struct.array([ParsedProgramAccountInfoResult]), +); + /** * Expected JSON RPC response for the "getSlot" message */ @@ -1051,20 +1111,32 @@ type SlotInfo = { root: number, }; +/** + * Parsed account data + * + * @typedef {Object} ParsedAccountData + * @property {string} program Name of the program that owns this account + * @property {any} parsed Parsed account data + */ +type ParsedAccountData = { + program: string, + parsed: any, +}; + /** * Information describing an account * * @typedef {Object} AccountInfo * @property {number} lamports Number of lamports assigned to the account * @property {PublicKey} owner Identifier of the program that owns the account - * @property {?Buffer} data Optional data assigned to the account + * @property {T} data Optional data assigned to the account * @property {boolean} executable `true` if this account's data contains a loaded program */ -type AccountInfo = { +type AccountInfo = { executable: boolean, owner: PublicKey, lamports: number, - data: Buffer, + data: T, }; /** @@ -1072,18 +1144,18 @@ type AccountInfo = { * * @typedef {Object} KeyedAccountInfo * @property {PublicKey} accountId - * @property {AccountInfo} accountInfo + * @property {AccountInfo} accountInfo */ type KeyedAccountInfo = { accountId: PublicKey, - accountInfo: AccountInfo, + accountInfo: AccountInfo, }; /** * Callback function for account change notifications */ export type AccountChangeCallback = ( - accountInfo: AccountInfo, + accountInfo: AccountInfo, context: Context, ) => void; @@ -1450,25 +1522,23 @@ export class Connection { /** * Fetch all the token accounts owned by the specified account * - * @return {Promise>>} + * @return {Promise}>>>} */ async getTokenAccountsByOwner( ownerAddress: PublicKey, filter: TokenAccountsFilter, commitment: ?Commitment, ): Promise< - RpcResponseAndContext>, + RpcResponseAndContext< + Array<{pubkey: PublicKey, account: AccountInfo}>, + >, > { let _args = [ownerAddress.toBase58()]; - - // Strip flow types to make flow happy - ((filter: any) => { - if ('mint' in filter) { - _args.push({mint: filter.mint.toBase58()}); - } else { - _args.push({programId: filter.programId.toBase58()}); - } - })(filter); + if (filter.mint) { + _args.push({mint: filter.mint.toBase58()}); + } else { + _args.push({programId: filter.programId.toBase58()}); + } const args = this._argsWithCommitment(_args, commitment); const unsafeRes = await this._rpcRequest('getTokenAccountsByOwner', args); @@ -1489,7 +1559,7 @@ export class Connection { return { context, value: value.map(result => ({ - pubkey: result.pubkey, + pubkey: new PublicKey(result.pubkey), account: { executable: result.account.executable, owner: new PublicKey(result.account.owner), @@ -1500,6 +1570,57 @@ export class Connection { }; } + /** + * Fetch parsed token accounts owned by the specified account + * + * @return {Promise}>>>} + */ + async getParsedTokenAccountsByOwner( + ownerAddress: PublicKey, + filter: TokenAccountsFilter, + commitment: ?Commitment, + ): Promise< + RpcResponseAndContext< + Array<{pubkey: PublicKey, account: AccountInfo}>, + >, + > { + let _args = [ownerAddress.toBase58()]; + if (filter.mint) { + _args.push({mint: filter.mint.toBase58()}); + } else { + _args.push({programId: filter.programId.toBase58()}); + } + + const args = this._argsWithCommitment(_args, commitment, 'jsonParsed'); + const unsafeRes = await this._rpcRequest('getTokenAccountsByOwner', args); + const res = GetParsedTokenAccountsByOwner(unsafeRes); + if (res.error) { + throw new Error( + 'failed to get token accounts owned by account ' + + ownerAddress.toBase58() + + ': ' + + res.error.message, + ); + } + + const {result} = res; + const {context, value} = result; + assert(typeof result !== 'undefined'); + + return { + context, + value: value.map(result => ({ + pubkey: new PublicKey(result.pubkey), + account: { + executable: result.account.executable, + owner: new PublicKey(result.account.owner), + lamports: result.account.lamports, + data: result.account.data, + }, + })), + }; + } + /** * Fetch the 20 largest accounts with their current balances */ @@ -1530,7 +1651,7 @@ export class Connection { async getAccountInfoAndContext( publicKey: PublicKey, commitment: ?Commitment, - ): Promise> { + ): Promise | null>> { const args = this._argsWithCommitment([publicKey.toBase58()], commitment); const unsafeRes = await this._rpcRequest('getAccountInfo', args); const res = GetAccountInfoAndContextRpcResult(unsafeRes); @@ -1563,13 +1684,64 @@ export class Connection { }; } + /** + * Fetch parsed account info for the specified public key + */ + async getParsedAccountInfo( + publicKey: PublicKey, + commitment: ?Commitment, + ): Promise< + RpcResponseAndContext | null>, + > { + const args = this._argsWithCommitment( + [publicKey.toBase58()], + commitment, + 'jsonParsed', + ); + const unsafeRes = await this._rpcRequest('getAccountInfo', args); + const res = GetParsedAccountInfoResult(unsafeRes); + if (res.error) { + throw new Error( + 'failed to get info about account ' + + publicKey.toBase58() + + ': ' + + res.error.message, + ); + } + assert(typeof res.result !== 'undefined'); + + let value = null; + if (res.result.value) { + const {executable, owner, lamports, data: resultData} = res.result.value; + + let data = resultData; + if (!data.program) { + data = bs58.decode(data); + } + + value = { + executable, + owner: new PublicKey(owner), + lamports, + data, + }; + } + + return { + context: { + slot: res.result.context.slot, + }, + value, + }; + } + /** * Fetch all the account info for the specified public key */ async getAccountInfo( publicKey: PublicKey, commitment: ?Commitment, - ): Promise { + ): Promise | null> { return await this.getAccountInfoAndContext(publicKey, commitment) .then(x => x.value) .catch(e => { @@ -1582,12 +1754,12 @@ export class Connection { /** * Fetch all the accounts owned by the specified program id * - * @return {Promise>} + * @return {Promise}>>} */ async getProgramAccounts( programId: PublicKey, commitment: ?Commitment, - ): Promise> { + ): Promise}>> { const args = this._argsWithCommitment([programId.toBase58()], commitment); const unsafeRes = await this._rpcRequest('getProgramAccounts', args); const res = GetProgramAccountsRpcResult(unsafeRes); @@ -1605,7 +1777,7 @@ export class Connection { return result.map(result => { return { - pubkey: result.pubkey, + pubkey: new PublicKey(result.pubkey), account: { executable: result.account.executable, owner: new PublicKey(result.account.owner), @@ -1616,6 +1788,59 @@ export class Connection { }); } + /** + * Fetch and parse all the accounts owned by the specified program id + * + * @return {Promise}>>} + */ + async getParsedProgramAccounts( + programId: PublicKey, + commitment: ?Commitment, + ): Promise< + Array<{ + pubkey: PublicKey, + account: AccountInfo, + }>, + > { + const args = this._argsWithCommitment( + [programId.toBase58()], + commitment, + 'jsonParsed', + ); + const unsafeRes = await this._rpcRequest('getProgramAccounts', args); + const res = GetParsedProgramAccountsRpcResult(unsafeRes); + if (res.error) { + throw new Error( + 'failed to get accounts owned by program ' + + programId.toBase58() + + ': ' + + res.error.message, + ); + } + + const {result} = res; + assert(typeof result !== 'undefined'); + + return result.map(result => { + const resultData = result.account.data; + + let data = resultData; + if (!data.program) { + data = bs58.decode(data); + } + + return { + pubkey: new PublicKey(result.pubkey), + account: { + executable: result.account.executable, + owner: new PublicKey(result.account.owner), + lamports: result.account.lamports, + data, + }, + }; + }); + } + /** * Confirm the transaction identified by the specified signature */ @@ -2625,10 +2850,21 @@ export class Connection { } } - _argsWithCommitment(args: Array, override: ?Commitment): Array { + _argsWithCommitment( + args: Array, + override: ?Commitment, + encoding?: 'jsonParsed', + ): Array { const commitment = override || this._commitment; - if (commitment) { - args.push({commitment}); + if (commitment || encoding) { + let options: any = {}; + if (encoding) { + options.encoding = encoding; + } + if (commitment) { + options.commitment = commitment; + } + args.push(options); } return args; } diff --git a/web3.js/test/connection.test.js b/web3.js/test/connection.test.js index d66baa4493..4567ebc7f8 100644 --- a/web3.js/test/connection.test.js +++ b/web3.js/test/connection.test.js @@ -77,6 +77,12 @@ test('get account info - not found', async () => { ]); expect(await connection.getAccountInfo(account.publicKey)).toBeNull(); + + if (!mockRpcEnabled) { + expect( + (await connection.getParsedAccountInfo(account.publicKey)).value, + ).toBeNull(); + } }); test('get program accounts', async () => { @@ -282,20 +288,39 @@ test('get program accounts', async () => { expect(programAccounts.length).toBe(2); programAccounts.forEach(function (element) { - expect([ - account0.publicKey.toBase58(), - account1.publicKey.toBase58(), - ]).toEqual(expect.arrayContaining([element.pubkey])); - if (element.pubkey == account0.publicKey) { + if (element.pubkey.equals(account0.publicKey)) { expect(element.account.lamports).toBe( LAMPORTS_PER_SOL - feeCalculator.lamportsPerSignature, ); - } else { + } else if (element.pubkey.equals(account1.publicKey)) { expect(element.account.lamports).toBe( 0.5 * LAMPORTS_PER_SOL - feeCalculator.lamportsPerSignature, ); + } else { + expect(element.pubkey.equals(account1.publicKey)).toBe(true); } }); + + if (!mockRpcEnabled) { + const programAccounts = await connection.getParsedProgramAccounts( + programId.publicKey, + ); + expect(programAccounts.length).toBe(2); + + programAccounts.forEach(function (element) { + if (element.pubkey.equals(account0.publicKey)) { + expect(element.account.lamports).toBe( + LAMPORTS_PER_SOL - feeCalculator.lamportsPerSignature, + ); + } else if (element.pubkey.equals(account1.publicKey)) { + expect(element.account.lamports).toBe( + 0.5 * LAMPORTS_PER_SOL - feeCalculator.lamportsPerSignature, + ); + } else { + expect(element.pubkey.equals(account1.publicKey)).toBe(true); + } + }); + } }); test('validatorExit', async () => { @@ -1410,6 +1435,55 @@ describe('token methods', () => { ).rejects.toThrow(); }); + test('get parsed token account info', async () => { + const accountInfo = ( + await connection.getParsedAccountInfo(testTokenAccount) + ).value; + if (accountInfo) { + const data = accountInfo.data; + if (data instanceof Buffer) { + expect(data instanceof Buffer).toBe(false); + } else { + expect(data.program).toEqual('spl-token'); + expect(data.parsed).toBeTruthy(); + } + } + }); + + test('get parsed token program accounts', async () => { + const tokenAccounts = await connection.getParsedProgramAccounts( + TOKEN_PROGRAM_ID, + ); + tokenAccounts.forEach(({account}) => { + expect(account.owner.equals(TOKEN_PROGRAM_ID)).toBe(true); + const data = account.data; + if (data instanceof Buffer) { + expect(data instanceof Buffer).toBe(false); + } else { + expect(data.parsed).toBeTruthy(); + expect(data.program).toEqual('spl-token'); + } + }); + }); + + test('get parsed token accounts by owner', async () => { + const tokenAccounts = ( + await connection.getParsedTokenAccountsByOwner(testOwner.publicKey, { + mint: testToken.publicKey, + }) + ).value; + tokenAccounts.forEach(({account}) => { + expect(account.owner.equals(TOKEN_PROGRAM_ID)).toBe(true); + const data = account.data; + if (data instanceof Buffer) { + expect(data instanceof Buffer).toBe(false); + } else { + expect(data.parsed).toBeTruthy(); + expect(data.program).toEqual('spl-token'); + } + }); + }); + test('get token accounts by owner', async () => { const accountsWithMintFilter = ( await connection.getTokenAccountsByOwner(testOwner.publicKey, { @@ -1611,6 +1685,45 @@ test('request airdrop', async () => { expect(accountInfo.lamports).toBe(minimumAmount + 42); expect(accountInfo.data).toHaveLength(0); expect(accountInfo.owner).toEqual(SystemProgram.programId); + + mockRpc.push([ + url, + { + method: 'getAccountInfo', + params: [ + account.publicKey.toBase58(), + {commitment: 'recent', encoding: 'jsonParsed'}, + ], + }, + { + error: null, + result: { + context: { + slot: 11, + }, + value: { + owner: '11111111111111111111111111111111', + lamports: minimumAmount + 42, + data: '', + executable: false, + }, + }, + }, + ]); + + const parsedAccountInfo = ( + await connection.getParsedAccountInfo(account.publicKey) + ).value; + if (parsedAccountInfo === null) { + expect(parsedAccountInfo).not.toBeNull(); + return; + } else if (parsedAccountInfo.data.parsed) { + expect(parsedAccountInfo.data.parsed).not.toBeTruthy(); + return; + } + expect(parsedAccountInfo.lamports).toBe(minimumAmount + 42); + expect(parsedAccountInfo.data).toHaveLength(0); + expect(parsedAccountInfo.owner).toEqual(SystemProgram.programId); }); test('transaction failure', async () => {