Repository: alchemyplatform/alchemy-sdk-js (ts)

Issue:
getOwnersForContract Function doesn't support pagination
As per docs This API supports pagination https://docs.alchemy.com/reference/getownersforcollection,
But SDK function getOwnersForContract doesn't support pagination.

Pull request description:
Add withTokenBalances and pagination to getOwnersForContract()
Fixes #115.

This PR adds support for `withTokenBalances` and the resulting `pageKey`.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f8f516fa..51456468 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,10 +7,12 @@
 - POTENTIALLY BREAKING: Fixed a typing bug where the `totalSupply` field in an `NftContract` should have type `string` instead of `number`.
 - Updated the `Nft` class to include the contract metadata in the `Nft.contract` field.
 - Added commonly used utility methods from ethers.js into a top-level `Utils` export.
+- Added the `NftNamespace.verifyNftOwnership()` method to replace the deprecated `checkNftOwnership()` method. 
 - Added `Contract` and `ContractFactory` exports from ethers into the top-level exports.  
 
 ### Minor Changes
 
+- Added support for `withTokenBalances` in `NftNamespace.getOwnersForContract()`.
 - Added support for the `erc20` token type and pagination for `CoreNamespace.getTokenBalances()`.
 - Added `bytes` field to the `Media` object in the NFT metadata responses to indicate the size of the media in bytes. Note that the `size` field is not supported by the backend and will be removed in the next version.  
 
diff --git a/README.md b/README.md
index 2674ed18..989821cf 100644
--- a/README.md
+++ b/README.md
@@ -86,6 +86,9 @@ It also includes the majority of Alchemy Enhanced APIs, including:
 - `getAssetTransfers()`: Get transactions for specific addresses.
 - `getTransactionReceipts()`: Gets all transaction receipts for a given block.
 
+You will also find the following utility methods:
+- `findContractDeployer()`: Find the contract deployer and block number for a given contract address.
+
 ### Accessing the full Ethers.js Provider
 
 To keep the package clean, we don't support certain uncommonly-used Ethers.js Provider methods as top-level methods in the Alchemy `core` namespace - for example, `provider.formatter`. If you'd like to access these methods, simply use the `alchemy.config.getProvider()` function to configure the
@@ -170,10 +173,9 @@ under the `alchemy.nft` namespace:
   automatically).
 - `getOwnersForNft()`: Get all the owners for a given NFT contract address and a particular token ID.
 - `getOwnersForContract()`: Get all the owners for a given NFT contract address.
