diff --git a/CHANGELOG.md b/CHANGELOG.md index 70a1bf278..aa56037ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Major Changes +- Added the `NftMetadataUpdateWebhook` to be used with the `NotifyNamespace`. This webhook tracks all ERC721 and ERC1155 token metadata updates. + ### Minor Changes ## 2.5.0 diff --git a/src/api/notify-namespace.ts b/src/api/notify-namespace.ts index 7b3f6ae7d..969b64ee5 100644 --- a/src/api/notify-namespace.ts +++ b/src/api/notify-namespace.ts @@ -24,6 +24,8 @@ import { NftActivityWebhook, NftFilter, NftFiltersResponse, + NftMetadataUpdateWebhook, + NftMetadataWebhookUpdate, NftWebhookParams, NftWebhookUpdate, TransactionWebhookParams, @@ -178,6 +180,17 @@ export class NotifyNamespace { */ updateWebhook(nftWebhookId: string, update: NftWebhookUpdate): Promise; + /** + * Update a {@link NftMetadataUpdateWebhook}'s active status or NFT filters. + * + * @param nftMetadataWebhookId The id of the NFT activity webhook. + * @param update Object containing the update. + */ + updateWebhook( + nftMetadataWebhookId: string, + update: NftMetadataWebhookUpdate + ): Promise; + /** * Update a {@link AddressActivityWebhook}'s active status or addresses. * @@ -201,7 +214,7 @@ export class NotifyNamespace { ): Promise; async updateWebhook( webhookOrId: NftActivityWebhook | AddressActivityWebhook | string, - update: NftWebhookUpdate | AddressWebhookUpdate + update: NftWebhookUpdate | AddressWebhookUpdate | NftMetadataWebhookUpdate ): Promise { const webhookId = typeof webhookOrId === 'string' ? webhookOrId : webhookOrId.id; @@ -230,6 +243,22 @@ export class NotifyNamespace { ? update.removeFilters.map(nftFilterToParam) : [] }; + } else if ( + 'addMetadataFilters' in update || + 'removeMetadataFilters' in update + ) { + restApiName = 'update-webhook-nft-metadata-filters'; + methodName = 'updateWebhookNftMetadataFilters'; + method = 'PATCH'; + data = { + webhook_id: webhookId, + nft_metadata_filters_to_add: update.addMetadataFilters + ? update.addMetadataFilters.map(nftFilterToParam) + : [], + nft_metadata_filters_to_remove: update.removeMetadataFilters + ? update.removeMetadataFilters.map(nftFilterToParam) + : [] + }; } else if ('addAddresses' in update || 'removeAddresses' in update) { restApiName = 'update-webhook-addresses'; methodName = 'webhook:updateWebhookAddresses'; @@ -310,6 +339,12 @@ export class NotifyNamespace { params: NftWebhookParams ): Promise; + createWebhook( + url: string, + type: WebhookType.NFT_METADATA_UPDATE, + params: NftWebhookParams + ): Promise; + /** * Create a new {@link AddressActivityWebhook} to track address activity. * @@ -332,6 +367,7 @@ export class NotifyNamespace { | DroppedTransactionWebhook | NftActivityWebhook | AddressActivityWebhook + | NftMetadataUpdateWebhook > { let appId; if ( @@ -345,9 +381,12 @@ export class NotifyNamespace { } let network = NETWORK_TO_WEBHOOK_NETWORK.get(this.config.network); - let filters; + let nftFilterObj; let addresses; - if (type === WebhookType.NFT_ACTIVITY) { + if ( + type === WebhookType.NFT_ACTIVITY || + type === WebhookType.NFT_METADATA_UPDATE + ) { if (!('filters' in params) || params.filters.length === 0) { throw new Error( 'Nft Activity Webhooks require a non-empty array input.' @@ -356,7 +395,7 @@ export class NotifyNamespace { network = params.network ? NETWORK_TO_WEBHOOK_NETWORK.get(params.network) : network; - filters = (params.filters as NftFilter[]).map(filter => + const filters = (params.filters as NftFilter[]).map(filter => filter.tokenId ? { contract_address: filter.contractAddress, @@ -366,6 +405,10 @@ export class NotifyNamespace { contract_address: filter.contractAddress } ); + nftFilterObj = + type === WebhookType.NFT_ACTIVITY + ? { nft_filters: filters } + : { nft_metadata_filters: filters }; } else if (type === WebhookType.ADDRESS_ACTIVITY) { if ( params === undefined || @@ -388,8 +431,8 @@ export class NotifyNamespace { webhook_url: url, ...(appId && { app_id: appId }), - // Only include the filters/addresses in the final response if it's defined - ...(filters && { nft_filters: filters }), + // Only include the filters/addresses in the final response if they're defined + ...nftFilterObj, ...(addresses && { addresses }) }; diff --git a/src/types/types.ts b/src/types/types.ts index 8cb934f08..a87b08a75 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -2123,7 +2123,8 @@ export enum WebhookType { MINED_TRANSACTION = 'MINED_TRANSACTION', DROPPED_TRANSACTION = 'DROPPED_TRANSACTION', ADDRESS_ACTIVITY = 'ADDRESS_ACTIVITY', - NFT_ACTIVITY = 'NFT_ACTIVITY' + NFT_ACTIVITY = 'NFT_ACTIVITY', + NFT_METADATA_UPDATE = 'NFT_METADATA_UPDATE' } /** @@ -2162,6 +2163,15 @@ export interface NftActivityWebhook extends Webhook { type: WebhookType.NFT_ACTIVITY; } +/** + * The NFT Metadata Update Webhook tracks all ERC721 and ERC1155 metadata updates. + * This can be used to notify your app with real time state changes when an NFT's + * metadata changes. + */ +export interface NftMetadataUpdateWebhook extends Webhook { + type: WebhookType.NFT_METADATA_UPDATE; +} + /** The response for a {@link NotifyNamespace.getAllWebhooks} method. */ export interface GetAllWebhooksResponse { /** All webhooks attached to the provided auth token. */ @@ -2207,7 +2217,7 @@ export interface TransactionWebhookParams { /** * Params to pass in when calling {@link NotifyNamespace.createWebhook} in order - * to create a {@link NftActivityWebhook}. + * to create a {@link NftActivityWebhook} or {@link NftMetadataUpdateWebhook}. */ export interface NftWebhookParams { /** Array of NFT filters the webhook should track. */ @@ -2233,7 +2243,7 @@ export interface AddressWebhookParams { network?: Network; } -/** NFT to track on a {@link NftActivityWebhook}. */ +/** NFT to track on a {@link NftActivityWebhook} or {@link NftMetadataUpdateWebhook}. */ export interface NftFilter { /** The contract address of the NFT. */ contractAddress: string; @@ -2274,6 +2284,17 @@ export interface WebhookNftFilterUpdate { removeFilters: NftFilter[]; } +/** + * Params object when calling {@link NotifyNamespace.updateWebhook} to add and + * remove NFT filters for a {@link NftMetadataUpdateWebhook}. + */ +export interface WebhookNftMetadataFilterUpdate { + /** The filters to additionally track. */ + addMetadataFilters: NftFilter[]; + /** Existing filters to remove. */ + removeMetadataFilters: NftFilter[]; +} + /** * Params object when calling {@link NotifyNamespace.updateWebhook} to add and * remove addresses for a {@link AddressActivityWebhook}. @@ -2298,11 +2319,18 @@ export interface WebhookAddressOverride { * Params object when calling {@link NotifyNamespace.updateWebhook} to update a * {@link NftActivityWebhook}. */ - export type NftWebhookUpdate = | WebhookStatusUpdate | RequireAtLeastOne; +/** + * Params object when calling {@link NotifyNamespace.updateWebhook} to update a + * {@link NftMetadataUpdateWebhook}. + */ +export type NftMetadataWebhookUpdate = + | WebhookStatusUpdate + | RequireAtLeastOne; + /** * Params object when calling {@link NotifyNamespace.updateWebhook} to update a * {@link AddressActivityWebhook}. diff --git a/test/integration/notify.test.ts b/test/integration/notify.test.ts index 0936c8e8c..b086a981c 100644 --- a/test/integration/notify.test.ts +++ b/test/integration/notify.test.ts @@ -4,6 +4,7 @@ import { Network, NftActivityWebhook, NftFilter, + NftMetadataUpdateWebhook, WebhookType } from '../../src'; import { loadAlchemyEnv } from '../test-util'; @@ -32,6 +33,7 @@ describe('E2E integration tests', () => { let addressWh: AddressActivityWebhook; let nftWh: NftActivityWebhook; + let nftMetadataWh: NftMetadataUpdateWebhook; async function createInitialWebhooks(): Promise { addressWh = await alchemy.notify.createWebhook( @@ -44,6 +46,11 @@ describe('E2E integration tests', () => { WebhookType.NFT_ACTIVITY, { filters: nftFilters, network: Network.ETH_MAINNET } ); + nftMetadataWh = await alchemy.notify.createWebhook( + webhookUrl, + WebhookType.NFT_METADATA_UPDATE, + { filters: nftFilters, network: Network.ETH_MAINNET } + ); } beforeAll(async () => { @@ -226,12 +233,35 @@ describe('E2E integration tests', () => { ).toEqual(0); }); + it('create and delete NftActivityWebhook', async () => { + const nftActivityWebhook = await alchemy.notify.createWebhook( + webhookUrl, + WebhookType.NFT_METADATA_UPDATE, + { filters: nftFilters, network: Network.ETH_GOERLI } + ); + expect(nftActivityWebhook.url).toEqual(webhookUrl); + expect(nftActivityWebhook.type).toEqual(WebhookType.NFT_METADATA_UPDATE); + expect(nftActivityWebhook.network).toEqual(Network.ETH_GOERLI); + let response = await alchemy.notify.getAllWebhooks(); + expect( + response.webhooks.filter(wh => wh.id === nftActivityWebhook.id).length + ).toEqual(1); + + await alchemy.notify.deleteWebhook(nftActivityWebhook.id); + response = await alchemy.notify.getAllWebhooks(); + expect( + response.webhooks.filter(wh => wh.id === nftActivityWebhook.id).length + ).toEqual(0); + }); + it('update NftActivityWebhook filter with same filter', async () => { const addFilters = [ + // Duplicate filter { contractAddress: '0x88b48f654c30e99bc2e4a1559b4dcf1ad93fa656', tokenId: '234' }, + // New Filter { contractAddress: '0x88b48f654c30e99bc2e4a1559b4dcf1ad93fa656', tokenId: '123' @@ -272,6 +302,54 @@ describe('E2E integration tests', () => { expect(updated[0].isActive).toEqual(false); }); + it('update NftMetadataUpdateWebhook status', async () => { + await alchemy.notify.updateWebhook(nftMetadataWh.id, { + isActive: false + }); + const response = await alchemy.notify.getAllWebhooks(); + const updated = response.webhooks.filter(wh => wh.id === nftMetadataWh.id); + expect(updated.length).toEqual(1); + expect(updated[0].isActive).toEqual(false); + }); + + it('update NftMetadataUpdateWebhook filter with same filter', async () => { + const addMetadataFilters = [ + // Duplicate filter + { + contractAddress: '0x88b48f654c30e99bc2e4a1559b4dcf1ad93fa656', + tokenId: '234' + }, + // New Filter + { + contractAddress: '0x88b48f654c30e99bc2e4a1559b4dcf1ad93fa656', + tokenId: '123' + } + ]; + + const removeMetadataFilters = [ + { + contractAddress: '0x17dc95f9052f86ed576af55b018360f853e19ac2', + tokenId: 345 + } + ]; + + await alchemy.notify.updateWebhook(nftWh, { + addFilters: addMetadataFilters, + removeFilters: removeMetadataFilters + }); + + const response = await alchemy.notify.getNftFilters(nftWh); + expect(response.filters.length).toEqual(2); + + await alchemy.notify.updateWebhook(nftWh, { + removeFilters: removeMetadataFilters + }); + + await alchemy.notify.updateWebhook(nftWh, { + addFilters: addMetadataFilters + }); + }); + it('update AddressActivityWebhook address', async () => { const addAddress = '0x7f268357A8c2552623316e2562D90e642bB538E5'; const removeAddress = '0xfdb16996831753d5331ff813c29a93c76834a0ad';