diff --git a/.changeset/serious-eagles-joke.md b/.changeset/serious-eagles-joke.md new file mode 100644 index 0000000000..12fbd3c4e8 --- /dev/null +++ b/.changeset/serious-eagles-joke.md @@ -0,0 +1,6 @@ +--- +'@moralisweb3/evm-api': minor +'@moralisweb3/evm-utils': minor +--- + +Add Moralis.EvmApi.nft.getWalletNFTCollections() to return all nft collections of a specified address diff --git a/demos/cli/src/main.ts b/demos/cli/src/main.ts index 750f2b4555..62ebd38184 100644 --- a/demos/cli/src/main.ts +++ b/demos/cli/src/main.ts @@ -15,14 +15,12 @@ async function main() { apiKey: env['MORALIS_API_KEY'], }); - const block = await Moralis.EvmApi.native.getBlock({ - blockNumberOrHash: '15305775', + const collection = await Moralis.EvmApi.nft.getWalletNFTCollections({ + address: '0xbf48C4f51dD8C1a396386380c80EBfe667b3c1A7', chain: '0x1', }); - console.log(block); + console.log(collection.raw); - const weights = await Moralis.EvmApi.info.endpointWeights(); - console.log(weights.result); } main(); diff --git a/packages/evmApi/src/EvmApi.ts b/packages/evmApi/src/EvmApi.ts index 340bc66478..3da928249c 100644 --- a/packages/evmApi/src/EvmApi.ts +++ b/packages/evmApi/src/EvmApi.ts @@ -28,6 +28,7 @@ import { getNFTTransfersByBlock, getNFTTransfersFromToBlock, getWalletNFTs, + getWalletNFTCollections, getWalletNFTTransfers, reSyncMetadata, searchNFTs, @@ -77,6 +78,7 @@ export class MoralisEvmApi extends ApiModule { getNFTTransfers: this.endpoints.createPaginatedFetcher(getNFTTransfers), syncNFTContract: this.endpoints.createFetcher(syncNFTContract), getNFTContractTransfers: this.endpoints.createPaginatedFetcher(getNFTContractTransfers), + getWalletNFTCollections: this.endpoints.createPaginatedFetcher(getWalletNFTCollections) }; private readonly _token = { diff --git a/packages/evmApi/src/generated/types.ts b/packages/evmApi/src/generated/types.ts index edea4c72d3..d0388a500b 100644 --- a/packages/evmApi/src/generated/types.ts +++ b/packages/evmApi/src/generated/types.ts @@ -62,6 +62,10 @@ export interface paths { /** Get the transfers of the tokens matching the given parameters. */ get: operations["getNFTTransfers"]; }; + "/{address}/nft/collections": { + /** Get the nft collections owned by an user */ + get: operations["getWalletNFTCollections"]; + }; "/{address}/nft/{token_address}": { /** * Get NFTs owned by the given address for a specific NFT contract address. @@ -898,6 +902,31 @@ export interface components { */ updatedAt: string; }; + nftWalletCollections: { + /** + * @description The syncing status of the address [SYNCING/SYNCED] + * @example SYNCING + */ + status?: string; + /** + * @description The total number of matches for this query + * @example 2000 + */ + total?: number; + /** + * @description The page of the current result + * @example 2 + */ + page?: number; + /** + * @description The number of results per page + * @example 100 + */ + page_size?: number; + /** @description The cursor to get to the next page */ + cursor?: string; + result?: components["schemas"]["nftCollections"][]; + }; nftCollection: { /** * @description The total number of matches for this query @@ -936,6 +965,28 @@ export interface components { page_size?: number; result?: components["schemas"]["nftMetadata"][]; }; + nftCollections: { + /** + * @description The address of the contract of the NFT + * @example 0xb47e3cd837dDF8e4c57F05d70Ab865de6e193BBB + */ + token_address: string; + /** + * @description The type of NFT contract standard + * @example ERC721 + */ + contract_type: string; + /** + * @description The name of the Token contract + * @example CryptoKitties + */ + name: string; + /** + * @description The symbol of the NFT contract + * @example RARI + */ + symbol: string; + }; nftOwner: { /** * @description The address of the contract of the NFT @@ -1981,6 +2032,31 @@ export interface operations { }; }; }; + /** Get the nft collections owned by an user */ + getWalletNFTCollections: { + parameters: { + query: { + /** The chain to query */ + chain?: components["schemas"]["chainList"]; + /** The desired page size of the result. */ + limit?: number; + /** The cursor returned in the previous response (used to getting the next page). */ + cursor?: string; + }; + path: { + /** The owner wallet address of the NFT collections */ + address: string; + }; + }; + responses: { + /** Returns a collection of NFTs owned by an user */ + 200: { + content: { + "application/json": components["schemas"]["nftWalletCollections"]; + }; + }; + }; + }; /** * Get NFTs owned by the given address for a specific NFT contract address. * * Use the token_address param to get results for a specific contract only @@ -2289,8 +2365,6 @@ export interface operations { to_date?: string; /** The addresses to get metadata for */ addresses?: string[]; - /** The token contract address */ - token_address?: string; /** The cursor returned in the previous response (used to getting the next page). */ cursor?: string; /** The desired page size of the result. */ diff --git a/packages/evmApi/src/resolvers/nft/getWalletNFTCollections.ts b/packages/evmApi/src/resolvers/nft/getWalletNFTCollections.ts new file mode 100644 index 0000000000..e73a482ca0 --- /dev/null +++ b/packages/evmApi/src/resolvers/nft/getWalletNFTCollections.ts @@ -0,0 +1,42 @@ +import { Camelize, toCamelCase } from '@moralisweb3/core'; +import { EvmChainish, EvmAddressish, EvmAddress, EvmNftCollection } from '@moralisweb3/evm-utils'; +import { operations } from '../../generated/types'; +import { createPaginatedEndpointFactory, createPaginatedEndpoint, PaginatedParams } from '@moralisweb3/api-utils'; +import { EvmChainResolver } from '../EvmChainResolver'; + +type operation = 'getWalletNFTCollections'; + +type QueryParams = operations[operation]['parameters']['query']; +type PathParams = operations[operation]['parameters']['path']; +type ApiParams = QueryParams & PathParams; +export interface Params extends Camelize>, PaginatedParams { + chain?: EvmChainish; + address: EvmAddressish; +} + +type ApiResult = operations[operation]['responses']['200']['content']['application/json']; + +export const getWalletNFTCollections = createPaginatedEndpointFactory((core) => + createPaginatedEndpoint({ + name: 'getWalletNFTCollections', + urlParams: ['address'], + getUrl: (params: Params) => `/${params.address}/nft/collections`, + apiToResult: (data: ApiResult, params: Params) => + (data.result ?? []).map((collection) => + EvmNftCollection.create( + { + ...toCamelCase(collection), + chain: EvmChainResolver.resolve(params.chain, core), + tokenAddress: EvmAddress.create(collection.token_address, core), + }, + core, + ), + ), + resultToJson: (data) => data.map((transaction) => transaction.toJSON()), + parseParams: (params: Params): ApiParams => ({ + ...params, + chain: EvmChainResolver.resolve(params.chain, core).apiHex, + address: EvmAddress.create(params.address, core).lowercase, + }), + }), +); diff --git a/packages/evmApi/src/resolvers/nft/index.ts b/packages/evmApi/src/resolvers/nft/index.ts index 30864ffc50..45d63fc5c1 100644 --- a/packages/evmApi/src/resolvers/nft/index.ts +++ b/packages/evmApi/src/resolvers/nft/index.ts @@ -8,9 +8,10 @@ export { getNFTTokenIdOwners } from './getNFTTokenIdOwners'; export { getNFTTrades } from './getNFTTrades'; export { getNFTTransfers } from './getNFTTransfers'; export { getNFTTransfersByBlock } from './getNFTTransfersByBlock'; +export { getNFTTransfersFromToBlock } from './getNFTTransfersFromToBlock'; +export { getWalletNFTCollections } from './getWalletNFTCollections' export { getWalletNFTs } from './getWalletNFTs'; +export { getWalletNFTTransfers } from './getWalletNFTTransfers'; export { reSyncMetadata } from './reSyncMetadata'; export { searchNFTs } from './searchNFTs'; -export { syncNFTContract } from './syncNFTContract'; -export { getWalletNFTTransfers } from './getWalletNFTTransfers'; -export { getNFTTransfersFromToBlock } from './getNFTTransfersFromToBlock'; +export { syncNFTContract } from './syncNFTContract'; \ No newline at end of file diff --git a/packages/evmUtils/src/dataTypes/EvmNftCollection/EvmNftCollection.test.ts b/packages/evmUtils/src/dataTypes/EvmNftCollection/EvmNftCollection.test.ts new file mode 100644 index 0000000000..163781950f --- /dev/null +++ b/packages/evmUtils/src/dataTypes/EvmNftCollection/EvmNftCollection.test.ts @@ -0,0 +1,106 @@ +import { MoralisCore } from '@moralisweb3/core'; +import { EvmNftCollection } from './EvmNftCollection'; +import { setupEvmUtils } from '../../test/setup'; +import { EvmNftCollectionInput } from './types'; + +const exampleInput: EvmNftCollectionInput = { + chain: '0x1', + tokenAddress: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + contractType: 'ERC721', + name: 'Test NFT', + symbol: 'TEST', +}; + +describe('EvmNftCollection', () => { + let core: MoralisCore; + + beforeAll(() => { + core = setupEvmUtils(); + }); + + beforeEach(() => { + core.config.reset(); + }); + + /** + * Creation + */ + it('should create a new EvmNftCollection', () => { + const collection = EvmNftCollection.create(exampleInput); + + expect(collection.chain.hex).toBe('0x1'); + expect(collection.tokenAddress.checksum).toBe('0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'); + expect(collection.contractType).toBe('ERC721'); + expect(collection.name).toBe('Test NFT'); + expect(collection.symbol).toBe('TEST'); + }); + + it('should throw an error when creating with an invalid contractType', () => { + expect(() => EvmNftCollection.create({ ...exampleInput, contractType: 'ERC100' })).toThrowErrorMatchingInlineSnapshot( + `"[C0005] Invalid NFT contract type provided"`, + ); + }); + + /** + * Formatting + */ + it('should return formatting in json', () => { + const collection = EvmNftCollection.create(exampleInput); + + const value = collection.toJSON(); + + expect(value).toStrictEqual({ + "chain": "0x1", + "contractType": "ERC721", + "name": "Test NFT", + "symbol": "TEST", + "tokenAddress": "0xd8da6bf26964af9d7eed9e03e53415d37aa96045", + }); + }); + + it('should return a result object', () => { + const collection = EvmNftCollection.create(exampleInput); + + const value = collection.result; + + expect(value.chain.equals(exampleInput.chain)).toBeTruthy(); + expect(value.contractType).toBe("ERC721"); + expect(value.name).toBe('Test NFT'); + expect(value.symbol).toBe('TEST'); + expect(value.tokenAddress.equals(exampleInput.tokenAddress)).toBeTruthy(); + }); + + /** + * Methods + */ + it('should check equality of 2 collections of the same value', () => { + const collectionA = EvmNftCollection.create(exampleInput); + const collectionB = EvmNftCollection.create(exampleInput); + + expect(collectionA.equals(collectionB)).toBeTruthy(); + }); + + it('should check equality of 2 collectiones of the same value via a static method', () => { + const collectionA = EvmNftCollection.create(exampleInput); + const collectionB = EvmNftCollection.create(exampleInput); + + expect(EvmNftCollection.equals(collectionA, collectionB)).toBeTruthy(); + }); + + it('should check inequality when chain is different', () => { + const collectionA = EvmNftCollection.create(exampleInput); + const collectionB = EvmNftCollection.create({ ...exampleInput, chain: '0x2' }); + + expect(collectionA.equals(collectionB)).toBeFalsy(); + }); + + it('should check inequality when tokenAddress is different', () => { + const collectionA = EvmNftCollection.create(exampleInput); + const collectionB = EvmNftCollection.create({ + ...exampleInput, + tokenAddress: '0xC969E2CBF0Be089d938256ebE3931c2416D8A109', + }); + + expect(collectionA.equals(collectionB)).toBeFalsy(); + }); +}); diff --git a/packages/evmUtils/src/dataTypes/EvmNftCollection/EvmNftCollection.ts b/packages/evmUtils/src/dataTypes/EvmNftCollection/EvmNftCollection.ts new file mode 100644 index 0000000000..3662e8c0c3 --- /dev/null +++ b/packages/evmUtils/src/dataTypes/EvmNftCollection/EvmNftCollection.ts @@ -0,0 +1,152 @@ +import MoralisCore, { MoralisDataObject, MoralisCoreProvider } from '@moralisweb3/core'; +import { EvmAddress } from '../EvmAddress'; +import { EvmChain } from '../EvmChain'; +import { validateValidEvmContractType } from '../EvmNftContractType'; +import { EvmNftCollectionData, EvmNftCollectionInput } from './types'; + +/** + * Valid input for a new EvmNftCollection instance. + * This can be an existing {@link EvmNftCollection} or a valid {@link EvmNftCollectionInput} object + */ +export type EvmNftCollectionish = EvmNftCollectionInput | EvmNftCollection; + +/** + * The EvmNftCollection is a representation of an nft collection. + * + * @category DataType + */ +export class EvmNftCollection implements MoralisDataObject { + /** + * Create a new instance of EvmNftCollection from any valid transaction input + * @param data - the EvmNftCollectionish type + * @example const collection = EvmTransaction.create(data); + */ + static create(data: EvmNftCollectionish, core?: MoralisCore) { + if (data instanceof EvmNftCollection) { + return data; + } + const finalCore = core ?? MoralisCoreProvider.getDefault(); + return new EvmNftCollection(data, finalCore); + } + + private _data: EvmNftCollectionData; + + constructor(data: EvmNftCollectionInput, core: MoralisCore) { + this._data = EvmNftCollection.parse(data, core); + } + + static parse = (data: EvmNftCollectionInput, core: MoralisCore): EvmNftCollectionData => ({ + ...data, + tokenAddress: EvmAddress.create(data.tokenAddress, core), + chain: EvmChain.create(data.chain, core), + contractType: validateValidEvmContractType(data.contractType) + }); + + /** + * Check the equality between two Evm collections. It compares their hashes and collections. + * @param dataA - The first collection to compare + * @param dataB - The second collection to compare + * @example EvmNftCollection.equals(dataA, dataB) + */ + static equals(dataA: EvmNftCollectionish, dataB: EvmNftCollectionish) { + const collectionA = EvmNftCollection.create(dataA); + const collectionB = EvmNftCollection.create(dataB); + + if (!collectionA.chain.equals(collectionB.chain)) { + return false; + } + + if (!collectionA.tokenAddress.equals(collectionB.tokenAddress)) { + return false; + } + + return true; + } + + /** + * Checks the equality of the current collection with another evm collection + * @param data - the collection to compare with + * @example + * ```ts + * collection.equals(data) + * ``` + */ + equals(data: EvmNftCollectionish): boolean { + return EvmNftCollection.equals(this, data); + } + + /** + * @returns a JSON represention of the collection. + * @example + * ``` + * collection.toJSON() + * ``` + */ + toJSON() { + const data = this._data; + return { + ...data, + chain: data.chain.format(), + tokenAddress: data.tokenAddress.format(), + }; + } + + /** + * @returns a JSON represention of the collection. + * @example + * ``` + * collection.format() + * ``` + */ + format() { + return this.toJSON(); + } + + /** + * @returns all the data without casting it to JSON. + * @example collection.result + */ + get result() { + return this._data; + } + + /** + * @returns the chain where the collection is deployed. + * @example collection.chain // EvmChain + */ + get chain() { + return this._data.chain; + } + + /** + * @returns the token address of collection. + * @example collection.tokenAddress // EvmAddress + */ + get tokenAddress() { + return this._data.tokenAddress; + } + + /** + * @returns the token type of collection. + * @example collection.tokenAddress // 'ERC721' + */ + get contractType() { + return this._data.contractType; + } + + /** + * @returns the token name of collection. + * @example collection.tokenAddress // 'Test NFT' + */ + get name() { + return this._data.name; + } + + /** + * @returns the token symbol of collection. + * @example collection.symbol // 'TEST' + */ + get symbol() { + return this._data.symbol; + } +} diff --git a/packages/evmUtils/src/dataTypes/EvmNftCollection/index.ts b/packages/evmUtils/src/dataTypes/EvmNftCollection/index.ts new file mode 100644 index 0000000000..b217d35120 --- /dev/null +++ b/packages/evmUtils/src/dataTypes/EvmNftCollection/index.ts @@ -0,0 +1,2 @@ +export * from './EvmNftCollection'; +export * from './types'; diff --git a/packages/evmUtils/src/dataTypes/EvmNftCollection/types.ts b/packages/evmUtils/src/dataTypes/EvmNftCollection/types.ts new file mode 100644 index 0000000000..2aa763f7b8 --- /dev/null +++ b/packages/evmUtils/src/dataTypes/EvmNftCollection/types.ts @@ -0,0 +1,35 @@ +import { EvmAddressish, EvmAddress, } from '../EvmAddress'; +import { EvmChain, EvmChainish } from '../EvmChain'; +import { EvmNftContractType } from '../EvmNftContractType'; + +/** + * This can be any object with valid block data. + * @example + * ``` + * const input = { + * chain: '0x1', + * contractType: 'ERC721', + * name: 'Test NFT', + * symbol: 'TEST', + * tokenAddress: '0xe4c7bf3aff7ad07f9e80d57f7189f0252592fee6321c2a9bd9b09b6ce0690d27', + * } + * ``` + */ +export interface EvmNftCollectionInput { + chain: EvmChainish, + contractType: string; + name: string; + symbol: string; + tokenAddress: EvmAddressish; +} + +/** + * This is the return type of the processed EVM transaction + */ +export interface EvmNftCollectionData { + chain: EvmChain, + contractType: EvmNftContractType; + name: string; + symbol: string; + tokenAddress: EvmAddress; +} diff --git a/packages/evmUtils/src/dataTypes/index.ts b/packages/evmUtils/src/dataTypes/index.ts index 4ee145ff95..c37e0226e8 100644 --- a/packages/evmUtils/src/dataTypes/index.ts +++ b/packages/evmUtils/src/dataTypes/index.ts @@ -1,15 +1,16 @@ export * from './Erc20'; +export * from './Erc20Transfer'; export * from './Erc20Value'; export * from './EvmAddress'; export * from './EvmBlock'; export * from './EvmChain'; -export * from './Erc20Transfer'; export * from './EvmEvent'; export * from './EvmNative'; export * from './EvmNft'; +export * from './EvmNftCollection'; +export * from './EvmNftContractType'; export * from './EvmNftMetadata'; export * from './EvmNftTrade'; export * from './EvmNftTransfer'; export * from './EvmTransaction'; export * from './EvmTransactionLog'; -export * from './EvmNftContractType'; diff --git a/packages/integration/mockRequests/evmApi/getWalletNFTCollections.ts b/packages/integration/mockRequests/evmApi/getWalletNFTCollections.ts new file mode 100644 index 0000000000..12d6b9c246 --- /dev/null +++ b/packages/integration/mockRequests/evmApi/getWalletNFTCollections.ts @@ -0,0 +1,41 @@ +import { rest } from 'msw'; +import { EVM_API_ROOT, MOCK_API_KEY } from '../config'; + +const collections: Record = { + '0x3514980793dceae1b34d0144e3ae725bee084a70': { + total: 1, + page_size: 100, + page: 1, + status: 'SYNCED', + "cursor": null, + result: [ + { + token_address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + contract_type: 'ERC721', + name: 'Test NFT', + symbol: 'TEST', + }, + ], + }, +}; + + +export const mockGetWalletNFTCollections = rest.get(`${EVM_API_ROOT}/:address/nft/collections`, (req, res, ctx) => { + const address = req.params.address as string; + const apiKey = req.headers.get('x-api-key'); + + if (apiKey !== MOCK_API_KEY) { + return res(ctx.status(401)); + } + + const value = collections[address]; + + if (!value) { + return res(ctx.status(404)); + } + + return res( + ctx.status(200), + ctx.json(value), + ); +}); diff --git a/packages/integration/mockRequests/mockRequests.ts b/packages/integration/mockRequests/mockRequests.ts index b984ae61b2..c6bdfdc588 100644 --- a/packages/integration/mockRequests/mockRequests.ts +++ b/packages/integration/mockRequests/mockRequests.ts @@ -24,6 +24,7 @@ import { mockGetTokenMetadataBySymbol } from './evmApi/getTokenMetadataBySymbol' import { mockGetTokenPrice } from './evmApi/getTokenPrice'; import { mockGetTokenTransfers } from './evmApi/getTokenTransfers'; import { mockGetTransaction } from './evmApi/getTransaction'; +import { mockGetWalletNFTCollections } from './evmApi/getWalletNFTCollections'; import { mockGetWalletNFTs } from './evmApi/getWalletNFTs'; import { mockGetWalletNFTTransfers } from './evmApi/getWalletNFTTransfers'; import { mockGetWalletTokenTransfers } from './evmApi/getWalletTokenTransfers'; @@ -73,6 +74,7 @@ const handlers = [ mockSyncNFTContract, mockUploadFolder, mockWeb3ApiVersion, + mockGetWalletNFTCollections ]; export const mockServer = setupServer(...handlers); diff --git a/packages/integration/test/evmApi/getWalletNFTCollections.test.ts b/packages/integration/test/evmApi/getWalletNFTCollections.test.ts new file mode 100644 index 0000000000..12527535e0 --- /dev/null +++ b/packages/integration/test/evmApi/getWalletNFTCollections.test.ts @@ -0,0 +1,51 @@ +import MoralisEvmApi from '@moralisweb3/evm-api'; +import { cleanEvmApi, setupEvmApi } from './setup'; + +describe('getWalletNFTCollections', () => { + let evmApi: MoralisEvmApi; + + beforeAll(() => { + evmApi = setupEvmApi(); + }); + + afterAll(() => { + cleanEvmApi(); + }); + + it('should get the Nft collections of an account address', async () => { + const result = await evmApi.nft.getWalletNFTCollections({ + address: '0x3514980793dceae1b34d0144e3ae725bee084a70', + }); + + expect(result).toBeDefined(); + expect(result.pagination.total).toBe(1); + expect(result.pagination.page).toBe(1); + expect(result.toJSON()[0]).toEqual( + expect.objectContaining({ + chain: '0x1', + contractType: 'ERC721', + name: 'Test NFT', + symbol: 'TEST', + tokenAddress: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + }), + ); + }); + + it('should not get the Nft collections and return an error code for an invalid address', () => { + const failedResult = evmApi.nft + .getWalletNFTCollections({ + address: '0x75e3e9c92162e62000425c98769965a76c2e387', + }) + .then() + .catch((err) => { + return err; + }); + + expect(failedResult).toBeDefined(); + expect( + evmApi.nft.getWalletNFTCollections({ + address: '0x75e3e9c92162e62000425c98769965a76c2e387', + }), + ).rejects.toThrowErrorMatchingInlineSnapshot(`"[C0005] Invalid address provided"`); + }); +});