diff --git a/.changeset/sixty-months-relate.md b/.changeset/sixty-months-relate.md new file mode 100644 index 000000000000..69fabc276f15 --- /dev/null +++ b/.changeset/sixty-months-relate.md @@ -0,0 +1,5 @@ +--- +'@solana/rpc-graphql': minor +--- + +Update program accounts filters for `programAccounts` query diff --git a/packages/rpc-api/src/__tests__/get-program-accounts-test.ts b/packages/rpc-api/src/__tests__/get-program-accounts-test.ts index 1e73a276bbf3..3cef63a74e66 100644 --- a/packages/rpc-api/src/__tests__/get-program-accounts-test.ts +++ b/packages/rpc-api/src/__tests__/get-program-accounts-test.ts @@ -2290,4 +2290,174 @@ describe('getProgramAccounts', () => { expect(accountInfo[0].account.data).toStrictEqual(['dGVzdCA=', 'base64']); }); }); + + describe('when called with a data size filter', () => { + it('returns the matching accounts', async () => { + expect.assertions(3); + const program = + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' as Address<'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'>; + + const programAccounts = await rpc + .getProgramAccounts(program, { + encoding: 'jsonParsed', + filters: [ + { + dataSize: 165n, // Token account size + }, + ], + }) + .send(); + + programAccounts.forEach(item => { + expect(item).toMatchObject({ + account: { + data: { + parsed: { + info: { + isNative: expect.any(Boolean), + mint: expect.any(String), + owner: expect.any(String), + state: expect.any(String), + tokenAmount: { + amount: expect.any(String), + decimals: expect.any(Number), + uiAmount: expect.any(Number), + uiAmountString: expect.any(String), + }, + }, + type: 'account', + }, + program: 'spl-token', + space: 165n, // Token account space + }, + executable: false, + lamports: expect.any(BigInt), + owner: expect.any(String), + rentEpoch: expect.any(BigInt), + space: 165n, // Token account space + }, + pubkey: expect.any(String), + }); + }); + }); + }); + + describe('when called with a memcmpy filter', () => { + it('returns the matching accounts', async () => { + expect.assertions(1); + // See scripts/fixtures/spl-token-mint-account.json + const mint = + 'Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr' as Address<'Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr'>; + const program = + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' as Address<'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'>; + + const programAccounts = await rpc + .getProgramAccounts(program, { + encoding: 'jsonParsed', + filters: [ + { + memcmp: { + bytes: mint, + encoding: 'base58', + offset: 0n, + }, + }, + ], + }) + .send(); + + programAccounts.forEach(item => { + expect(item).toMatchObject({ + account: { + data: { + parsed: { + info: { + isNative: expect.any(Boolean), + mint, // Matches mint address provided in filter. + owner: expect.any(String), + state: expect.any(String), + tokenAmount: { + amount: expect.any(String), + decimals: expect.any(Number), + uiAmount: expect.any(Number), + uiAmountString: expect.any(String), + }, + }, + type: 'account', + }, + program: 'spl-token', + space: 165n, // Token account space + }, + executable: false, + lamports: expect.any(BigInt), + owner: expect.any(String), + rentEpoch: expect.any(BigInt), + space: 165n, // Token account space + }, + pubkey: expect.any(String), + }); + }); + }); + }); + + describe('when called with both a data size and a memcmpy filter', () => { + it('returns the matching accounts', async () => { + expect.assertions(1); + // See scripts/fixtures/spl-token-mint-account.json + const mint = + 'Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr' as Address<'Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr'>; + const program = + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' as Address<'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'>; + + const programAccounts = await rpc + .getProgramAccounts(program, { + encoding: 'jsonParsed', + filters: [ + { + dataSize: 165n, // Token account size + }, + { + memcmp: { + bytes: mint, + encoding: 'base58', + offset: 0n, + }, + }, + ], + }) + .send(); + + programAccounts.forEach(item => { + expect(item).toMatchObject({ + account: { + data: { + parsed: { + info: { + isNative: expect.any(Boolean), + mint, // Matches mint address provided in filter. + owner: expect.any(String), + state: expect.any(String), + tokenAmount: { + amount: expect.any(String), + decimals: expect.any(Number), + uiAmount: expect.any(Number), + uiAmountString: expect.any(String), + }, + }, + type: 'account', + }, + program: 'spl-token', + space: 165n, // Token account space + }, + executable: false, + lamports: expect.any(BigInt), + owner: expect.any(String), + rentEpoch: expect.any(BigInt), + space: 165n, // Token account space + }, + pubkey: expect.any(String), + }); + }); + }); + }); }); diff --git a/packages/rpc-graphql/src/__tests__/program-accounts-test.ts b/packages/rpc-graphql/src/__tests__/program-accounts-test.ts index 4ae5af2e9bdc..1178807458f8 100644 --- a/packages/rpc-graphql/src/__tests__/program-accounts-test.ts +++ b/packages/rpc-graphql/src/__tests__/program-accounts-test.ts @@ -1,3 +1,4 @@ +import type { Address } from '@solana/addresses'; import { GetAccountInfoApi, GetBlockApi, @@ -617,4 +618,164 @@ describe('programAccounts', () => { }); }); }); + describe('when called with a data size filter', () => { + const programAddress = + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' as Address<'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'>; + + describe('when using memcmp filter', () => { + it('returns the matching accounts', async () => { + expect.assertions(1); + const variableValues = { + dataSizeFilters: [ + { + dataSize: 165n, // Token account size + }, + ], + programAddress, + }; + const source = /* GraphQL */ ` + query testQuery($programAddress: Address!, $dataSizeFilters: [ProgramAccountsDataSizeFilter!]!) { + programAccounts( + programAddress: $programAddress + commitment: null + dataSizeFilters: $dataSizeFilters + ) { + ... on TokenAccount { + mint { + address + } + } + } + } + `; + const result = await rpcGraphQL.query(source, variableValues); + console.log(result); + expect(result).toMatchObject({ + data: { + programAccounts: expect.arrayContaining([ + { + mint: { + address: expect.any(String), + }, + }, + ]), + }, + }); + }); + }); + }); + describe('when called with a memcmp filter', () => { + // See scripts/fixtures/spl-token-mint-account.json + const mintAddress = + 'Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr' as Address<'Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr'>; + const programAddress = + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' as Address<'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'>; + + describe('when using memcmp filter', () => { + it('returns the matching accounts', async () => { + expect.assertions(1); + const variableValues = { + memcmpFilters: [ + { + bytes: mintAddress, // Mint address in data. + encoding: 'BASE_58', // Base58-encoded address. + offset: 0, // Offset 0 for mint address. + }, + ], + programAddress, + }; + const source = /* GraphQL */ ` + query testQuery($programAddress: Address!, $memcmpFilters: [ProgramAccountsMemcmpFilter!]!) { + programAccounts( + programAddress: $programAddress + commitment: null + dataSizeFilters: null + memcmpFilters: $memcmpFilters + ) { + ... on TokenAccount { + mint { + address + } + } + } + } + `; + const result = await rpcGraphQL.query(source, variableValues); + console.log(result); + expect(result).toMatchObject({ + data: { + programAccounts: expect.arrayContaining([ + { + mint: { + address: mintAddress, + }, + }, + ]), + }, + }); + }); + }); + }); + + describe('when called with both a data size and a memcmpy filter', () => { + // See scripts/fixtures/spl-token-mint-account.json + const mintAddress = + 'Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr' as Address<'Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr'>; + const programAddress = + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' as Address<'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'>; + + describe('when using memcmp filter', () => { + it('returns the matching accounts', async () => { + expect.assertions(1); + const variableValues = { + dataSizeFilters: [ + { + dataSize: 165n, // Token account size + }, + ], + memcmpFilters: [ + { + bytes: mintAddress, // Mint address in data. + encoding: 'BASE_58', // Base58-encoded address. + offset: 0, // Offset 0 for mint address. + }, + ], + programAddress, + }; + const source = /* GraphQL */ ` + query testQuery( + $programAddress: Address! + $dataSizeFilters: [ProgramAccountsDataSizeFilter!]! + $memcmpFilters: [ProgramAccountsMemcmpFilter!]! + ) { + programAccounts( + programAddress: $programAddress + commitment: null + dataSizeFilters: $dataSizeFilters + memcmpFilters: $memcmpFilters + ) { + ... on TokenAccount { + mint { + address + } + } + } + } + `; + const result = await rpcGraphQL.query(source, variableValues); + console.log(result); + expect(result).toMatchObject({ + data: { + programAccounts: expect.arrayContaining([ + { + mint: { + address: mintAddress, + }, + }, + ]), + }, + }); + }); + }); + }); }); diff --git a/packages/rpc-graphql/src/loaders/loader.ts b/packages/rpc-graphql/src/loaders/loader.ts index 1371dc3f1376..34e769894afb 100644 --- a/packages/rpc-graphql/src/loaders/loader.ts +++ b/packages/rpc-graphql/src/loaders/loader.ts @@ -43,7 +43,18 @@ export type ProgramAccountsLoaderArgsBase = { commitment?: Commitment; dataSlice?: { length: number; offset: number }; encoding?: 'base58' | 'base64' | 'base64+zstd' | 'jsonParsed'; - filters?: readonly { memcmp: { bytes: string; offset: number } }[]; + filters?: ( + | { + dataSize: bigint; + } + | { + memcmp: { + bytes: string; + encoding: 'base58' | 'base64'; + offset: bigint; + }; + } + )[]; minContextSlot?: Slot; }; export type ProgramAccountsLoaderArgs = ProgramAccountsLoaderArgsBase & { programAddress: Address }; diff --git a/packages/rpc-graphql/src/loaders/program-accounts.ts b/packages/rpc-graphql/src/loaders/program-accounts.ts index b6ceba91bfb1..d6f04ee7c7e3 100644 --- a/packages/rpc-graphql/src/loaders/program-accounts.ts +++ b/packages/rpc-graphql/src/loaders/program-accounts.ts @@ -19,14 +19,7 @@ async function loadProgramAccounts( rpc: Rpc, { programAddress, ...config }: ProgramAccountsLoaderArgs, ): Promise { - // @ts-expect-error FIX ME: https://github.com/microsoft/TypeScript/issues/43187 - return await rpc - .getProgramAccounts( - programAddress, - // @ts-expect-error FIX ME: https://github.com/microsoft/TypeScript/issues/43187 - config, - ) - .send(); + return await rpc.getProgramAccounts(programAddress, config).send(); } function createProgramAccountsBatchLoadFn(rpc: Rpc, config: Config) { diff --git a/packages/rpc-graphql/src/resolvers/program-accounts.ts b/packages/rpc-graphql/src/resolvers/program-accounts.ts index 348984919f57..d9434a6b79c7 100644 --- a/packages/rpc-graphql/src/resolvers/program-accounts.ts +++ b/packages/rpc-graphql/src/resolvers/program-accounts.ts @@ -12,7 +12,8 @@ export function resolveProgramAccounts(fieldName?: string) { parent: { [x: string]: Address }, args: { commitment?: Commitment; - filters?: readonly { memcmp: { bytes: string; offset: number } }[]; + dataSizeFilters?: { dataSize: bigint }[]; + memcmpFilters?: { bytes: string; encoding: 'base58' | 'base64'; offset: bigint }[]; minContextSlot?: Slot; programAddress: Address; }, @@ -20,9 +21,36 @@ export function resolveProgramAccounts(fieldName?: string) { info: GraphQLResolveInfo, ): Promise => { const programAddress = fieldName ? parent[fieldName] : args.programAddress; + let filters: + | ( + | { + dataSize: bigint; + } + | { + memcmp: { + bytes: string; + encoding: 'base58' | 'base64'; + offset: bigint; + }; + } + )[] + | undefined = []; + if (args.memcmpFilters) { + filters.concat( + args.memcmpFilters.map(memcmpFilter => ({ + memcmp: memcmpFilter, + })), + ); + } + if (args.dataSizeFilters) { + filters = filters.concat(args.dataSizeFilters); + } + if (filters.length === 0) { + filters = undefined; + } if (programAddress) { - const argsSet = buildProgramAccountsLoaderArgSetFromResolveInfo({ ...args, programAddress }, info); + const argsSet = buildProgramAccountsLoaderArgSetFromResolveInfo({ ...args, filters, programAddress }, info); const loadedProgramAccountsLists = await context.loaders.programAccounts.loadMany(argsSet); const result: { diff --git a/packages/rpc-graphql/src/resolvers/resolve-info/program-accounts.ts b/packages/rpc-graphql/src/resolvers/resolve-info/program-accounts.ts index 10353684244a..118028380741 100644 --- a/packages/rpc-graphql/src/resolvers/resolve-info/program-accounts.ts +++ b/packages/rpc-graphql/src/resolvers/resolve-info/program-accounts.ts @@ -12,7 +12,18 @@ import { buildAccountArgSetWithVisitor } from './account'; export function buildProgramAccountsLoaderArgSetFromResolveInfo( args: { commitment?: Commitment; - filters?: readonly { memcmp: { bytes: string; offset: number } }[]; + filters?: ( + | { + dataSize: bigint; + } + | { + memcmp: { + bytes: string; + encoding: 'base58' | 'base64'; + offset: bigint; + }; + } + )[]; minContextSlot?: Slot; programAddress: Address; }, diff --git a/packages/rpc-graphql/src/schema/type-defs/root.ts b/packages/rpc-graphql/src/schema/type-defs/root.ts index 3a21d1a1e60f..97411fc01b3a 100644 --- a/packages/rpc-graphql/src/schema/type-defs/root.ts +++ b/packages/rpc-graphql/src/schema/type-defs/root.ts @@ -5,7 +5,8 @@ export const rootTypeDefs = /* GraphQL */ ` programAccounts( programAddress: Address! commitment: Commitment - filters: [ProgramAccountsFilter] + dataSizeFilters: [ProgramAccountsDataSizeFilter] + memcmpFilters: [ProgramAccountsMemcmpFilter] minContextSlot: Slot ): [Account] transaction(signature: Signature!, commitment: CommitmentWithoutProcessed): Transaction diff --git a/packages/rpc-graphql/src/schema/type-defs/types.ts b/packages/rpc-graphql/src/schema/type-defs/types.ts index 0f6adbbda17d..56f0a4527538 100644 --- a/packages/rpc-graphql/src/schema/type-defs/types.ts +++ b/packages/rpc-graphql/src/schema/type-defs/types.ts @@ -37,11 +37,19 @@ export const typeTypeDefs = /* GraphQL */ ` scalar Lamports - input ProgramAccountsFilter { - bytes: BigInt - dataSize: BigInt - encoding: AccountEncoding - offset: BigInt + input ProgramAccountsDataSizeFilter { + dataSize: BigInt! + } + + enum ProgramAccountsMemcmpFilterAccountEncoding { + BASE_58 + BASE_64 + } + + input ProgramAccountsMemcmpFilter { + bytes: String! + encoding: ProgramAccountsMemcmpFilterAccountEncoding! + offset: BigInt! } type ReturnData { diff --git a/packages/rpc-graphql/src/schema/type-resolvers/types.ts b/packages/rpc-graphql/src/schema/type-resolvers/types.ts index 53c41e705cc0..d0ac83c433fb 100644 --- a/packages/rpc-graphql/src/schema/type-resolvers/types.ts +++ b/packages/rpc-graphql/src/schema/type-resolvers/types.ts @@ -55,6 +55,10 @@ export const typeTypeResolvers = { Epoch: bigIntScalarAlias, Hash: stringScalarAlias, Lamports: bigIntScalarAlias, + ProgramAccountsMemcmpFilterAccountEncoding: { + BASE_58: 'base58', + BASE_64: 'base64', + }, Signature: stringScalarAlias, Slot: bigIntScalarAlias, SplTokenDefaultAccountState: {