Repository: alchemyplatform/alchemy-sdk-js
Pull request #148 (2022-10-16 01:10:04)

Add Nft Api Compute Rarity
### Changelog

- Adds the `computeRarity` method in the `nft` namespace.
- Adds the `NftAttributeRarity` type to represent the result of the `computeRarity` endpoint.
- Fixes a typo in the `OwnedBaseNftsResponse` type doc.
- Adds unit and integration tests for the `computeRarity` method.

### Extra
I was considering wrapping the `computeRarity` endpoint result in an object with the following structure `{attributes: NftAttributeRarity[] }` and exposing that in the public API but then decided to keep the same format as the endpoint response. Also, I'll gladly accept suggestions for the `NftAttributeRarity` type name if you have a better name for this use case.
The other endpoints listed in #140 will be added in another pr. 

xeno097: > Thanks for the clean PR and fast turnaround times! Left one last request, but will merge this in after that rocket

🔥 

Patch:
diff --git a/src/api/nft-namespace.ts b/src/api/nft-namespace.ts
index 7135c0fc..7f918a48 100644
--- a/src/api/nft-namespace.ts
+++ b/src/api/nft-namespace.ts
@@ -2,6 +2,7 @@ import type { BigNumberish } from '@ethersproject/bignumber';
 
 import {
   checkNftOwnership,
+  computeRarity,
   getContractMetadata,
   getFloorPrice,
   getNftMetadata,
@@ -28,6 +29,7 @@ import {
   GetOwnersForContractWithTokenBalancesOptions,
   GetOwnersForContractWithTokenBalancesResponse,
   GetOwnersForNftResponse,
+  NftAttributeRarity,
   NftContractBaseNftsResponse,
   NftContractNftsResponse,
   NftTokenType,
@@ -360,6 +362,19 @@ export class NftNamespace {
     return getFloorPrice(this.config, contractAddress);
   }
 
+  /**
+   * Get the rarity of each attribute of an NFT.
+   *
+   * @param contractAddress - Contract address for the NFT collection.
+   * @param tokenId - Token id of the NFT.
+   */
+  computeRarity(
+    contractAddress: string,
+    tokenId: BigNumberish
+  ): Promise<NftAttributeRarity[]> {
+    return computeRarity(this.config, contractAddress, tokenId);
+  }
+
   /**
    * Refreshes the cached metadata for a provided NFT contract address and token
    * id. Returns a boolean value indicating whether the metadata was refreshed.
diff --git a/src/api/notify-namespace.ts b/src/api/notify-namespace.ts
index 0f6dd30d..0e38b03c 100644
--- a/src/api/notify-namespace.ts
+++ b/src/api/notify-namespace.ts
@@ -51,6 +51,7 @@ import { AlchemyConfig } from './alchemy-config';
  * namespace via `alchemy.notify`.
  */
 export class NotifyNamespace {
+  /** @internal */
   constructor(private readonly config: AlchemyConfig) {}
 
   /**
diff --git a/src/internal/nft-api.ts b/src/internal/nft-api.ts
index 71c04a9c..1d6e4532 100644
--- a/src/internal/nft-api.ts
+++ b/src/internal/nft-api.ts
@@ -13,6 +13,7 @@ import {
   GetOwnersForContractWithTokenBalancesOptions,
   GetOwnersForContractWithTokenBalancesResponse,
   GetOwnersForNftResponse,
+  NftAttributeRarity,
   NftContractBaseNftsResponse,
   NftContractNftsResponse,
   NftTokenType,
@@ -27,7 +28,8 @@ import { AlchemyApiType } from '../util/const';
 import {
   getBaseNftFromRaw,
   getNftContractFromRaw,
-  getNftFromRaw
+  getNftFromRaw,
+  getNftRarityFromRaw
 } from '../util/util';
 import { paginateEndpoint, requestHttpWithBackoff } from './dispatch';
 import {
@@ -40,6 +42,7 @@ import {
   RawGetNftsResponse,
   RawGetOwnersForContractResponse,
   RawNft,
+  RawNftAttributeRarity,
   RawNftContract,
   RawOwnedBaseNft,
   RawOwnedNft,
@@ -358,6 +361,23 @@ export async function getFloorPrice(
   );
 }
 
+export async function computeRarity(
+  config: AlchemyConfig,
+  contractAddress: string,
+  tokenId: BigNumberish,
+  srcMethod = 'computeRarity'
+): Promise<NftAttributeRarity[]> {
+  const response = await requestHttpWithBackoff<
+    ComputeRarityParams,
+    RawNftAttributeRarity[]
+  >(config, AlchemyApiType.NFT, 'computeRarity', srcMethod, {
+    contractAddress,
+    tokenId: BigNumber.from(tokenId).toString()
+  });
+
+  return getNftRarityFromRaw(response);
+}
+
 export async function refreshNftMetadata(
   config: AlchemyConfig,
   contractAddress: string,
@@ -573,6 +593,16 @@ interface GetFloorPriceParams {
   contractAddress: string;
 }
 
+/**
+ * Interface for the `computeRarity` endpoint.
+ *
+ * @internal
+ */
+interface ComputeRarityParams {
+  contractAddress: string;
+  tokenId: string;
+}
+
 interface ReingestContractParams {
   contractAddress: string;
 }
diff --git a/src/internal/raw-interfaces.ts b/src/internal/raw-interfaces.ts
index 9c3c8b3f..c7b0cc98 100644
--- a/src/internal/raw-interfaces.ts
+++ b/src/internal/raw-interfaces.ts
@@ -255,3 +255,9 @@ export interface RawNftFilterParam {
   contract_address: string;
   token_id: string;
 }
+
+export interface RawNftAttributeRarity {
+  value: string;
+  trait_type: string;
+  prevalence: number;
+}
diff --git a/src/types/types.ts b/src/types/types.ts
index c0341da1..6ca5b8cd 100644
--- a/src/types/types.ts
+++ b/src/types/types.ts
@@ -623,7 +623,7 @@ export interface OwnedNftsResponse {
 
 /**
  * The response object for the {@link getNftsForOwner} and
- * {@link getNftsForOwnerIterator)} functions. The object contains the NFTs
+ * {@link getNftsForOwnerIterator} functions. The object contains the NFTs
  * without metadata owned by the provided address, along with pagination
  * information and the total count.
  *
@@ -762,6 +762,25 @@ export interface GetFloorPriceResponse {
   readonly looksRare: FloorPriceMarketplace | FloorPriceError;
 }
 
+/**
+ * Information about the rarity of an NFT's attribute in the specified collection.
+ *
+ * @public
+ */
+export interface NftAttributeRarity {
+  /** Name of the NFT's attribute. */
+  value: string;
+
+  /** The type of NFT attribute. */
+  traitType: string;
+
+  /**
+   * A number from 0 to 1 representing the prevalence of this value for this
+   * trait type in the current collection.
+   */
+  prevalence: number;
+}
+
 /** The refresh result response object returned by {@link refreshContract}. */
 export interface RefreshContractResult {
   /** The NFT contract address that was passed in to be refreshed. */
diff --git a/src/util/util.ts b/src/util/util.ts
index 1882c4e5..3f1b628b 100644
--- a/src/util/util.ts
+++ b/src/util/util.ts
@@ -6,10 +6,16 @@ import {
   RawBaseNft,
   RawBaseNftContract,
   RawNft,
+  RawNftAttributeRarity,
   RawNftContract,
   RawSpamInfo
 } from '../internal/raw-interfaces';
-import { NftTokenType, SpamInfo, TokenUri } from '../types/types';
+import {
+  NftAttributeRarity,
+  NftTokenType,
+  SpamInfo,
+  TokenUri
+} from '../types/types';
 
 export function formatBlock(block: string | number): string {
   if (typeof block === 'string') {
@@ -73,6 +79,16 @@ export function getNftFromRaw(rawNft: RawNft, contractAddress: string): Nft {
   };
 }
 
+export function getNftRarityFromRaw(
+  rawNftRarity: RawNftAttributeRarity[]
+): NftAttributeRarity[] {
+  return rawNftRarity.map(({ prevalence, trait_type, value }) => ({
+    prevalence,
+    traitType: trait_type,
+    value
+  }));
+}
+
 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.


Test patch:
diff --git a/test/integration/nft.test.ts b/test/integration/nft.test.ts
index e28e5c80..5e798285 100644
--- a/test/integration/nft.test.ts
+++ b/test/integration/nft.test.ts
@@ -240,6 +240,19 @@ describe('E2E integration tests', () => {
     expect(response.looksRare).toBeDefined();
   });
 
+  it('computeRarity()', async () => {
+    const contractAddress = '0x0510745d2ca36729bed35c818527c4485912d99e';
+    const tokenId = '403';
+
+    const response = await alchemy.nft.computeRarity(contractAddress, tokenId);
+
+    expect(response).toBeDefined();
+    expect(response.length).toBeGreaterThan(0);
+    expect(response[0].prevalence).toBeDefined();
+    expect(response[0].traitType).toBeDefined();
+    expect(response[0].value).toBeDefined();
+  });
+
   it('refreshNftContract()', async () => {
     const contractAddress = '0x0510745d2ca36729bed35c818527c4485912d99e';
     const result = await alchemy.nft.refreshContract(contractAddress);
diff --git a/test/unit/nft-api.test.ts b/test/unit/nft-api.test.ts
index 3fdbe4df..91dcff90 100644
--- a/test/unit/nft-api.test.ts
+++ b/test/unit/nft-api.test.ts
@@ -4,6 +4,7 @@ import MockAdapter from 'axios-mock-adapter';
 import {
   Alchemy,
   BaseNft,
+  fromHex,
   GetFloorPriceResponse,
   GetNftsForOwnerOptions,
   GetOwnersForContractWithTokenBalancesResponse,
@@ -17,17 +18,21 @@ import {
   OwnedBaseNftsResponse,
   OwnedNft,
   OwnedNftsResponse,
-  RefreshState,
-  fromHex
+  RefreshState
 } from '../../src';
 import {
   RawGetBaseNftsForContractResponse,
   RawGetBaseNftsResponse,
   RawGetNftsForContractResponse,
   RawGetNftsResponse,
-  RawGetOwnersForContractWithTokenBalancesResponse
+  RawGetOwnersForContractWithTokenBalancesResponse,
+  RawNftAttributeRarity
 } from '../../src/internal/raw-interfaces';
-import { getNftContractFromRaw, getNftFromRaw } from '../../src/util/util';
+import {
+  getNftContractFromRaw,
+  getNftFromRaw,
+  getNftRarityFromRaw
+} from '../../src/util/util';
 import {
   createBaseNft,
   createNft,
@@ -1204,6 +1209,57 @@ describe('NFT module', () => {
     });
   });
 
+  describe('computeRarity()', () => {
+    const contractAddress = '0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d';
+    const tokenId = 7495;
+    const templateResponse: RawNftAttributeRarity[] = [
+      {
+        value: 'Aquamarine',
+        trait_type: 'Background',
+        prevalence: 0.1266
+      },
+      {
+        value: 'Cyborg',
+        trait_type: 'Eyes',
+        prevalence: 0.0108
+      }
+    ];
+    const expectedResult = getNftRarityFromRaw(templateResponse);
+
+    beforeEach(() => {
+      mock.onGet().reply(200, templateResponse);
+    });
+
+    it('calls with the correct parameters', async () => {
+      await alchemy.nft.computeRarity(contractAddress, tokenId);
+
+      expect(mock.history.get.length).toEqual(1);
+      expect(mock.history.get[0].params).toHaveProperty(
+        'contractAddress',
+        contractAddress
+      );
+      expect(mock.history.get[0].params).toHaveProperty(
+        'tokenId',
+        tokenId.toString()
+      );
+    });
+
+    it('returns the api response in the expected format', async () => {
+      const result = await alchemy.nft.computeRarity(contractAddress, tokenId);
+
+      expect(result).toEqual(expectedResult);
+    });
+
+    it('surfaces errors', async () => {
+      mock.reset();
+      mock.onGet().reply(400, 'Could not fetch metadata for that NFT');
+
+      await expect(
+        alchemy.nft.computeRarity(contractAddress, tokenId)
+      ).rejects.toThrow('Could not fetch metadata for that NFT');
+    });
+  });
+
   describe('refreshNftMetadata()', () => {
     const originalTimestamp = '2022-02-16T17:12:00.280Z';
     const updatedTimestamp = '2022-02-16T17:12:10.281Z';