diff --git a/README.md b/README.md index 70aa0f9..99b88bb 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,21 @@ In production the app is ran in 3 lambas: 2. Worker lambda - Does the actual final allowlist creation (handler: `dist/worker-lambda.handler`) 3. Tokenpool downloader lambda - Helps to get aggregated tokenpool data needed for worker lambda (handler: `dist/token-pool-downloader-lambda.handler`) +## Collection metadata + +EMMA offers five fixed 6529 collection shortcuts and manual contract entry. +`GET /other/memes-collections` and +`GET /other/contract-metadata/:contract` remain supported, including canonical +fallback metadata for 6529 collections. Both retain their existing response +schema. Exact-address lookup uses Alchemy V3 `getContractMetadata`; historical +owner snapshots still use V2 `getOwnersForCollection`. + +The retired `POST /other/search-contract-metadata` route is no longer available. +Deploy the compatible frontend before this API cleanup, verify the collection +and manual-entry flows, then confirm the old route is unused through request +telemetry or allow a compatibility window for older clients. Deploy staging +before production using the existing Lambda workflows described above. + ## Dependency security policy CI and both deployment workflows run two independent audits: diff --git a/src/alchemy-api/alchemy-api.client.spec.ts b/src/alchemy-api/alchemy-api.client.spec.ts index 238fab1..29629be 100644 --- a/src/alchemy-api/alchemy-api.client.spec.ts +++ b/src/alchemy-api/alchemy-api.client.spec.ts @@ -116,72 +116,22 @@ describe(AlchemyApiClient.name, () => { ).resolves.toBeNull(); }); - it('maps v2 contract search results and filters unknown verification statuses', async () => { + it('filters unknown verification statuses in exact-address metadata', async () => { axiosGet.mockResolvedValue({ - data: [ - { - address: '0x1111111111111111111111111111111111111111', - contractMetadata: { - name: 'Collection', - tokenType: 'ERC1155', - opensea: { - safelistRequestStatus: 'unrecognized', - description: 'Description', - }, - }, - }, - ], - }); - - await expect(client.searchContractMetadata('collect')).resolves.toEqual([ - { + data: { address: '0x1111111111111111111111111111111111111111', name: 'Collection', tokenType: 'ERC1155', - openSea: { - collectionName: undefined, - safelistRequestStatus: undefined, - imageUrl: undefined, - description: 'Description', - }, - }, - ]); - expect(axiosGet).toHaveBeenCalledWith( - 'https://eth-mainnet.g.alchemy.com/nft/v2/api%20key%2Fwith-special-characters/searchContractMetadata', - { - params: { query: 'collect' }, - headers: { accept: '*/*' }, + openseaMetadata: { safelistRequestStatus: 'unrecognized' }, }, - ); - }); - - it('skips malformed contract search entries without discarding valid results', async () => { - axiosGet.mockResolvedValue({ - data: [ - { - address: '0x1111111111111111111111111111111111111111', - contractMetadata: { name: 'Collection', tokenType: 'ERC721' }, - }, - { address: '0x2222222222222222222222222222222222222222' }, - ], }); - await expect(client.searchContractMetadata('collect')).resolves.toEqual([ - { - address: '0x1111111111111111111111111111111111111111', - name: 'Collection', - tokenType: 'ERC721', - openSea: undefined, - }, - ]); - }); - - it('rejects malformed contract search responses', async () => { - axiosGet.mockResolvedValue({ data: { contracts: [] } }); - - await expect(client.searchContractMetadata('collect')).rejects.toThrow( - 'Invalid Alchemy contract search response', - ); + await expect( + client.getContractMetadata('0x1111111111111111111111111111111111111111'), + ).resolves.toMatchObject({ + tokenType: 'ERC1155', + openSea: { safelistRequestStatus: undefined }, + }); }); it('preserves historical owner options, token balances, and page keys', async () => { @@ -317,9 +267,9 @@ describe(AlchemyApiClient.name, () => { response: { status: 400, data: 'invalid request' }, }); - await expect(client.searchContractMetadata('collection')).rejects.toThrow( - '400: invalid request', - ); + await expect( + client.getContractMetadata('0x1111111111111111111111111111111111111111'), + ).rejects.toThrow('400: invalid request'); expect(axiosGet).toHaveBeenCalledTimes(1); }); @@ -333,9 +283,9 @@ describe(AlchemyApiClient.name, () => { }); jest.spyOn(client as any, 'sleep').mockResolvedValue(undefined); - await expect(client.searchContractMetadata('collection')).rejects.toThrow( - '429: {"code":-32000,"message":"rate limited"}', - ); + await expect( + client.getContractMetadata('0x1111111111111111111111111111111111111111'), + ).rejects.toThrow('429: {"code":-32000,"message":"rate limited"}'); expect(axiosGet).toHaveBeenCalledTimes(5); }); @@ -346,11 +296,11 @@ describe(AlchemyApiClient.name, () => { isAxiosError: true, response: { status: 429, data: 'rate limited' }, }) - .mockResolvedValueOnce({ data: [] }); + .mockResolvedValueOnce({ data: null }); - await expect(client.searchContractMetadata('collection')).resolves.toEqual( - [], - ); + await expect( + client.getContractMetadata('0x1111111111111111111111111111111111111111'), + ).resolves.toBeNull(); expect(axiosGet).toHaveBeenCalledTimes(2); expect((client as any).sleep).toHaveBeenCalledWith(1_000); }); @@ -363,11 +313,11 @@ describe(AlchemyApiClient.name, () => { isAxiosError: true, response: { status: 503, data: 'provider unavailable' }, }) - .mockResolvedValueOnce({ data: [] }); + .mockResolvedValueOnce({ data: null }); - await expect(client.searchContractMetadata('collection')).resolves.toEqual( - [], - ); + await expect( + client.getContractMetadata('0x1111111111111111111111111111111111111111'), + ).resolves.toBeNull(); expect(axiosGet).toHaveBeenCalledTimes(3); }); }); diff --git a/src/alchemy-api/alchemy-api.client.ts b/src/alchemy-api/alchemy-api.client.ts index f217c14..18dc6f2 100644 --- a/src/alchemy-api/alchemy-api.client.ts +++ b/src/alchemy-api/alchemy-api.client.ts @@ -47,16 +47,6 @@ export interface AlchemyContractMetadata { type RawOpenSeaMetadata = AlchemyOpenSeaMetadata; -interface RawV2ContractMetadata { - address: string; - contractMetadata?: { - name?: string; - tokenType?: string; - openSea?: RawOpenSeaMetadata; - opensea?: RawOpenSeaMetadata; - }; -} - interface RawV3ContractMetadata { address: string; name?: string; @@ -99,7 +89,7 @@ export class AlchemyApiClient implements AllowlistAlchemyClient { private readonly provider: AlchemyJsonRpcProvider, ) { const encodedApiKey = encodeURIComponent(alchemyConfig.key); - // Keyword search and historical owner snapshots still use the v2 contracts; + // Historical owner snapshots still use the v2 contracts; // maintained metadata and token-list operations use v3. this.nftV2BaseUrl = `https://eth-mainnet.g.alchemy.com/nft/v2/${encodedApiKey}`; this.nftV3BaseUrl = `https://eth-mainnet.g.alchemy.com/nft/v3/${encodedApiKey}`; @@ -141,34 +131,6 @@ export class AlchemyApiClient implements AllowlistAlchemyClient { }; } - async searchContractMetadata( - query: string, - ): Promise { - const response = await this.alchemyGet( - `${this.nftV2BaseUrl}/searchContractMetadata`, - { query }, - ); - if (!Array.isArray(response)) { - throw new Error('Invalid Alchemy contract search response'); - } - return response.flatMap((contract) => { - const metadata = contract?.contractMetadata; - if (!metadata || typeof contract.address !== 'string') { - return []; - } - return [ - { - address: contract.address, - name: metadata.name, - tokenType: this.normalizeTokenType(metadata.tokenType), - openSea: this.normalizeOpenSeaMetadata( - metadata.openSea ?? metadata.opensea, - ), - }, - ]; - }); - } - async getContractTokenIds({ address, continuation, diff --git a/src/alchemy-api/alchemy-api.service.spec.ts b/src/alchemy-api/alchemy-api.service.spec.ts index d6fdc6d..86b598f 100644 --- a/src/alchemy-api/alchemy-api.service.spec.ts +++ b/src/alchemy-api/alchemy-api.service.spec.ts @@ -45,7 +45,6 @@ const CANONICAL_COLLECTIONS = [ describe(AlchemyApiService.name, () => { const getContractMetadata = jest.fn(); - const searchContractMetadata = jest.fn(); const getBlockNumber = jest.fn(); const getContractTokenIds = jest.fn(); const resolveName = jest.fn(); @@ -57,7 +56,6 @@ describe(AlchemyApiService.name, () => { warnSpy = jest.spyOn(Logger.prototype, 'warn').mockImplementation(); service = new AlchemyApiService({ getContractMetadata, - searchContractMetadata, getBlockNumber, getContractTokenIds, core: { resolveName }, @@ -203,65 +201,28 @@ describe(AlchemyApiService.name, () => { ); }); - it('applies the canonical fallback to configured collection search results', async () => { - searchContractMetadata.mockResolvedValue([ - { - address: MEME_LAB_CONTRACT, - name: 'N/A', - tokenType: 'UNKNOWN', - openSea: null, - }, - ]); - - await expect(service.searchContractMetadata('memes')).resolves.toEqual([ - expect.objectContaining({ - id: MEME_LAB_CONTRACT, - address: MEME_LAB_CONTRACT, - name: 'Meme Lab', - tokenType: 'ERC1155', - imageUrl: CANONICAL_COLLECTIONS[1].imageUrl, - }), - ]); - }); - - it('maps only the OpenSea verified status to true in search results', async () => { - searchContractMetadata.mockResolvedValue([ - { - address: '0x1111111111111111111111111111111111111111', - name: 'Verified', - tokenType: 'ERC721', - openSea: { safelistRequestStatus: 'verified' }, - }, - { - address: '0x2222222222222222222222222222222222222222', - name: 'Approved', + it.each([ + ['verified', true], + ['approved', false], + ])( + 'maps OpenSea status %s to verified=%s for exact-address metadata', + async (status, verified) => { + const address = '0x1111111111111111111111111111111111111111'; + getContractMetadata.mockResolvedValue({ + address, + name: 'Collection', tokenType: 'ERC721', - openSea: { safelistRequestStatus: 'approved' }, - }, - ]); - - await expect(service.searchContractMetadata('collection')).resolves.toEqual( - [ - expect.objectContaining({ - address: '0x1111111111111111111111111111111111111111', - openseaVerified: true, - }), - expect.objectContaining({ - address: '0x2222222222222222222222222222222222222222', - openseaVerified: false, - }), - ], - ); - }); - - it('propagates contract metadata search provider errors', async () => { - const providerError = new Error('provider unavailable'); - searchContractMetadata.mockRejectedValue(providerError); + openSea: { safelistRequestStatus: status }, + }); - await expect(service.searchContractMetadata('collection')).rejects.toBe( - providerError, - ); - }); + await expect(service.getContractMetadata(address)).resolves.toMatchObject( + { + address, + openseaVerified: verified, + }, + ); + }, + ); it('returns the Ethereum mainnet block number from the client', async () => { getBlockNumber.mockResolvedValue(24_123_456); diff --git a/src/alchemy-api/alchemy-api.service.ts b/src/alchemy-api/alchemy-api.service.ts index fc1f93b..b0d114e 100644 --- a/src/alchemy-api/alchemy-api.service.ts +++ b/src/alchemy-api/alchemy-api.service.ts @@ -134,25 +134,6 @@ export class AlchemyApiService { }); } - async searchContractMetadata( - kw: string, - ): Promise { - const contracts = await this.alchemyClient.searchContractMetadata(kw); - - return contracts.map((metadata) => - this.applyCanonicalFallback({ - id: metadata.address, - address: metadata.address, - name: metadata?.name ?? 'N/A', - tokenType: metadata?.tokenType ?? 'N/A', - description: metadata?.openSea?.description ?? 'N/A', - imageUrl: metadata?.openSea?.imageUrl ?? null, // optional - openseaVerified: - metadata?.openSea?.safelistRequestStatus === OPENSEA_VERIFIED_STATUS, - }), - ); - } - private applyCanonicalFallback( metadata: ContractMetadataResponse, ): ContractMetadataResponse { diff --git a/src/api/other/model/search-contract-metadata-request-api.model.ts b/src/api/other/model/search-contract-metadata-request-api.model.ts deleted file mode 100644 index 3cdf9e5..0000000 --- a/src/api/other/model/search-contract-metadata-request-api.model.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsString } from 'class-validator'; - -export class SearchContractMetadataRequestApiModel { - @ApiProperty({ - description: 'Keyword to search for', - minLength: 1, - maxLength: 250, - }) - @IsString() - readonly keyword: string; -} diff --git a/src/api/other/model/search-contract-metadata-response-api.model.ts b/src/api/other/model/search-contract-metadata-response-api.model.ts index 08630a0..583e93b 100644 --- a/src/api/other/model/search-contract-metadata-response-api.model.ts +++ b/src/api/other/model/search-contract-metadata-response-api.model.ts @@ -1,6 +1,8 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsBoolean, IsNumber, IsOptional, IsString } from 'class-validator'; +// Keep the existing OpenAPI schema name for exact-address metadata and the +// ecosystem collections endpoint, which share this response contract. export class SearchContractMetadataResponseApiModel { @ApiProperty({ description: 'ID of the contract', diff --git a/src/api/other/other.controller.spec.ts b/src/api/other/other.controller.spec.ts new file mode 100644 index 0000000..b4a5726 --- /dev/null +++ b/src/api/other/other.controller.spec.ts @@ -0,0 +1,169 @@ +import { HttpService } from '@nestjs/axios'; +import { INestApplication, Logger } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { jest } from '@jest/globals'; +import request from 'supertest'; +import { + AlchemyApiClient, + AlchemyJsonRpcProvider, +} from '../../alchemy-api/alchemy-api.client'; +import { AlchemyApiService } from '../../alchemy-api/alchemy-api.service'; +import { AlchemyConfig } from '../../alchemy-api/alchemy.config'; +import { configureApiApplication } from '../../api-bootstrap'; +import { EtherscanApiService } from '../../etherscan-api/etherscan-api.service'; +import { SeizeApiService } from '../../seize-api/seize-api.service'; +import { TransposeApiService } from '../../transpose-api/transpose-api.service'; +import { OtherController } from './other.controller'; +import { OtherService } from './other.service'; + +const CONTRACT = '0x1111111111111111111111111111111111111111'; + +describe('EMMA collection metadata HTTP routes', () => { + const axiosGet = jest.fn(); + let app: INestApplication; + + beforeAll(async () => { + const client = new AlchemyApiClient( + new AlchemyConfig({ key: 'test-key' }), + { axiosRef: { get: axiosGet } } as unknown as HttpService, + {} as AlchemyJsonRpcProvider, + ); + const service = new OtherService( + new AlchemyApiService(client), + {} as TransposeApiService, + {} as SeizeApiService, + {} as EtherscanApiService, + ); + const module = await Test.createTestingModule({ + controllers: [OtherController], + providers: [{ provide: OtherService, useValue: service }], + }).compile(); + app = module.createNestApplication({ logger: false }); + configureApiApplication(app); + await app.init(); + }); + + beforeEach(() => { + axiosGet.mockReset(); + jest.spyOn(Logger.prototype, 'warn').mockImplementation(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + afterAll(async () => { + await app.close(); + }); + + it('returns 404 for retired keyword search without contacting Alchemy', async () => { + await request(app.getHttpServer()) + .post('/other/search-contract-metadata') + .send({ keyword: 'memes' }) + .expect(404); + + expect(axiosGet).not.toHaveBeenCalled(); + }); + + it('removes keyword search from OpenAPI while preserving shared metadata schemas', async () => { + const response = await request(app.getHttpServer()) + .get('/api-json') + .expect(200); + + expect(response.body.paths).not.toHaveProperty( + '/other/search-contract-metadata', + ); + expect(response.body.components.schemas).not.toHaveProperty( + 'SearchContractMetadataRequestApiModel', + ); + const metadataSchema = { + $ref: '#/components/schemas/SearchContractMetadataResponseApiModel', + }; + expect( + response.body.paths['/other/contract-metadata/{contract}'].get.responses[ + '200' + ].content['application/json'].schema, + ).toEqual(metadataSchema); + expect( + response.body.paths['/other/memes-collections'].get.responses['200'] + .content['application/json'].schema, + ).toEqual({ type: 'array', items: metadataSchema }); + }); + + it('preserves arbitrary-contract metadata through the supported exact-address endpoint', async () => { + axiosGet.mockResolvedValue({ + data: { + address: CONTRACT, + name: 'Another collection', + tokenType: 'ERC721', + openseaMetadata: { + imageUrl: 'https://example.com/collection.png', + description: 'Collection description', + safelistRequestStatus: 'verified', + }, + }, + }); + + await request(app.getHttpServer()) + .get(`/other/contract-metadata/${CONTRACT}`) + .expect(200) + .expect({ + id: CONTRACT, + address: CONTRACT, + name: 'Another collection', + tokenType: 'ERC721', + floorPrice: null, + imageUrl: 'https://example.com/collection.png', + description: 'Collection description', + allTimeVolume: null, + openseaVerified: true, + }); + + expect(axiosGet).toHaveBeenCalledTimes(1); + expect(axiosGet).toHaveBeenCalledWith( + 'https://eth-mainnet.g.alchemy.com/nft/v3/test-key/getContractMetadata', + { + params: { contractAddress: CONTRACT }, + headers: { accept: '*/*' }, + }, + ); + }); + + it.each(['empty', 'unavailable'])( + 'keeps all five shortcuts available when external metadata is %s', + async (providerState) => { + if (providerState === 'empty') { + axiosGet.mockResolvedValue({ data: null }); + } else { + axiosGet.mockRejectedValue(new Error('provider unavailable')); + } + + const response = await request(app.getHttpServer()) + .get('/other/memes-collections') + .expect(200); + + expect(response.body).toHaveLength(5); + expect( + response.body.map((collection: { name: string; tokenType: string }) => [ + collection.name, + collection.tokenType.toUpperCase(), + ]), + ).toEqual([ + ['The Memes by 6529', 'ERC1155'], + ['Meme Lab', 'ERC1155'], + ['6529 Gradient', 'ERC721'], + ['6529 RAW', 'ERC721'], + ['6529 Intern JPGs', 'ERC1155'], + ]); + expect(response.body[4].id).toBe( + '0x495f947276749ce646f68ac8c248420045cb7b5e:opensea-6529internjpg', + ); + expect(axiosGet).toHaveBeenCalledTimes(4); + for (const [url] of axiosGet.mock.calls) { + expect(url).toBe( + 'https://eth-mainnet.g.alchemy.com/nft/v3/test-key/getContractMetadata', + ); + } + }, + ); +}); diff --git a/src/api/other/other.controller.ts b/src/api/other/other.controller.ts index e74bc04..e3bfc30 100644 --- a/src/api/other/other.controller.ts +++ b/src/api/other/other.controller.ts @@ -3,7 +3,6 @@ import { OtherService } from './other.service'; import { OperationDescriptionsResponseApiModel } from './model/operation-descriptions-response-api.model'; import { ApiOkResponse, ApiOperation } from '@nestjs/swagger'; import { SearchContractMetadataResponseApiModel } from './model/search-contract-metadata-response-api.model'; -import { SearchContractMetadataRequestApiModel } from './model/search-contract-metadata-request-api.model'; import { ContractTokenIdsAsStringResponseApiModel } from './model/contract-token-ids-as-string-response-api.model'; import { MemesSeasonResponseApiModel } from './model/memes-season-response-api.model'; import { ResolveEnsResponseApiModel } from './model/resolve-ens-response-api.model'; @@ -43,20 +42,6 @@ export class OtherController { return this.otherService.getOperationDescriptionsForType(operationType); } - @ApiOperation({ - summary: 'Search contract metadata', - }) - @ApiOkResponse({ - type: SearchContractMetadataResponseApiModel, - isArray: true, - }) - @Post('search-contract-metadata') - async searchContractMetadata( - @Body() { keyword }: SearchContractMetadataRequestApiModel, - ): Promise { - return await this.otherService.searchContractMetadata(keyword); - } - @ApiOperation({ summary: 'Get contract metadata', }) diff --git a/src/api/other/other.service.ts b/src/api/other/other.service.ts index cda0102..48fe138 100644 --- a/src/api/other/other.service.ts +++ b/src/api/other/other.service.ts @@ -95,13 +95,6 @@ export class OtherService { }; } - async searchContractMetadata( - kw: string, - ): Promise { - const contracts = await this.alchemyApiService.searchContractMetadata(kw); - return (contracts ?? []).map(this.mapContractMetadata); - } - async getLatestBlockNumber(): Promise { return await this.alchemyApiService.getBlockNumber(); }