Repository: alchemyplatform/alchemy-sdk-js
Pull request #201 (2022-11-18 00:22:19)

getContractsForOwner()
### Changelog

- Adds the `getContractsForOwner` method in the nft namespace.
- Adds the `GetContractsForOwnerResponse` and `ContractForOwner` types to describe the result of the `getContractsForOwner` endpoint.
- Adds unit and integration tests for the `getContractsForOwner` method.
- Adds the `NftFilters` enum to deprecate the `NftExcludeFilters` enum as the endpoint uses the same enum values for the `excludefilters` and `includeFilters` params.

xeno097: 



> @xeno097 Thanks for taking this on, and apologies for the late review! I've been out for the past few weeks, but getting back into it. Just left a small comment, appreciate your time :)

Hey don't worry ahaha, I'm glad you're back hope everything is fine.

Patch:
diff --git a/src/api/nft-namespace.ts b/src/api/nft-namespace.ts
index 4d13c6c8..f1123a84 100644
--- a/src/api/nft-namespace.ts
+++ b/src/api/nft-namespace.ts
@@ -4,6 +4,7 @@ import {
   checkNftOwnership,
   computeRarity,
   getContractMetadata,
+  getContractsForOwner,
   getFloorPrice,
   getNftMetadata,
   getNftMetadataBatch,
@@ -25,6 +26,8 @@ import {
 import {
   GetBaseNftsForContractOptions,
   GetBaseNftsForOwnerOptions,
+  GetContractsForOwnerOptions,
+  GetContractsForOwnerResponse,
   GetFloorPriceResponse,
   GetNftSalesOptions,
   GetNftSalesOptionsByContractAddress,
@@ -309,6 +312,20 @@ export class NftNamespace {
     return getOwnersForNft(this.config, contractAddress, tokenId);
   }
 
+  /**
+   * Gets all NFT contracts held by the specified owner address.
+   *
+   * @param owner - Address for NFT owner (can be in ENS format!).
+   * @param options - The optional parameters to use for the request.
+   * @public
+   */
+  getContractsForOwner(
+    owner: string,
+    options?: GetContractsForOwnerOptions
+  ): Promise<GetContractsForOwnerResponse> {
+    return getContractsForOwner(this.config, owner, options);
+  }
+
   /**
    * DEPRECATED - Checks that the provided owner address owns one of more of the
    * provided NFTs.
diff --git a/src/internal/nft-api.ts b/src/internal/nft-api.ts
index 2f576065..98b522f9 100644
--- a/src/internal/nft-api.ts
+++ b/src/internal/nft-api.ts
@@ -5,6 +5,8 @@ import { BaseNft, Nft, NftContract } from '../api/nft';
 import {
   GetBaseNftsForContractOptions,
   GetBaseNftsForOwnerOptions,
+  GetContractsForOwnerOptions,
+  GetContractsForOwnerResponse,
   GetFloorPriceResponse,
   GetNftSalesOptions,
   GetNftSalesOptionsByContractAddress,
@@ -20,6 +22,7 @@ import {
   NftAttributesResponse,
   NftContractBaseNftsResponse,
   NftContractNftsResponse,
+  NftFilters,
   NftMetadataBatchOptions,
   NftMetadataBatchToken,
   NftSaleMarketplace,
@@ -36,6 +39,7 @@ import {
 import { AlchemyApiType } from '../util/const';
 import {
   getBaseNftFromRaw,
+  getContractsForOwnerFromRaw,
   getNftContractFromRaw,
   getNftFromRaw,
   getNftRarityFromRaw,
@@ -47,6 +51,7 @@ import {
   RawContractBaseNft,
   RawGetBaseNftsForContractResponse,
   RawGetBaseNftsResponse,
+  RawGetContractsForOwnerResponse,
   RawGetNftSalesResponse,
   RawGetNftsForContractResponse,
   RawGetNftsResponse,
@@ -262,6 +267,25 @@ export async function getOwnersForContract(
   };
 }
 
+export async function getContractsForOwner(
+  config: AlchemyConfig,
+  owner: string,
+  options?: GetContractsForOwnerOptions,
+  srcMethod = 'getContractsForOwner'
+): Promise<GetContractsForOwnerResponse> {
+  const response = await requestHttpWithBackoff<
+    GetOwnersForContractParams,
+    RawGetContractsForOwnerResponse
+  >(config, AlchemyApiType.NFT, 'getContractsForOwner', srcMethod, {
+    owner,
+    excludeFilters: options?.excludeFilters,
+    includeFilters: options?.includeFilters,
+    pageKey: options?.pageKey
+  });
+
+  return getContractsForOwnerFromRaw(response);
+}
+
 export async function getOwnersForNft(
   config: AlchemyConfig,
   contractAddress: string,
@@ -678,6 +702,18 @@ interface GetOwnersForNftContractAlchemyParams {
   contractAddress: string;
 }
 
+/**
+ * Interface for the `getOwnersForContract` endpoint.
+ *
+ * @internal
+ */
+interface GetOwnersForContractParams {
+  owner: string;
+  pageKey?: string;
+  includeFilters?: NftFilters[];
+  excludeFilters?: NftFilters[];
+}
+
 /**
  * Interface for the `getFloorPrice` endpoint.
  *
diff --git a/src/internal/raw-interfaces.ts b/src/internal/raw-interfaces.ts
index 14d08cfb..71606b85 100644
--- a/src/internal/raw-interfaces.ts
+++ b/src/internal/raw-interfaces.ts
@@ -296,3 +296,20 @@ export interface RawNftSale {
   bundleIndex: number;
   transactionHash: string;
 }
+
+export interface RawGetContractsForOwnerResponse {
+  contracts: RawContractForOwner[];
+  pageKey?: string;
+  totalCount: number;
+}
+
+export interface RawContractForOwner
+  extends Omit<RawNftContractMetadata, 'openSea'> {
+  address: string;
+  totalBalance: number;
+  numDistinctTokensOwned: number;
+  isSpam: boolean;
+  tokenId: string;
+  media: Media;
+  opensea?: RawOpenSeaCollectionMetadata;
+}
diff --git a/src/types/types.ts b/src/types/types.ts
index 6d9386ad..96858222 100644
--- a/src/types/types.ts
+++ b/src/types/types.ts
@@ -4,7 +4,7 @@ import {
 } from '@ethersproject/abstract-provider';
 import { BigNumberish } from '@ethersproject/bignumber';
 
-import { BaseNft, Nft } from '../api/nft';
+import { BaseNft, Nft, NftContract } from '../api/nft';
 
 // TODO: separate this file into other files.
 
@@ -301,9 +301,9 @@ export enum AssetTransfersCategory {
  * Enum for the order of the {@link AssetTransfersParams} request object when
  * using {@link CoreNamespace.getAssetTransfers}.
  *
- * @public
  * @deprecated Use {@link SortingOrder} instead. This enum will be removed in a
- * future version.
+ *   future version.
+ * @public
  */
 export enum AssetTransfersOrder {
   ASCENDING = 'asc',
@@ -541,7 +541,7 @@ export interface GetNftsForOwnerOptions {
    * Optional list of filters applied to the query. NFTs that match one or more
    * of these filters are excluded from the response.
    */
-  excludeFilters?: NftExcludeFilters[];
+  excludeFilters?: (NftExcludeFilters | NftFilters)[];
 
   /**
    * Sets the total number of NFTs to return in the response. Defaults to 100.
@@ -584,7 +584,7 @@ export interface GetBaseNftsForOwnerOptions {
    * Optional list of filters applied to the query. NFTs that match one or more
    * of these filters are excluded from the response.
    */
-  excludeFilters?: NftExcludeFilters[];
+  excludeFilters?: (NftExcludeFilters | NftFilters)[];
 
   /**
    * Sets the total number of NFTs to return in the response. Defaults to 100.
@@ -608,6 +608,9 @@ export interface GetBaseNftsForOwnerOptions {
  * Enum of NFT filters that can be applied to a {@link getNftsForOwner} request.
  * NFTs that match one or more of these filters are excluded from the response.
  *
+ * - @deprecated Use {@link NftFilters} instead. This enum will be removed in a
+ *   future version.
+ *
  * @beta
  */
 export enum NftExcludeFilters {
@@ -618,6 +621,20 @@ export enum NftExcludeFilters {
   AIRDROPS = 'AIRDROPS'
 }
 
+/**
+ * Enum of NFT filters that can be applied to a {@link getNftsForOwner} or a
+ * {@link getContractsForOwner} request.
+ *
+ * @beta
+ */
+export enum NftFilters {
+  /** NFTs that have been classified as spam. */
+  SPAM = 'SPAM',
+
+  /** NFTs that have been airdropped to a user. */
+  AIRDROPS = 'AIRDROPS'
+}
+
 /**
  * The response object for the {@link getNftsForOwner} and
  * {@link getNftsForOwnerIterator} functions. The object contains the NFTs with
@@ -813,6 +830,71 @@ export interface GetFloorPriceResponse {
   readonly looksRare: FloorPriceMarketplace | FloorPriceError;
 }
 
+/**
+ * Optional parameters object for the the {@link getContractsForOwner} method
+ *
+ * @public
+ */
+export interface GetContractsForOwnerOptions {
+  /** Key for pagination to use to fetch results from the next page if available. */
+  pageKey?: string;
+
+  /**
+   * Optional list of filters applied to the query. NFTs that match one or more
+   * of these filters are included in the response. May not be used in
+   * conjunction with {@link excludeFilters}.
+   */
+  includeFilters?: NftFilters[];
+
+  /**
+   * Optional list of filters applied to the query. NFTs that match one or more
+   * of these filters are excluded from the response. May not be used in
+   * conjunction with {@link includeFilters}
+   */
+  excludeFilters?: NftFilters[];
+}
+
+/**
+ * The response for the {@link NftNamespace.getContractsForOwner} method.
+ *
+ * @public
+ */
+export interface GetContractsForOwnerResponse {
+  /** The list of contracts, that match the query, held by the given address. */
+  contracts: ContractForOwner[];
+
+  /** Key for pagination to use to fetch results from the next page if available. */
+  pageKey?: string;
+
+  /** Total number of NFT contracts held by the given address. */
+  totalCount: number;
+}
+
+/** Represents a single NFT contract data in the {@link GetContractsForOwnerResponse}. */
+export interface ContractForOwner extends NftContract {
+  /**
+   * Sum of NFT balances across all token IDs held by the owner. For
+   * non-fungible tokens this will be equal to the numDistinctTokensOwned, but
+   * it may be higher if the user holds some fungible ERC1155 tokens.
+   */
+  totalBalance: number;
+
+  /**
+   * Number of distinct token IDs held by the owner. For non-fungible tokens
+   * this will be equal to the totalBalance, but it may be lower if the user
+   * holds some fungible ERC1155 tokens.
+   */
+  numDistinctTokensOwned: number;
+
+  isSpam: boolean;
+
+  /** One of the tokens from this contract held by the owner. */
+  tokenId: string;
+
+  /** Alternative NFT metadata for this contract to be parsed manually. */
+  media: Media;
+}
+
 /**
  * Optional parameters object for the {@link getNftSales} endpoint.
  *
diff --git a/src/util/util.ts b/src/util/util.ts
index 318d64a4..33e8c49d 100644
--- a/src/util/util.ts
+++ b/src/util/util.ts
@@ -5,6 +5,7 @@ import { toHex } from '../api/util';
 import {
   RawBaseNft,
   RawContractBaseNft,
+  RawGetContractsForOwnerResponse,
   RawGetNftSalesResponse,
   RawNft,
   RawNftAttributeRarity,
@@ -13,6 +14,7 @@ import {
   RawSpamInfo
 } from '../internal/raw-interfaces';
 import {
+  GetContractsForOwnerResponse,
   GetNftSalesResponse,
   NftAttributeRarity,
   NftSaleMarketplace,
@@ -160,6 +162,30 @@ export function getNftRarityFromRaw(
   }));
 }
 
+export function getContractsForOwnerFromRaw(
+  rawContractsForOwner: RawGetContractsForOwnerResponse
+): GetContractsForOwnerResponse {
+  return {
+    pageKey: rawContractsForOwner?.pageKey,
+    totalCount: rawContractsForOwner.totalCount,
+    contracts: rawContractsForOwner.contracts.map(contract => {
+      return {
+        address: contract.address,
+        totalSupply: contract.totalSupply,
+        isSpam: contract.isSpam,
+        media: contract.media,
+        numDistinctTokensOwned: contract.numDistinctTokensOwned,
+        tokenId: contract.tokenId,
+        totalBalance: contract.totalBalance,
+        name: contract.name,
+        openSea: parseOpenSeaMetadata(contract?.opensea),
+        symbol: contract?.symbol,
+        tokenType: parseNftTokenType(contract?.tokenType)
+      };
+    })
+  };
+}
+
 function parseNftTokenId(tokenId: string): string {
   // We have to normalize the token id here since the backend sometimes
   // returns the token ID as a hex string and sometimes as an integer.
@@ -219,7 +245,7 @@ function parseNftTokenUriArray(arr: TokenUri[] | undefined): TokenUri[] {
   return arr.filter(uri => parseNftTokenUri(uri) !== undefined);
 }
 
-function parseOpenSeaMetadata(
+export function parseOpenSeaMetadata(
   openSea: RawOpenSeaCollectionMetadata | undefined
 ): OpenSeaCollectionMetadata | undefined {
   if (openSea === undefined) {


Test patch:
diff --git a/test/integration/nft.test.ts b/test/integration/nft.test.ts
index 273cd8de..0395543b 100644
--- a/test/integration/nft.test.ts
+++ b/test/integration/nft.test.ts
@@ -1,10 +1,10 @@
 import {
   Alchemy,
-  NftExcludeFilters,
+  fromHex,
+  NftFilters,
   NftSaleMarketplace,
   NftTokenType,
-  OpenSeaSafelistRequestStatus,
-  fromHex
+  OpenSeaSafelistRequestStatus
 } from '../../src';
 import { loadAlchemyEnv } from '../test-util';
 
@@ -14,6 +14,7 @@ jest.setTimeout(50000);
 // regressions in the backend.
 describe('E2E integration tests', () => {
   let alchemy: Alchemy;
+  const ownerEns = 'vitalik.eth';
   const ownerAddress = '0x65d25E3F2696B73b850daA07Dd1E267dCfa67F2D';
   const contractAddress = '0x01234567bac6ff94d7e4f0ee23119cf848f93245';
 
@@ -91,13 +92,13 @@ describe('E2E integration tests', () => {
 
   it('getOwnersForNft() from NFT', async () => {
     const nfts = await alchemy.nft.getNftsForOwner(ownerAddress, {
-      excludeFilters: [NftExcludeFilters.SPAM],
+      excludeFilters: [NftFilters.SPAM],
       omitMetadata: true
     });
     expect(nfts.ownedNfts.length).toBeGreaterThan(0);
 
     const nfts2 = await alchemy.nft.getNftsForOwner(ownerAddress, {
-      excludeFilters: [NftExcludeFilters.AIRDROPS],
+      excludeFilters: [NftFilters.AIRDROPS],
       omitMetadata: true
     });
 
@@ -112,7 +113,7 @@ describe('E2E integration tests', () => {
   it('getNftsForOwner() spam check', async () => {
     const withSpam = await alchemy.nft.getNftsForOwner('vitalik.eth');
     const noSpam = await alchemy.nft.getNftsForOwner('vitalik.eth', {
-      excludeFilters: [NftExcludeFilters.SPAM]
+      excludeFilters: [NftFilters.SPAM]
     });
     expect(withSpam.totalCount).not.toEqual(noSpam.totalCount);
   });
@@ -162,6 +163,63 @@ describe('E2E integration tests', () => {
     );
   });
 
+  it('getContractsForOwner()', async () => {
+    const response = await alchemy.nft.getContractsForOwner(ownerAddress);
+
+    expect(response.contracts.length).toBeGreaterThan(0);
+    expect(response.contracts[0].address).toBeDefined();
+    expect(typeof response.contracts[0].address).toEqual('string');
+    expect(response.contracts[0].isSpam).toBeDefined();
+    expect(typeof response.contracts[0].isSpam).toEqual('boolean');
+    expect(response.contracts[0].media).toBeDefined();
+    expect(response.contracts[0].numDistinctTokensOwned).toBeDefined();
+    expect(typeof response.contracts[0].numDistinctTokensOwned).toEqual(
+      'number'
+    );
+    expect(response.contracts[0].totalBalance).toBeDefined();
+    expect(typeof response.contracts[0].totalBalance).toEqual('number');
+    expect(response.contracts[0].tokenId).toBeDefined();
+    expect(typeof response.contracts[0].tokenId).toEqual('string');
+  });
+
+  it('getContractsForOwner() with pageKey', async () => {
+    const firstPage = await alchemy.nft.getContractsForOwner(ownerEns);
+
+    expect(firstPage.pageKey).toBeDefined();
+    expect(typeof firstPage.pageKey).toEqual('string');
+
+    const response = await alchemy.nft.getContractsForOwner(ownerEns, {
+      pageKey: firstPage?.pageKey
+    });
+
+    expect(response.contracts).not.toEqual(firstPage.contracts);
+  });
+
+  it.each(Object.values(NftFilters))(
+    `getContractsForOwner() with includeFilters=[%s]`,
+    async includeFilter => {
+      const expectedIsSpam = includeFilter === NftFilters.SPAM;
+
+      const response = await alchemy.nft.getContractsForOwner(ownerAddress, {
+        includeFilters: [includeFilter]
+      });
+
+      response.contracts.forEach(nftSale => {
+        expect(nftSale.isSpam).toBe(expectedIsSpam);
+      });
+    }
+  );
+
+  it(`getContractsForOwner() with excludeFilter=[${NftFilters.SPAM}]`, async () => {
+    const response = await alchemy.nft.getContractsForOwner(ownerAddress, {
+      excludeFilters: [NftFilters.SPAM]
+    });
+
+    response.contracts.forEach(nftSale => {
+      expect(nftSale.isSpam).toBe(false);
+    });
+  });
+
   it('getNftsForContract() with pageKey', async () => {
     const nftsForNftContract = await alchemy.nft.getNftsForContract(
       contractAddress
@@ -367,7 +425,7 @@ describe('E2E integration tests', () => {
     expect(response[0].address).toBeDefined();
     expect(typeof response[0].address).toEqual('string');
     expect(response[0].tokenType).toBeDefined();
-    expect(response[0].tokenType).toEqual(NftTokenType.ERC721);
+    expect(response[0].tokenType).toEqual(NftTokenType.ERC1155);
   });
 
   it('summarizeNftAttributes()', async () => {
diff --git a/test/test-util.ts b/test/test-util.ts
index ffca831a..7239f7e8 100644
--- a/test/test-util.ts
+++ b/test/test-util.ts
@@ -11,11 +11,13 @@ import {
   TokenUri,
   toHex,
   NftSaleTakerType,
-  NftSaleMarketplace
+  NftSaleMarketplace,
+  Media
 } from '../src';
 import {
   RawBaseNft,
   RawContractBaseNft,
+  RawContractForOwner,
   RawNft,
   RawNftContract,
   RawNftContractMetadata,
@@ -53,6 +55,20 @@ export function createRawNftContract(
   };
 }
 
+export function createRawOpenSeaCollectionMetadata(): RawOpenSeaCollectionMetadata {
+  return {
+    floorPrice: 2.2998,
+    collectionName: 'Collection Name',
+    safelistRequestStatus: 'verified',
+    imageUrl: 'http://image.url',
+    description: 'A sample description',
+    externalUrl: 'http://external.url',
+    twitterUsername: 'twitter-handle',
+    discordUrl: 'https://discord.gg/example',
+    lastIngestedAt: '2022-10-26T22:24:49.000Z'
+  };
+}
+
 export function createRawOwnedBaseNft(
   address: string,
   tokenId: string,
@@ -223,6 +239,46 @@ export function createRawNftSale(
   };
 }
 
+export function createRawContractForOwner(
+  address: string,
+  tokenId: string,
+  media: Media,
+  isSpam?: boolean,
+  name?: string,
+  tokenType?: NftTokenType,
+  symbol?: string,
+  totalSupply?: string,
+  opensea?: RawOpenSeaCollectionMetadata
+): RawContractForOwner {
+  return {
+    address,
+    isSpam: isSpam ?? true,
+    media,
+    tokenId,
+    totalBalance: 1,
+    numDistinctTokensOwned: 1,
+    name,
+    totalSupply,
+    opensea,
+    symbol,
+    tokenType
+  };
+}
+
+export function createNftMediaData(
+  bytes?: number,
+  format?: string,
+  thumbnail?: string
+): Media {
+  return {
+    raw: 'http://api.nikeape.xyz/ipfs/nickbanc/1.jpg',
+    gateway: 'http://api.nikeape.xyz/ipfs/nickbanc/1.jpg',
+    bytes,
+    format,
+    thumbnail
+  };
+}
+
 export function verifyNftContractMetadata(
   actualNftContract: NftContract,
   expectedNftContract: NftContract,
diff --git a/test/unit/nft-api.test.ts b/test/unit/nft-api.test.ts
index cdcbc899..e0d44346 100644
--- a/test/unit/nft-api.test.ts
+++ b/test/unit/nft-api.test.ts
@@ -4,6 +4,8 @@ import MockAdapter from 'axios-mock-adapter';
 import {
   Alchemy,
   BaseNft,
+  fromHex,
+  GetContractsForOwnerOptions,
   GetFloorPriceResponse,
   GetNftSalesOptions,
   GetNftsForOwnerOptions,
@@ -12,7 +14,7 @@ import {
   NftAttributesResponse,
   NftContractBaseNftsResponse,
   NftContractNftsResponse,
-  NftExcludeFilters,
+  NftFilters,
   NftMetadataBatchToken,
   NftSaleMarketplace,
   NftSaleTakerType,
@@ -22,33 +24,36 @@ import {
   OwnedNft,
   OwnedNftsResponse,
   RefreshState,
-  SortingOrder,
-  fromHex
+  SortingOrder
 } from '../../src';
 import {
   RawGetBaseNftsForContractResponse,
   RawGetBaseNftsResponse,
+  RawGetContractsForOwnerResponse,
   RawGetNftSalesResponse,
   RawGetNftsForContractResponse,
   RawGetNftsResponse,
   RawGetOwnersForContractWithTokenBalancesResponse,
-  RawNftAttributeRarity,
-  RawOpenSeaCollectionMetadata
+  RawNftAttributeRarity
 } from '../../src/internal/raw-interfaces';
 import {
   getNftContractFromRaw,
   getNftFromRaw,
-  getNftRarityFromRaw
+  getNftRarityFromRaw,
+  parseOpenSeaMetadata
 } from '../../src/util/util';
 import {
   createBaseNft,
   createNft,
+  createNftMediaData,
   createOwnedBaseNft,
   createOwnedNft,
+  createRawContractForOwner,
   createRawNft,
   createRawNftContract,
   createRawNftContractBaseNft,
   createRawNftSale,
+  createRawOpenSeaCollectionMetadata,
   createRawOwnedBaseNft,
   createRawOwnedNft,
   verifyNftContractMetadata
@@ -77,17 +82,7 @@ describe('NFT module', () => {
     const symbol = 'NCN';
     const totalSupply = '9999';
     const tokenType = NftTokenType.ERC721;
-    const openSea: RawOpenSeaCollectionMetadata = {
-      floorPrice: 2.2998,
-      collectionName: 'Collection Name',
-      safelistRequestStatus: 'verified',
-      imageUrl: 'http://image.url',
-      description: 'A sample description',
-      externalUrl: 'http://external.url',
-      twitterUsername: 'twitter-handle',
-      discordUrl: 'https://discord.gg/example',
-      lastIngestedAt: '2022-10-26T22:24:49.000Z'
-    };
+    const openSea = createRawOpenSeaCollectionMetadata();
 
     const rawNftContractResponse = createRawNftContract(
       address,
@@ -279,7 +274,7 @@ describe('NFT module', () => {
     const ownerAddress = '0xABC';
     const pageKey = 'page-key0';
     const contractAddresses = ['0xCA1', '0xCA2'];
-    const excludeFilters = [NftExcludeFilters.SPAM];
+    const excludeFilters = [NftFilters.SPAM];
     const expectedFilters = ['SPAM'];
     const getNftsParams: GetNftsForOwnerOptions = {
       pageKey,
@@ -404,7 +399,7 @@ describe('NFT module', () => {
   describe('getNftsForOwnerIterator()', () => {
     const ownerAddress = '0xABC';
     const contractAddresses = ['0xCA1', '0xCA2'];
-    const excludeFilters = [NftExcludeFilters.SPAM];
+    const excludeFilters = [NftFilters.SPAM];
     const expectedFilters = ['SPAM'];
     const baseResponses: RawGetBaseNftsResponse[] = [
       {
@@ -1122,6 +1117,106 @@ describe('NFT module', () => {
     });
   });
 
+  describe('getContractsForOwner()', () => {
+    const owner = 'vitalik.eth';
+    const contractAddress = '0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d';
+    const tokenId = '27';
+    const name = 'NFT Contract Name';
+    const symbol = 'XNO';
+    const totalContractCount = 3;
+    const totalSupply = '1492';
+    const nftMediaData = createNftMediaData();
+    const completeNftMediaData = createNftMediaData(
+      128,
+      'jpg',
+      'http://api.nikeape.xyz/ipfs/nickbanc/1.jpg'
+    );
+    const rawOpenSeaContractMetadata = createRawOpenSeaCollectionMetadata();
+    const expectedOpenseaMetadata = parseOpenSeaMetadata(
+      rawOpenSeaContractMetadata
+    );
+
+    const templateResponse: RawGetContractsForOwnerResponse = {
+      totalCount: totalContractCount,
+      contracts: [
+        createRawContractForOwner(contractAddress, tokenId, nftMediaData),
+        createRawContractForOwner(
+          contractAddress,
+          tokenId,
+          completeNftMediaData,
+          false,
+          name,
+          NftTokenType.ERC721,
+          symbol
+        ),
+        createRawContractForOwner(
+          contractAddress,
+          tokenId,
+          nftMediaData,
+          true,
+          name,
+          NftTokenType.ERC1155,
+          symbol,
+          totalSupply,
+          rawOpenSeaContractMetadata
+        )
+      ]
+    };
+
+    beforeEach(() => {
+      mock.onGet().reply(200, templateResponse);
+    });
+
+    it.each<[keyof GetContractsForOwnerOptions, any]>([
+      ['excludeFilters', [NftFilters.AIRDROPS]],
+      ['includeFilters', [NftFilters.SPAM]],
+      ['pageKey', 'a-page-key']
+    ])('calls with the correct parameters', async (fieldName, value) => {
+      await alchemy.nft.getContractsForOwner(owner, {
+        [fieldName]: value
+      });
+
+      expect(mock.history.get.length).toEqual(1);
+      expect(mock.history.get[0].params).toHaveProperty(fieldName, value);
+    });
+
+    it('returns the api response in the expected format', async () => {
+      const result = await alchemy.nft.getContractsForOwner(owner);
+
+      expect(result.totalCount).toEqual(totalContractCount);
+      expect(result.contracts.length).toEqual(totalContractCount);
+
+      expect(result.contracts[0].address).toEqual(contractAddress);
+      expect(result.contracts[0].tokenId).toEqual(tokenId);
+      expect(result.contracts[0].totalSupply).toBeUndefined();
+
+      expect(result.contracts[1].address).toEqual(contractAddress);
+      expect(result.contracts[1].tokenId).toEqual(tokenId);
+      expect(result.contracts[1].name).toEqual(name);
+      expect(result.contracts[1].tokenType).toEqual(NftTokenType.ERC721);
+      expect(result.contracts[1].symbol).toEqual(symbol);
+      expect(result.contracts[1].totalSupply).toBeUndefined();
+      expect(result.contracts[1].media).toEqual(completeNftMediaData);
+
+      expect(result.contracts[2].address).toEqual(contractAddress);
+      expect(result.contracts[2].tokenId).toEqual(tokenId);
+      expect(result.contracts[2].name).toEqual(name);
+      expect(result.contracts[2].tokenType).toEqual(NftTokenType.ERC1155);
+      expect(result.contracts[2].symbol).toEqual(symbol);
+      expect(result.contracts[2].openSea).toEqual(expectedOpenseaMetadata);
+      expect(result.contracts[2].totalSupply).toEqual(totalSupply);
+    });
+
+    it('surfaces errors', async () => {
+      mock.reset();
+      mock.onGet().reply(400, 'Invalid include filters: []');
+
+      await expect(alchemy.nft.getContractsForOwner(owner)).rejects.toThrow(
+        'Invalid include filters: []'
+      );
+    });
+  });
+
   describe('verifyNftOwnership()', () => {
     const owner = '0xABC';
     const addresses = ['0xCA1', '0xCA2'];