Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
96 changes: 23 additions & 73 deletions src/alchemy-api/alchemy-api.client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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);
});

Expand All @@ -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);
});

Expand All @@ -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);
});
Expand All @@ -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);
});
});
Expand Down
40 changes: 1 addition & 39 deletions src/alchemy-api/alchemy-api.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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}`;
Expand Down Expand Up @@ -141,34 +131,6 @@ export class AlchemyApiClient implements AllowlistAlchemyClient {
};
}

async searchContractMetadata(
query: string,
): Promise<AlchemyContractMetadata[]> {
const response = await this.alchemyGet<RawV2ContractMetadata[]>(
`${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,
Expand Down
79 changes: 20 additions & 59 deletions src/alchemy-api/alchemy-api.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -57,7 +56,6 @@ describe(AlchemyApiService.name, () => {
warnSpy = jest.spyOn(Logger.prototype, 'warn').mockImplementation();
service = new AlchemyApiService({
getContractMetadata,
searchContractMetadata,
getBlockNumber,
getContractTokenIds,
core: { resolveName },
Expand Down Expand Up @@ -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);
Expand Down
19 changes: 0 additions & 19 deletions src/alchemy-api/alchemy-api.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,25 +134,6 @@ export class AlchemyApiService {
});
}

async searchContractMetadata(
kw: string,
): Promise<ContractMetadataResponse[]> {
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 {
Expand Down
12 changes: 0 additions & 12 deletions src/api/other/model/search-contract-metadata-request-api.model.ts

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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',
Expand Down
Loading