Repository: alchemyplatform/alchemy-sdk-js
Pull request #37 (2022-06-27 15:52:07)

Add getNftContractMetadata
Need to add tests still! But a quick code review would be great

deric-alchemy: need formatting for some files

Patch:
diff --git a/README.md b/README.md
index 96b0ef10..5174b159 100644
--- a/README.md
+++ b/README.md
@@ -20,8 +20,8 @@ import { Network, initializeAlchemy } from '@alch/alchemy-sdk';
 
 // Optional Config object, but defaults to demo api-key and eth-mainnet.
 const settings = {
-  apiKey: 'demo',  // Replace with your Alchemy API Key.
-  network: Network.ETH_MAINNET,  // Replace with your network.
+  apiKey: 'demo', // Replace with your Alchemy API Key.
+  network: Network.ETH_MAINNET, // Replace with your network.
   maxRetries: 10
 };
 
@@ -54,18 +54,17 @@ alchemySdk.getNftsForOwner(alchemy, '0xshah.eth').then(console.log);
 
 ## SDK Structure
 
-
-The `Alchemy` object returned by `initializeAlchemy()` provides access to the Alchemy API. An optional config 
+The `Alchemy` object returned by `initializeAlchemy()` provides access to the Alchemy API. An optional config
 object can be passed in when initializing to set your API key, change the network, or specify the max number of retries.
 
 There are two different patterns for the Alchemy object to be used:
 
-1. It can be passed into top-level functions like `getNftsForOwner()` or `getAssetTransfers()`. The current supported 
-functions using this pattern are the Alchemy NFT API endpoints and Alchemy Enhanced APIs.
+1. It can be passed into top-level functions like `getNftsForOwner()` or `getAssetTransfers()`. The current supported
+   functions using this pattern are the Alchemy NFT API endpoints and Alchemy Enhanced APIs.
 
