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

Issue:
getContractMetadata API vs SDK
### [REQUIRED] Environment

- Browser version: Chrome 106
- Alchemy SDK version: 2.2.0

### [REQUIRED] Describe the problem

The getContractMetadata API returns useful openSea data that the SDK does not currently include.

Pull request description:
Add openSea property to `getContractMetadata()`
Fixes #162.

This PR updates the SDK to include the new `openSea` property on the `NftNamespace.getContractMetadata()` endpoint.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 12e4dc50..6dbcf5db 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,7 @@
 - Added support for ENS resolution on several methods. You should now be able to pass in an ENS domain into any param that requires an owner address. 
 - Added the `CoreNamespace.resolveName()` and `CoreNamespace.lookupAddress()` methods to resolve and lookup ENS domains and their owner addresses.
 - Fixed an issue where the `Accept-Encoding` header was incorrectly included in requests that originated from the browser (#174).
+- Added the `openSea` response to `NftNamespace.getContractMetadata()` (#162). 
 
 ## 2.2.0
 
diff --git a/src/api/nft.ts b/src/api/nft.ts
index 824191a6..edf4e301 100644
--- a/src/api/nft.ts
+++ b/src/api/nft.ts
@@ -2,6 +2,7 @@ import {
   Media,
   NftMetadata,
   NftTokenType,
+  OpenSeaCollectionMetadata,
   SpamInfo,
   TokenUri
 } from '../types/types';
@@ -30,6 +31,8 @@ export interface NftContract extends BaseNftContract {
   symbol?: string;
   /** The number of NFTs in the contract as an integer string. */
   totalSupply?: string;
+  /** OpenSea's metadata for the contract. */
+  openSea?: OpenSeaCollectionMetadata;
 }
 
 /**
diff --git a/src/internal/raw-interfaces.ts b/src/internal/raw-interfaces.ts
index c7b0cc98..16098013 100644
--- a/src/internal/raw-interfaces.ts
+++ b/src/internal/raw-interfaces.ts
@@ -79,6 +79,22 @@ export interface RawNftContractMetadata {
   symbol?: string;
   totalSupply?: string;
   tokenType?: NftTokenType;
+  openSea?: RawOpenSeaCollectionMetadata;
+}
+
+/**
+ * OpenSea's metadata for an NFT collection.
+ */
+export interface RawOpenSeaCollectionMetadata {
+  floorPrice?: number;
+  collectionName?: string;
+  safelistRequestStatus?: string;
+  imageUrl?: string;
+  description?: string;
+  externalUrl?: string;
+  twitterUsername?: string;
+  discordUrl?: string;
+  lastIngestedAt?: string;
 }
 
 /**
diff --git a/src/types/types.ts b/src/types/types.ts
index c4fcb7d5..26bebfe9 100644
--- a/src/types/types.ts
+++ b/src/types/types.ts
@@ -1050,6 +1050,42 @@ export interface NftContractNftsResponse {
   pageKey?: string;
 }
 
+/**
+ * OpenSea's metadata for an NFT collection.
+ */
+export interface OpenSeaCollectionMetadata {
+  /** The floor price of the NFT. */
+  floorPrice?: number;
+  /** The name of the collection on OpenSea. */
+  collectionName?: string;
+  /** The approval status of the collection on OpenSea. */
+  safelistRequestStatus?: OpenSeaSafelistRequestStatus;
+  /** The image URL determined by OpenSea. */
+  imageUrl?: string;
+  /** The description of the collection on OpenSea. */
+  description?: string;
+  /** The homepage of the collection as determined by OpenSea.*/
+  externalUrl?: string;
+  /** The Twitter handle of the collection. */
+  twitterUsername?: string;
+  /** The Discord URL of the collection. */
+  discordUrl?: string;
+  /** Timestamp of when the OpenSea metadata was last ingested by Alchemy. */
+  lastIngestedAt?: string;
+}
+
+/** An OpenSea collection's approval status. */
+export enum OpenSeaSafelistRequestStatus {
+  /** Verified collection. */
+  VERIFIED = 'verified',
+  /** Collections that are approved on open sea and can be found in search results. */
+  APPROVED = 'approved',
+  /** Collections that requested safelisting on OpenSea. */
+  REQUESTED = 'requested',
+  /** Brand new collections. */
+  NOT_REQUESTED = 'not_requested'
+}
+
 /**
  * The response object for the {@link findContractDeployer} function.
  *
diff --git a/src/util/util.ts b/src/util/util.ts
index 0917c38a..9532ad2f 100644
--- a/src/util/util.ts
+++ b/src/util/util.ts
@@ -13,6 +13,7 @@ import {
 import {
   NftAttributeRarity,
   NftTokenType,
+  OpenSeaSafelistRequestStatus,
   SpamInfo,
   TokenUri
 } from '../types/types';
@@ -26,6 +27,13 @@ export function formatBlock(block: string | number): string {
   return block.toString();
 }
 
+function stringToEnum<T extends string>(
+  x: string,
+  enumb: Record<string, T>
+): T | undefined {
+  return Object.values(enumb).includes(x as T) ? (x as T) : undefined;
+}
+
 export function getBaseNftContractFromRaw(
   rawBaseNftContract: RawBaseNftContract
 ): BaseNftContract {
@@ -35,13 +43,33 @@ export function getBaseNftContractFromRaw(
 export function getNftContractFromRaw(
   rawNftContract: RawNftContract
 ): NftContract {
-  return {
+  const contract: NftContract = {
     address: rawNftContract.address,
     name: rawNftContract.contractMetadata.name,
     symbol: rawNftContract.contractMetadata.symbol,
     totalSupply: rawNftContract.contractMetadata.totalSupply,
     tokenType: parseNftTokenType(rawNftContract.contractMetadata.tokenType)
   };
+  if (rawNftContract.contractMetadata.openSea) {
+    const safeListStatus =
+      rawNftContract.contractMetadata.openSea?.safelistRequestStatus;
+    contract.openSea = {
+      floorPrice: rawNftContract.contractMetadata.openSea?.floorPrice,
+      collectionName: rawNftContract.contractMetadata.openSea?.collectionName,
+      safelistRequestStatus:
+        safeListStatus !== undefined
+          ? stringToEnum(safeListStatus, OpenSeaSafelistRequestStatus)
+          : undefined,
+      imageUrl: rawNftContract.contractMetadata.openSea?.imageUrl,
+      description: rawNftContract.contractMetadata.openSea?.description,
+      externalUrl: rawNftContract.contractMetadata.openSea?.externalUrl,
+      twitterUsername: rawNftContract.contractMetadata.openSea?.twitterUsername,
+      discordUrl: rawNftContract.contractMetadata.openSea?.discordUrl,
+      lastIngestedAt: rawNftContract.contractMetadata.openSea?.lastIngestedAt
+    };
+  }
+
+  return contract;
 }
 
 export function getNftContractsFromRaw(


Test patch:
diff --git a/test/integration/nft.test.ts b/test/integration/nft.test.ts
index 6672706b..54ea9818 100644
--- a/test/integration/nft.test.ts
+++ b/test/integration/nft.test.ts
@@ -1,4 +1,9 @@
-import { Alchemy, NftExcludeFilters, NftTokenType } from '../../src';
+import {
+  Alchemy,
+  NftExcludeFilters,
+  NftTokenType,
+  OpenSeaSafelistRequestStatus
+} from '../../src';
 
 jest.setTimeout(50000);
 
@@ -34,6 +39,13 @@ describe('E2E integration tests', () => {
     expect(response.tokenType).toEqual(NftTokenType.ERC721);
     expect(response.address).toEqual(contractAddress);
     expect(typeof response.name).toEqual('string');
+    expect(response.openSea).toBeDefined();
+    expect(response.openSea?.safelistRequestStatus).toBeDefined();
+    expect(
+      Object.values(OpenSeaSafelistRequestStatus).includes(
+        response.openSea?.safelistRequestStatus!
+      )
+    ).toEqual(true);
   });
 
   it('getOwnersForNft()', async () => {
diff --git a/test/test-util.ts b/test/test-util.ts
index 32a63ada..39118c10 100644
--- a/test/test-util.ts
+++ b/test/test-util.ts
@@ -4,12 +4,12 @@ import {
   BaseNft,
   BaseNftContract,
   Nft,
+  NftContract,
   NftTokenType,
   OwnedBaseNft,
   OwnedNft,
   TokenUri,
-  toHex,
-  NftContract
+  toHex
 } from '../src';
 import {
   RawBaseNft,
@@ -18,6 +18,7 @@ import {
   RawNft,
   RawNftContract,
   RawNftContractMetadata,
+  RawOpenSeaCollectionMetadata,
   RawOwnedBaseNft,
   RawOwnedNft
 } from '../src/internal/raw-interfaces';
@@ -39,7 +40,8 @@ export function createRawNftContract(
   tokenType: NftTokenType,
   name?: string,
   symbol?: string,
-  totalSupply?: string
+  totalSupply?: string,
+  openSea?: RawOpenSeaCollectionMetadata
 ): RawNftContract {
   return {
     address,
@@ -47,7 +49,8 @@ export function createRawNftContract(
       name,
       symbol,
       totalSupply,
-      tokenType
+      tokenType,
+      openSea
     }
   };
 }
diff --git a/test/unit/nft-api.test.ts b/test/unit/nft-api.test.ts
index f23f8306..cb3c0c32 100644
--- a/test/unit/nft-api.test.ts
+++ b/test/unit/nft-api.test.ts
@@ -4,12 +4,12 @@ import MockAdapter from 'axios-mock-adapter';
 import {
   Alchemy,
   BaseNft,
-  fromHex,
   GetFloorPriceResponse,
   GetNftsForOwnerOptions,
   GetOwnersForContractWithTokenBalancesResponse,
   Nft,
   NftAttributesResponse,
+  NftContract,
   NftContractBaseNftsResponse,
   NftContractNftsResponse,
   NftExcludeFilters,
@@ -18,7 +18,8 @@ import {
   OwnedBaseNftsResponse,
   OwnedNft,
   OwnedNftsResponse,
-  RefreshState
+  RefreshState,
+  fromHex
 } from '../../src';
 import {
   RawGetBaseNftsForContractResponse,
@@ -26,7 +27,8 @@ import {
   RawGetNftsForContractResponse,
   RawGetNftsResponse,
   RawGetOwnersForContractWithTokenBalancesResponse,
-  RawNftAttributeRarity
+  RawNftAttributeRarity,
+  RawOpenSeaCollectionMetadata
 } from '../../src/internal/raw-interfaces';
 import {
   getNftContractFromRaw,
@@ -69,13 +71,25 @@ 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 rawNftContractResponse = createRawNftContract(
       address,
       tokenType,
       name,
       symbol,
-      totalSupply
+      totalSupply,
+      openSea
     );
     const expectedNftContract = getNftContractFromRaw(rawNftContractResponse);
 
@@ -83,21 +97,61 @@ describe('NFT module', () => {
       mock.onGet().reply(200, rawNftContractResponse);
     });
 
-    it('can be called with raw parameters', async () => {
-      await alchemy.nft.getContractMetadata(address);
+    function verifyNftContractMetadata(
+      actualNftContract: NftContract,
+      expectedNftContract: NftContract,
+      address: string,
+      name: string,
+      symbol: string,
+      totalSupply: string,
+      tokenType?: NftTokenType,
+      openSea?: RawOpenSeaCollectionMetadata
+    ) {
+      expect(actualNftContract).toEqual(expectedNftContract);
+
+      expect(actualNftContract.address).toEqual(address);
+      expect(actualNftContract.name).toEqual(name);
+      expect(actualNftContract.symbol).toEqual(symbol);
+      expect(actualNftContract.totalSupply).toEqual(totalSupply);
+      expect(actualNftContract.tokenType).toEqual(tokenType);
+      if (openSea) {
+        expect(actualNftContract.openSea?.floorPrice).toEqual(
+          openSea.floorPrice
+        );
+        expect(actualNftContract.openSea?.collectionName).toEqual(
+          openSea.collectionName
+        );
+        expect(actualNftContract.openSea?.safelistRequestStatus).toEqual(
+          openSea.safelistRequestStatus
+        );
+        expect(actualNftContract.openSea?.imageUrl).toEqual(openSea.imageUrl);
+        expect(actualNftContract.openSea?.description).toEqual(
+          openSea.description
+        );
+        expect(actualNftContract.openSea?.externalUrl).toEqual(
+          openSea.externalUrl
+        );
+        expect(actualNftContract.openSea?.twitterUsername).toEqual(
+          openSea.twitterUsername
+        );
+        expect(actualNftContract.openSea?.discordUrl).toEqual(
+          openSea.discordUrl
+        );
+        expect(actualNftContract.openSea?.lastIngestedAt).toEqual(
+          openSea.lastIngestedAt
+        );
+      }
 
       expect(mock.history.get.length).toEqual(1);
       expect(mock.history.get[0].params).toHaveProperty(
         'contractAddress',
         address
       );
-    });
+    }
 
     it('returns the api response in the expected format', async () => {
-      const response = await alchemy.nft.getContractMetadata(address);
-
       verifyNftContractMetadata(
-        response,
+        await alchemy.nft.getContractMetadata(address),
         expectedNftContract,
         address,
         name,