-- `checkNftOwnership()`: Check that the provided owner address owns one or more of the provided NFT contract addresses.
+- `verifyNftOwnership()`: Check whether the provided owner address owns the provided NFT contract addresses.
 - `isSpamContract()`: Check whether the given NFT contract address is a spam contract as defined by Alchemy (see the [NFT API FAQ](https://docs.alchemy.com/alchemy/enhanced-apis/nft-api/nft-api-faq#nft-spam-classification))
 - `getSpamContracts()`: Returns a list of all spam contracts marked by Alchemy.
-- `findContractDeployer()`: Find the contract deployer and block number for a given NFT contract address.
 - `refreshNftMetadata()`: Refresh the cached NFT metadata for a contract address and a single tokenId.
 - `refreshContract()`: Enqueues the specified contract address to have all token ids' metadata refreshed.
 - `getFloorPrice()`: Return the floor prices of a NFT contract by marketplace.
diff --git a/src/api/nft-namespace.ts b/src/api/nft-namespace.ts
index 92c197cb..b82e4f6e 100644
--- a/src/api/nft-namespace.ts
+++ b/src/api/nft-namespace.ts
@@ -5,7 +5,10 @@ import {
   GetFloorPriceResponse,
   GetNftsForContractOptions,
   GetNftsForOwnerOptions,
+  GetOwnersForContractOptions,
   GetOwnersForContractResponse,
+  GetOwnersForContractWithTokenBalancesOptions,
+  GetOwnersForContractWithTokenBalancesResponse,
   GetOwnersForNftResponse,
   NftContractBaseNftsResponse,
   NftContractNftsResponse,
@@ -32,7 +35,8 @@ import {
   getSpamContracts,
   isSpamContract,
   refreshContract,
-  refreshNftMetadata
+  refreshNftMetadata,
+  verifyNftOwnership
 } from '../internal/nft-api';
 
 /**
@@ -227,16 +231,42 @@ export class NftNamespace {
     return getNftsForContractIterator(this.config, contractAddress, options);
   }
 
+  /**
+   * Gets all the owners for a given NFT contract along with the token balance.
+   *
+   * @param contractAddress - The NFT contract to get the owners for.
+   * @param options Optional parameters to use for the request.
+   * @public
+   */
+  getOwnersForContract(
+    contractAddress: string,
+    options: GetOwnersForContractWithTokenBalancesOptions
+  ): Promise<GetOwnersForContractWithTokenBalancesResponse>;
+
   /**
    * Gets all the owners for a given NFT contract.
    *
+   * Note that token balances are omitted by default. To include token balances
+   * for each owner, use {@link GetOwnersForContractWithTokenBalancesOptions},
+   * which has the `withTokenBalances` field set to `true`.
+   *
    * @param contractAddress - The NFT contract to get the owners for.
-   * @beta
+   * @param options Optional parameters to use for the request.
+   * @public
    */
   getOwnersForContract(
-    contractAddress: string
-  ): Promise<GetOwnersForContractResponse> {
-    return getOwnersForContract(this.config, contractAddress);
+    contractAddress: string,
+    options?: GetOwnersForContractOptions
+  ): Promise<GetOwnersForContractResponse>;
+  getOwnersForContract(
+    contractAddress: string,
+    options?:
+      | GetOwnersForContractOptions
+      | GetOwnersForContractWithTokenBalancesOptions
+  ): Promise<
+    GetOwnersForContractResponse | GetOwnersForContractWithTokenBalancesResponse
+  > {
+    return getOwnersForContract(this.config, contractAddress, options);
   }
 
   /**
@@ -254,11 +284,13 @@ export class NftNamespace {
   }
 
   /**
-   * Checks that the provided owner address owns one of more of the provided NFTs.
+   * DEPRECATED - Checks that the provided owner address owns one of more of the
+   * provided NFTs.
    *
+   * @deprecated - Use {@link verifyNftOwnership} instead. This method will be
+   *   removed in a future release.
    * @param owner - The owner address to check.
    * @param contractAddresses - An array of NFT contract addresses to check ownership for.
-   * @beta
    */
   checkNftOwnership(
     owner: string,
@@ -267,6 +299,33 @@ export class NftNamespace {
     return checkNftOwnership(this.config, owner, contractAddresses);
   }
 
+  /**
+   * Checks that the provided owner address owns one of more of the provided
+   * NFT. Returns a boolean indicating whether the owner address owns the provided NFT.
+   *
+   * @param owner - The owner address to check.
+   * @param contractAddress - An NFT contract address to check ownership for.
+   */
+  verifyNftOwnership(owner: string, contractAddress: string): Promise<boolean>;
+
+  /**
+   * Checks which of the provided NFTs the owner address owns. Returns a map of
+   * contract address to a boolean indicating whether the owner address owns the NFT.
+   *
+   * @param owner - The owner address to check.
+   * @param contractAddresses - An array NFT contract address to check ownership for.
+   */
+  verifyNftOwnership(
+    owner: string,
+    contractAddresses: string[]
+  ): Promise<{ [contractAddress: string]: boolean }>;
+  verifyNftOwnership(
+    owner: string,
+    contractAddress: string | string[]
+  ): Promise<boolean | { [contractAddress: string]: boolean }> {
+    return verifyNftOwnership(this.config, owner, contractAddress);
+  }
+
   /**
    * Returns whether a contract is marked as spam or not by Alchemy. For more
    * information on how we classify spam, go to our NFT API FAQ at
diff --git a/src/internal/nft-api.ts b/src/internal/nft-api.ts
index d3b9f782..24088ffe 100644
--- a/src/internal/nft-api.ts
+++ b/src/internal/nft-api.ts
@@ -5,7 +5,10 @@ import {
   GetFloorPriceResponse,
   GetNftsForContractOptions,
   GetNftsForOwnerOptions,
+  GetOwnersForContractOptions,
   GetOwnersForContractResponse,
+  GetOwnersForContractWithTokenBalancesOptions,
+  GetOwnersForContractWithTokenBalancesResponse,
   GetOwnersForNftResponse,
   NftContractBaseNftsResponse,
   NftContractNftsResponse,
@@ -199,17 +202,27 @@ export async function* getNftsForContractIterator(
 export async function getOwnersForContract(
   config: AlchemyConfig,
   contractAddress: string,
+  options?:
+    | GetOwnersForContractWithTokenBalancesOptions
+    | GetOwnersForContractOptions,
   srcMethod = 'getOwnersForContract'
-): Promise<GetOwnersForContractResponse> {
-  const response = await requestHttpWithBackoff<
+): Promise<
+  GetOwnersForContractResponse | GetOwnersForContractWithTokenBalancesResponse
+> {
+  // Cast to `any` to avoid more type wrangling.
+  const response: any = await requestHttpWithBackoff<
     GetOwnersForNftContractAlchemyParams,
     RawGetOwnersForContractResponse
   >(config, AlchemyApiType.NFT, 'getOwnersForCollection', srcMethod, {
+    ...options,
     contractAddress
   });
 
   return {
-    owners: response.ownerAddresses
+    owners: response.ownerAddresses,
+
+    // Only include the pageKey in the final response if it's defined
+    ...(response.pageKey !== undefined && { pageKey: response.pageKey })
   };
 }
 
@@ -252,6 +265,53 @@ export async function checkNftOwnership(
   return response.ownedNfts.length > 0;
 }
 
+export async function verifyNftOwnership(
+  config: AlchemyConfig,
+  owner: string,
+  contractAddresses: string | string[],
+  srcMethod = 'verifyNftOwnership'
+): Promise<boolean | { [contractAddress: string]: boolean }> {
+  if (typeof contractAddresses === 'string') {
+    const response = await getNftsForOwner(
+      config,
+      owner,
+      {
+        contractAddresses: [contractAddresses],
+        omitMetadata: true
+      },
+      srcMethod
+    );
+    return response.ownedNfts.length > 0;
+  } else {
+    if (contractAddresses.length === 0) {
+      throw new Error('Must provide at least one contract address');
+    }
+    const response = await getNftsForOwner(
+      config,
+      owner,
+      {
+        contractAddresses,
+        omitMetadata: true
+      },
+      srcMethod
+    );
+
+    // Create map where all input contract addresses are set to false, then flip
+    // owned nfts to true.
+    const result = contractAddresses.reduce(
+      (acc: { [contractAddress: string]: boolean }, curr) => {
+        acc[curr] = false;
+        return acc;
+      },
+      {}
+    );
+    for (const nft of response.ownedNfts) {
+      result[nft.contract.address] = true;
+    }
+    return result;
+  }
+}
+
 export async function isSpamContract(
   config: AlchemyConfig,
   contractAddress: string,
diff --git a/src/internal/raw-interfaces.ts b/src/internal/raw-interfaces.ts
index 293a169f..7e560165 100644
--- a/src/internal/raw-interfaces.ts
+++ b/src/internal/raw-interfaces.ts
@@ -174,6 +174,21 @@ export interface RawGetOwnersForContractResponse {
   ownerAddresses: string[];
 }
 
+export interface RawGetOwnersForContractWithTokenBalancesResponse {
+  ownerAddresses: RawOwnerAddress[];
+  pageKey?: string;
+}
+
+export interface RawOwnerAddress {
+  ownerAddress: string;
+  tokenBalances: RawTokenBalances[];
+}
+
+export interface RawTokenBalances {
+  tokenId: string;
+  balance: number;
+}
+
 export interface RawReingestContractResponse {
   contractAddress: string;
   reingestionState: string;
diff --git a/src/types/types.ts b/src/types/types.ts
index f18008a4..11f892f2 100644
--- a/src/types/types.ts
+++ b/src/types/types.ts
@@ -647,7 +647,44 @@ export interface GetOwnersForNftResponse {
  */
 export interface GetOwnersForContractResponse {
   /** An array of owner addresses for the provided contract address */
-  readonly owners: string[];
+  owners: string[];
+}
+
+/**
+ * The response object for the {@link getOwnersForContract}.
+ *
+ * @public
+ */
+export interface GetOwnersForContractWithTokenBalancesResponse {
+  /** An array of owner addresses for the provided contract address */
+  owners: NftContractOwner[];
+
+  /** Optional page key that is returned when a collection has more than 50,000 owners. */
+  pageKey?: string;
+}
+
+/**
+ * An object representing the owner of an NFT and its corresponding token
+ * balances in a {@link GetOwnersForContractWithTokenBalancesResponse} object.
+ */
+export interface NftContractOwner {
+  /** The NFT's owner address. */
+  ownerAddress: string;
+
+  /** A list of objects containing token balances for the provided NFT contract. */
+  tokenBalances: NftContractTokenBalance[];
+}
+
+/**
+ * An object representing the owned token and balance values in a
+ * {@link GetOwnersForContractWithTokenBalancesResponse} object.
+ */
+export interface NftContractTokenBalance {
+  /** The token id owned in the NFT contract. */
+  tokenId: string;
+
+  /** The token Id balance for the provided owner. */
+  balance: number;
 }
 
 /**
@@ -861,6 +898,50 @@ export interface GetBaseNftsForContractOptions {
   pageSize?: number;
 }
 
+/**
+ * Optional parameters object for the {@link getNftsForContract} method.
+ *
+ * This interface configures options when fetching the owner addresses of the
+ * provided contract.
+ *
+ * @public
+ */
+export interface GetOwnersForContractOptions {
+  /**
+   * Whether to include the token balances per token id for each owner. Defaults
+   * to false when omitted.
+   */
+  withTokenBalances?: boolean;
+
+  /** The block number in hex or decimal to fetch owners for. */
+  block?: string;
+
+  /** Optional page key to paginate the next page for large requests. */
+  pageKey?: string;
+}
+
+/**
+ * Optional parameters object for the {@link getNftsForContract} method.
+ *
+ * This interface configures options when fetching the owner addresses of the
+ * provided contract.
+ *
+ * @public
+ */
+export interface GetOwnersForContractWithTokenBalancesOptions {
+  /**
+   * Whether to include the token balances per token id for each owner. Defaults
+   * to false when omitted.
+   */
+  withTokenBalances: true;
+
+  /** The block number in hex or decimal to fetch owners for. */
+  block?: string;
+
+  /** Optional page key to paginate the next page for large requests. */
+  pageKey?: string;
+}
+
 /**
  * The response object for the {@link getNftsForContract} function. The object
  * contains the NFTs without metadata inside the NFT contract.


Test patch:
diff --git a/test/integration/integration.test.ts b/test/integration/integration.test.ts
index bc24f408..2e7d89d7 100644
--- a/test/integration/integration.test.ts
+++ b/test/integration/integration.test.ts
@@ -156,12 +156,25 @@ describe('E2E integration tests', () => {
     ).toBeGreaterThan(0);
   });
 
-  it('getOwnersForNftContract', async () => {
+  it('getOwnersForContract', async () => {
     const response = await alchemy.nft.getOwnersForContract(contractAddress);
     expect(response.owners.length).toBeGreaterThan(0);
   });
 
-  it('getNftsForNftContract with pageKey', async () => {
+  it('getOwnersForContract', async () => {
+    const address = '0x57f1887a8BF19b14fC0dF6Fd9B2acc9Af147eA85';
+    const response = await alchemy.nft.getOwnersForContract(address, {
+      withTokenBalances: true
+    });
+
+    expect(response.owners.length).toBeGreaterThan(0);
+    expect(response.owners[0].tokenBalances.length).toBeGreaterThan(0);
+    expect(typeof response.owners[0].tokenBalances[0].balance).toEqual(
+      'string'
+    );
+  });
+
+  it('getNftsForContract with pageKey', async () => {
     const nftsForNftContract = await alchemy.nft.getNftsForContract(
       contractAddress
     );
diff --git a/test/unit/nft-api.test.ts b/test/unit/nft-api.test.ts
index ffa4c3a5..4153735d 100644
--- a/test/unit/nft-api.test.ts
+++ b/test/unit/nft-api.test.ts
@@ -4,6 +4,7 @@ import {
   fromHex,
   GetFloorPriceResponse,
   GetNftsForOwnerOptions,
+  GetOwnersForContractWithTokenBalancesResponse,
   Nft,
   NftContract,
   NftContractBaseNftsResponse,
@@ -33,7 +34,8 @@ import {
   RawGetBaseNftsForContractResponse,
   RawGetBaseNftsResponse,
   RawGetNftsForContractResponse,
-  RawGetNftsResponse
+  RawGetNftsResponse,
+  RawGetOwnersForContractWithTokenBalancesResponse
 } from '../../src/internal/raw-interfaces';
 import { getNftContractFromRaw, getNftFromRaw } from '../../src/util/util';
 
@@ -958,7 +960,7 @@ describe('NFT module', () => {
     });
   });
 
-  describe('getOwnersForNftContract()', () => {
+  describe('getOwnersForContract()', () => {
     const contractAddress = '0xCA1';
     const tokenIdHex = '0x1b7';
     const owners = ['0x1', '0x2', '0x3'];
@@ -979,6 +981,73 @@ describe('NFT module', () => {
       expect(response).toEqual({ owners });
     });
 
+    it('handles withTokenBalances=true', async () => {
+      const mockResponse: RawGetOwnersForContractWithTokenBalancesResponse = {
+        ownerAddresses: [
+          {
+            ownerAddress: '0xABC',
+            tokenBalances: [
+              {
+                tokenId: '0x1',
+                balance: 1
+              }
+            ]
+          },
+          {
+            ownerAddress: '0xDEF',
+            tokenBalances: [
+              {
+                tokenId: '0x2',
+                balance: 2
+              }
+            ]
+          }
+        ],
+        pageKey: 'page-key2'
+      };
+      const expected: GetOwnersForContractWithTokenBalancesResponse = {
+        owners: [
+          {
+            ownerAddress: '0xABC',
+            tokenBalances: [
+              {
+                tokenId: '0x1',
+                balance: 1
+              }
+            ]
+          },
+          {
+            ownerAddress: '0xDEF',
+            tokenBalances: [
+              {
+                tokenId: '0x2',
+                balance: 2
+              }
+            ]
+          }
+        ],
+        pageKey: 'page-key2'
+      };
+      mock.reset();
+      mock.onGet().reply(200, mockResponse);
+      const response = await alchemy.nft.getOwnersForContract(contractAddress, {
+        withTokenBalances: true,
+        block: '0x0',
+        pageKey: 'page-key1'
+      });
+
+      expect(mock.history.get.length).toEqual(1);
+      expect(mock.history.get[0].params).toHaveProperty(
+        'contractAddress',
+        contractAddress
+      );
+      expect(mock.history.get[0].params).toHaveProperty(
+        'withTokenBalances',
+        true
+      );
+      expect(response).toEqual(expected);
+    });
+
     it('retries with maxAttempts', async () => {
       mock.reset();
       mock.onGet().reply(429, 'Too many requests');
@@ -989,13 +1058,19 @@ describe('NFT module', () => {
     });
   });
 
-  describe('checkNftOwnership', () => {
+  describe('verifyNftOwnership()', () => {
     const owner = '0xABC';
     const addresses = ['0xCA1', '0xCA2'];
     const emptyResponse: RawGetNftsResponse = {
       ownedNfts: [],
       totalCount: 0
     };
+    const partialResponse: RawGetNftsResponse = {
+      ownedNfts: [
+        createRawOwnedNft('b', '0xCA2', '0x2', '2', NftTokenType.ERC1155)
+      ],
+      totalCount: 1
+    };
     const nftResponse: RawGetNftsResponse = {
       ownedNfts: [
         createRawOwnedNft('a', '0xCA1', '0x1', '1'),
@@ -1006,7 +1081,7 @@ describe('NFT module', () => {
 
     it('calls with the correct parameters', async () => {
       mock.onGet().reply(200, emptyResponse);
-      await alchemy.nft.checkNftOwnership(owner, addresses);
+      await alchemy.nft.verifyNftOwnership(owner, addresses);
       expect(mock.history.get.length).toEqual(1);
       expect(mock.history.get[0].params).toHaveProperty('owner', owner);
       expect(mock.history.get[0].params).toHaveProperty(
@@ -1017,27 +1092,43 @@ describe('NFT module', () => {
     });
 
     it('throws if no contract address is passed in', async () => {
-      await expect(alchemy.nft.checkNftOwnership(owner, [])).rejects.toThrow(
+      await expect(alchemy.nft.verifyNftOwnership(owner, [])).rejects.toThrow(
         'Must provide at least one contract address'
       );
     });
 
     const cases = [
-      [emptyResponse, false],
-      [nftResponse, true]
+      [emptyResponse, { '0xCA1': false, '0xCA2': false }],
+      [partialResponse, { '0xCA1': false, '0xCA2': true }],
+      [nftResponse, { '0xCA1': true, '0xCA2': true }]
     ];
     it.each(cases)(
-      'returns the correct response',
+      'returns the correct response for arrayinputs',
       async (response, expected) => {
         mock.onGet().reply(200, response);
-        const result = await alchemy.nft.checkNftOwnership(owner, addresses);
+        const result = await alchemy.nft.verifyNftOwnership(owner, addresses);
         expect(result).toEqual(expected);
       }
     );
+
+    const cases2 = [
+      [emptyResponse, false],
+      [partialResponse, true]
+    ];
+    it.each(cases2)(
+      'returns the correct response for inputs',
+      async (response, expected) => {
+        const address = '0xCA2';
+        mock.onGet().reply(200, response);
+        const result = await alchemy.nft.verifyNftOwnership(owner, address);
+        expect(result).toEqual(expected);
+      }
+    );
+
     it('surfaces errors', async () => {
       mock.onGet().reply(500, 'Internal Server Error');
       await expect(
-        alchemy.nft.checkNftOwnership(owner, addresses)
+        alchemy.nft.verifyNftOwnership(owner, addresses)
       ).rejects.toThrow('Internal Server Error');
     });
   });