-2. It can be used to generate an Ethers.js provider that allows access to Alchemy Provider-specific 
-[Ethers.js methods](https://docs.ethers.io/v5/single-page/). These encompass most standard JSON-RPC requests to 
-the blockchain.
+2. It can be used to generate an Ethers.js provider that allows access to Alchemy Provider-specific
+   [Ethers.js methods](https://docs.ethers.io/v5/single-page/). These encompass most standard JSON-RPC requests to
+   the blockchain.
 
 ## Ethers.js for standard JSON-RPC Calls
 
@@ -111,16 +110,21 @@ const alchemy = initializeAlchemy();
 const websocketProvider = alchemy.getWebsocketProvider();
 
 // Listen to all new pending transactions.
-websocketProvider.on({
-  method: 'alchemy_newFullPendingTransactions',
-}, res => console.log(res));
+websocketProvider.on(
+  {
+    method: 'alchemy_newFullPendingTransactions'
+  },
+  res => console.log(res)
+);
 
 // Listen to all transactions on the USDC contract.
-websocketProvider.on({
-  method: 'alchemy_filteredNewFullPendingTransactions',
-  address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'
-}, res => console.log(res));
-
+websocketProvider.on(
+  {
+    method: 'alchemy_filteredNewFullPendingTransactions',
+    address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'
+  },
+  res => console.log(res)
+);
 ```
 
 The SDK brings multiple improvements to ensure correct WebSocket behavior in cases of temporary network failure or
@@ -128,13 +132,13 @@ dropped connections. As with any network connection, you should not assume that
 without interruption, but correctly handling dropped connections and reconnection by hand can be challenging to get
 right. `alchemy-sdk` automatically handles these failures with no configuration necessary. The main benefits are:
 
-- Resilient event delivery: Unlike standard Web3.js or Ethers.js, you will not permanently miss events which arrive 
-  while the backing WebSocket is temporarily down. Instead, you will receive these events as soon as the connection 
-  is reopened. Note that if the connection is down for more than 120 blocks (approximately 20 minutes), you may 
+- Resilient event delivery: Unlike standard Web3.js or Ethers.js, you will not permanently miss events which arrive
+  while the backing WebSocket is temporarily down. Instead, you will receive these events as soon as the connection
+  is reopened. Note that if the connection is down for more than 120 blocks (approximately 20 minutes), you may
   still miss some events that were not part of the most recent 120 blocks.
-- Lowered rate of failure: Compared to standard Web3.js or Ethers.js, there are fewer failures when sending requests 
-  over the WebSocket while the connection is down. Alchemy Web3 will attempt to send the requests once the connection 
-  is reopened. Note that it is still possible, with a lower likelihood, for outgoing requests to be lost, 
+- Lowered rate of failure: Compared to standard Web3.js or Ethers.js, there are fewer failures when sending requests
+  over the WebSocket while the connection is down. Alchemy Web3 will attempt to send the requests once the connection
+  is reopened. Note that it is still possible, with a lower likelihood, for outgoing requests to be lost,
   so you should still have error handling as with any network request.
 
 ## NFT Module
@@ -142,6 +146,7 @@ right. `alchemy-sdk` automatically handles these failures with no configuration
 The SDK currently supports the following [NFT API](https://docs.alchemy.com/alchemy/enhanced-apis/nft-api) endpoints:
 
 - `getNftMetadata()`: Get the NFT metadata for a contract address and tokenId.
+- `getNftContractMetadata()`: Get the metadata associated with an NFT contract
 - `getNftsForOwner()`: Get NFTs for an owner address.
 - `getNftsForOwnerIterator()`: Get NFTs for an owner address as an async iterator (handles paging automatically).
 - `getNftsForCollection()`: Get all NFTs for a contract address.
@@ -175,13 +180,13 @@ an `AsyncIterable`.
 Here's an example of how to paginate through all the NFTs in Vitalik's ENS address:
 
 ```ts
-import { initializeAlchemy, getNftsForOwnerIterator } from "@alch/alchemy-sdk";
+import { initializeAlchemy, getNftsForOwnerIterator } from '@alch/alchemy-sdk';
 const alchemy = initializeAlchemy();
 
 async function main() {
-  const ownerAddress = "vitalik.eth";
+  const ownerAddress = 'vitalik.eth';
   for await (const nft of getNftsForOwnerIterator(alchemy, ownerAddress)) {
-    console.log("ownedNft:", nft);
+    console.log('ownedNft:', nft);
   }
 }
 
@@ -227,19 +232,19 @@ import {
   getNftsForOwner,
   getNftsForOwnerIterator,
   NftExcludeFilters,
-  initializeAlchemy,
-} from "@alch/alchemy-sdk";
+  initializeAlchemy
+} from '@alch/alchemy-sdk';
 
 const alchemy = initializeAlchemy();
 
 // Get how many NFTs an address owns.
-getNftsForOwner(alchemy, "0xshah.eth").then((nfts) => {
+getNftsForOwner(alchemy, '0xshah.eth').then(nfts => {
   console.log(nfts.totalCount);
 });
 
 // Get all the image urls for all the NFTs an address owns.
 async function main() {
-  for await (const nft of getNftsForOwnerIterator(alchemy, "0xshah.eth")) {
+  for await (const nft of getNftsForOwnerIterator(alchemy, '0xshah.eth')) {
     console.log(nft.media);
   }
 }
@@ -247,8 +252,8 @@ async function main() {
 main();
 
 // Filter out spam NFTs.
-getNftsForOwner(alchemy, "0xshah.eth", {
-  excludeFilters: [NftExcludeFilters.SPAM],
+getNftsForOwner(alchemy, '0xshah.eth', {
+  excludeFilters: [NftExcludeFilters.SPAM]
 }).then(console.log);
 ```
 
@@ -283,10 +288,10 @@ main();
 Get all outbound transfers for a provided address.
 
 ```ts
-import { getTokenBalances, initializeAlchemy } from "@alch/alchemy-sdk";
+import { getTokenBalances, initializeAlchemy } from '@alch/alchemy-sdk';
 const alchemy = initializeAlchemy();
 
-getTokenBalances(alchemy, "0x994b342dd87fc825f66e51ffa3ef71ad818b6893").then(
+getTokenBalances(alchemy, '0x994b342dd87fc825f66e51ffa3ef71ad818b6893').then(
   console.log
 );
 ```
@@ -296,5 +301,3 @@ getTokenBalances(alchemy, "0x994b342dd87fc825f66e51ffa3ef71ad818b6893").then(
 If you have any questions, issues, or feedback, please file an issue
 on [GitHub](https://github.com/alchemyplatform/alchemy-sdk-js/issues), or drop us a message on
 our [Discord](https://discord.com/channels/735965332958871634/983472322998575174) channel for the SDK.
-
-
diff --git a/src/api/nft-api.ts b/src/api/nft-api.ts
index d9f0f47d..6008b429 100644
--- a/src/api/nft-api.ts
+++ b/src/api/nft-api.ts
@@ -16,7 +16,7 @@ import {
 } from '../types/types';
 import { Alchemy } from './alchemy';
 import { paginateEndpoint, requestHttpWithBackoff } from '../internal/dispatch';
-import { BaseNft, Nft } from './nft';
+import { BaseNft, BaseNftContract, Nft, NftContract } from './nft';
 import {
   RawBaseNft,
   RawCollectionBaseNft,
@@ -27,10 +27,11 @@ import {
   RawGetNftsResponse,
   RawGetOwnersForCollectionResponse,
   RawNft,
+  RawNftContract,
   RawOwnedBaseNft,
   RawOwnedNft
 } from '../internal/raw-interfaces';
-import { toHex } from './util';
+import { getNftContractFromRaw, toHex } from './util';
 import { getTransactionReceipts } from './enhanced';
 import { BigNumber, BigNumberish } from 'ethers';
 
@@ -100,6 +101,53 @@ export async function getNftMetadata(
   return Nft.fromResponse(response, contractAddress);
 }
 
+/**
+ * Get the NFT collection metadata associated with the provided parameters.
+ *
+ * @param alchemy - The Alchemy SDK instance.
+ * @param contractAddress - The contract address of the NFT.
+ * @param tokenType - Optionally specify the type of token to speed up the query.
+ * @public
+ */
+export function getNftContractMetadata(
+  alchemy: Alchemy,
+  contractAddress: string
+): Promise<NftContract>;
+
+/**
+ * Get the NFT metadata associated with the provided Base NFT.
+ *
+ * @param alchemy - The Alchemy SDK instance.
+ * @param baseNft - The base NFT object to be used for the request.
+ * @public
+ */
+export function getNftContractMetadata(
+  alchemy: Alchemy,
+  baseNftContract: BaseNftContract
+): Promise<NftContract>;
+export async function getNftContractMetadata(
+  alchemy: Alchemy,
+  contractAddressOrBaseNftContract: string | BaseNftContract
+): Promise<NftContract> {
+  let response;
+  if (typeof contractAddressOrBaseNftContract === 'string') {
+    response = await requestHttpWithBackoff<
+      GetNftContractMetadataParams,
+      RawNftContract
+    >(alchemy, 'getContractMetadata', {
+      contractAddress: contractAddressOrBaseNftContract
+    });
+  } else {
+    response = await requestHttpWithBackoff<
+      GetNftContractMetadataParams,
+      RawNftContract
+    >(alchemy, 'getContractMetadata', {
+      contractAddress: contractAddressOrBaseNftContract.address
+    });
+  }
+  return getNftContractFromRaw(response);
+}
+
 /**
  * Fetches all NFTs for a given owner and yields them in an async iterable.
  *
@@ -694,6 +742,15 @@ interface GetNftMetadataParams {
   refreshCache?: boolean;
 }
 
+/**
+ * Interface for the `getNftContractMetadata` endpoint.
+ *
+ * @internal
+ */
+interface GetNftContractMetadataParams {
+  contractAddress: string;
+}
+
 /**
  * Interface for the `getOwnersForCollection` endpoint.
  *
diff --git a/src/api/nft.ts b/src/api/nft.ts
index e726a86f..8807e23c 100644
--- a/src/api/nft.ts
+++ b/src/api/nft.ts
@@ -1,24 +1,38 @@
 import { NftMetadata, NftTokenType, TokenUri } from '../types/types';
 import { RawBaseNft, RawNft } from '../internal/raw-interfaces';
 import { BigNumber } from 'ethers';
-
 /**
- * Represents an NFT contract.
+ * Alchemy representation of a base NFT contract that doesn't contain metadata.
  *
  * @public
  */
-export interface NftContract {
-  /** The NFT contract address. */
+export interface BaseNftContract {
   address: string;
 }
 
+/**
+ * Alchemy representation of an NFT contract.
+ *
+ * @public
+ */
+export interface NftContract extends BaseNftContract {
+  /** The type of the token in the contract. */
+  tokenType: NftTokenType;
+  /** The name of the contract. */
+  name?: string;
+  /** The symbol of the contract. */
+  symbol?: string;
+  /** The number of NFTs in the contract. */
+  totalSupply?: number;
+}
+
 /**
  * Alchemy representation of a base NFT that doesn't contain metadata.
  *
  * @public
  */
 export class BaseNft {
-  readonly contract: NftContract;
+  readonly contract: BaseNftContract;
 
   /**
    * This constructor should never be called directly. All Nft instances should
diff --git a/src/api/util.ts b/src/api/util.ts
index ac9fed65..b6f8fc32 100644
--- a/src/api/util.ts
+++ b/src/api/util.ts
@@ -1,4 +1,6 @@
 import { BigNumber } from 'ethers';
+import { RawBaseNftContract, RawNftContract } from '../internal/raw-interfaces';
+import { BaseNftContract, NftContract } from './nft';
 
 /**
  * Converts a hex string to a decimal number.
@@ -29,3 +31,21 @@ export function toHex(num: number): string {
 export function isHex(possibleHexString: string): boolean {
   return /^0x[0-9a-fA-F]+$/.test(possibleHexString);
 }
+
+export function getBaseNftContractFromRaw(
+  rawBaseNftContract: RawBaseNftContract
+): BaseNftContract {
+  return { address: rawBaseNftContract.address };
+}
+
+export function getNftContractFromRaw(
+  rawNftContract: RawNftContract
+): NftContract {
+  return {
+    address: rawNftContract.address,
+    name: rawNftContract.contractMetadata.name,
+    symbol: rawNftContract.contractMetadata.symbol,
+    totalSupply: rawNftContract.contractMetadata.totalSupply,
+    tokenType: rawNftContract.contractMetadata.tokenType
+  };
+}
diff --git a/src/index.ts b/src/index.ts
index 26dd983f..d3984ae9 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -9,6 +9,7 @@ export { AlchemyWebSocketProvider } from './api/alchemy-websocket-provider';
 
 export {
   getNftMetadata,
+  getNftContractMetadata,
   getNftsForOwner,
   getNftsForOwnerIterator,
   getNftsForCollection,
@@ -27,7 +28,7 @@ export {
   getTokenBalances
 } from './api/enhanced';
 
-export { NftContract, Nft, BaseNft } from './api/nft';
+export { BaseNftContract, NftContract, Nft, BaseNft } from './api/nft';
 
 export { fromHex, toHex, isHex } from './api/util';
 
diff --git a/src/internal/raw-interfaces.ts b/src/internal/raw-interfaces.ts
index d04c8934..4ab1ecd6 100644
--- a/src/internal/raw-interfaces.ts
+++ b/src/internal/raw-interfaces.ts
@@ -36,10 +36,29 @@ export interface RawNft extends RawBaseNft {
  *
  * @internal
  */
-interface RawNftContract {
+export interface RawBaseNftContract {
   address: string;
 }
 
+/**
+ * Represents the contract address and metadata of an NFT object received from
+ * Alchemy. This field is separated out since not all NFT API endpoints return a
+ * contract field.
+ *
+ * @internal
+ */
+export interface RawNftContract {
+  address: string;
+  contractMetadata: RawNftContractMetadata;
+}
+
+interface RawNftContractMetadata {
+  name?: string;
+  symbol?: string;
+  totalSupply?: number;
+  tokenType: NftTokenType;
+}
+
 /**
  * Represents the ID information of an NFT object received from Alchemy.
  *
@@ -88,7 +107,7 @@ export interface RawGetNftsResponse {
  * @internal
  */
 export interface RawOwnedBaseNft extends RawBaseNft {
-  contract: RawNftContract;
+  contract: RawBaseNftContract;
   balance: string;
 }
 
diff --git a/src/version.ts b/src/version.ts
index 30b7887d..f2f2260c 100644
--- a/src/version.ts
+++ b/src/version.ts
@@ -1,3 +1,3 @@
 // This file is autogenerated by injectVersion.js. Any changes will be
 // overwritten on commit!
-export const VERSION = "1.0.3";
+export const VERSION = '1.0.3';


Test patch:
diff --git a/test/test-util.ts b/test/test-util.ts
index e96fbfe5..fb1ecd2c 100644
--- a/test/test-util.ts
+++ b/test/test-util.ts
@@ -1,12 +1,15 @@
 import {
   RawBaseNft,
+  RawBaseNftContract,
   RawCollectionBaseNft,
   RawNft,
+  RawNftContract,
   RawOwnedBaseNft,
   RawOwnedNft
 } from '../src/internal/raw-interfaces';
 import {
   BaseNft,
+  BaseNftContract,
   Nft,
   NftTokenType,
   OwnedBaseNft,
@@ -16,6 +19,33 @@ import {
 } from '../src';
 import { BigNumber } from 'ethers';
 import { BlockHead, LogsEvent } from '../src/internal/websocket-backfiller';
+import { getBaseNftContractFromRaw } from '../src/api/util';
+
+export function createRawNftContract(
+  address: string,
+  tokenType: NftTokenType,
+  name?: string,
+  symbol?: string,
+  totalSupply?: number
+): RawNftContract {
+  return {
+    address,
+    contractMetadata: {
+      name,
+      symbol,
+      totalSupply,
+      tokenType
+    }
+  };
+}
+
+export function createRawBaseNftContract(address: string): RawBaseNftContract {
+  return { address };
+}
+
+export function createBaseNftContract(address: string): BaseNftContract {
+  return getBaseNftContractFromRaw(createRawBaseNftContract(address));
+}
 
 export function createRawOwnedBaseNft(
   address: string,
diff --git a/test/unit/nft-api.test.ts b/test/unit/nft-api.test.ts
index f32fdf55..434bb45e 100644
--- a/test/unit/nft-api.test.ts
+++ b/test/unit/nft-api.test.ts
@@ -5,6 +5,7 @@ import {
   CollectionBaseNftsResponse,
   CollectionNftsResponse,
   fromHex,
+  getNftContractMetadata,
   getNftMetadata,
   getNftsForCollection,
   getNftsForCollectionIterator,
@@ -15,6 +16,7 @@ import {
   getOwnersForNft,
   initializeAlchemy,
   Nft,
+  NftContract,
   NftExcludeFilters,
   NftTokenType,
   OwnedBaseNft,
@@ -27,11 +29,13 @@ import axios from 'axios';
 import MockAdapter from 'axios-mock-adapter';
 import {
   createBaseNft,
+  createBaseNftContract,
   createNft,
   createOwnedBaseNft,
   createOwnedNft,
   createRawCollectionBaseNft,
   createRawNft,
+  createRawNftContract,
   createRawOwnedBaseNft,
   createRawOwnedNft
 } from '../test-util';
@@ -41,6 +45,7 @@ import {
   RawGetNftsForCollectionResponse,
   RawGetNftsResponse
 } from '../../src/internal/raw-interfaces';
+import { getNftContractFromRaw } from '../../src/api/util';
 
 describe('NFT module', () => {
   let alchemy: Alchemy;
@@ -59,6 +64,84 @@ describe('NFT module', () => {
     mock.reset();
   });
 
+  describe('getNftContractMetadata()', () => {
+    const address = '0xABC';
+    const name = 'NFT Contract Name';
+    const symbol = 'NCN';
+    const totalSupply = 9999;
+    const tokenType = NftTokenType.ERC721;
+
+    const rawNftContractResponse = createRawNftContract(
+      address,
+      tokenType,
+      name,
+      symbol,
+      totalSupply
+    );
+    const expectedNftContract = getNftContractFromRaw(rawNftContractResponse);
+
+    beforeEach(() => {
+      mock.onGet().reply(200, rawNftContractResponse);
+    });
+
+    function verifyNftContractMetadata(
+      actualNftContract: NftContract,
+      expectedNftContract: NftContract,
+      address: string,
+      name: string,
+      symbol: string,
+      totalSupply: number,
+      tokenType?: NftTokenType
+    ) {
+      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);
+
+      expect(mock.history.get.length).toEqual(1);
+      expect(mock.history.get[0].params).toHaveProperty(
+        'contractAddress',
+        address
+      );
+    }
+
+    it('can be called with a BaseNftContract', async () => {
+      const nftContract = createBaseNftContract(address);
+      verifyNftContractMetadata(
+        await getNftContractMetadata(alchemy, nftContract),
+        expectedNftContract,
+        address,
+        name,
+        symbol,
+        totalSupply,
+        tokenType
+      );
+    });
+
+    it('can be called with raw parameters', async () => {
+      verifyNftContractMetadata(
+        await getNftContractMetadata(alchemy, address),
+        expectedNftContract,
+        address,
+        name,
+        symbol,
+        totalSupply,
+        tokenType
+      );
+    });
+
+    it('surfaces errors', async () => {
+      mock.reset();
+      mock.onGet().reply(500, 'Internal Server Error');
+      await expect(getNftContractMetadata(alchemy, address)).rejects.toThrow(
+        'Internal Server Error'
+      );
+    });
+  });
+
   describe('getNftMetadata()', () => {
     const contractAddress = '0xABC';
     const title = 'NFT Title';