diff --git a/packages/api/package.json b/packages/api/package.json index bae2e4e4a4..daba06eb73 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -28,7 +28,9 @@ "joi": "^17.10.1", "lodash": "^4.17.21", "rxjs": "^7.8.1", - "ws": "^8.13.0" + "ws": "^8.13.0", + "@iota/iota.js-next": "npm:@iota/iota.js@2.0.0-rc.4", + "@iota/util.js-next": "npm:@iota/util.js@2.0.0-rc.2" }, "devDependencies": { "@types/express": "^4.17.17", diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 034d00945b..b1a80654cb 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -13,6 +13,9 @@ import { getPriceChange } from './getPriceChange'; import { getTokenPrice } from './getTokenPrice'; import { getTopMilestones } from './getTopMilestones'; import { getUpdatedAfter } from './getUpdatedAfter'; +import { getNftIds } from './metadataNft/getNftIds'; +import { getNftMutableMetadata } from './metadataNft/getNftMutableMetadata'; +import { getNftMutableMetadataHistory } from './metadataNft/getNftMutableMetadataHistory'; import { sendLiveUpdates } from './sendLiveUpdates'; const port = process.env.PORT || 3000; @@ -83,6 +86,12 @@ const getObservable = (url: string): Promise> => { return getAddresses(url); case ApiRoutes.GET_TOP_MILESTONES: return getTopMilestones(url); + case ApiRoutes.GET_NFT_MUTABLE_METADATA: + return getNftMutableMetadata(url); + case ApiRoutes.GET_NFT_IDS: + return getNftIds(url); + case ApiRoutes.GET_NFT_MUTABLE_METADATA_HISTORY: + return getNftMutableMetadataHistory(url); default: throw { code: 400, message: 'Invalid route' }; } diff --git a/packages/api/src/metadataNft/getNftIds.ts b/packages/api/src/metadataNft/getNftIds.ts new file mode 100644 index 0000000000..5f28aa1952 --- /dev/null +++ b/packages/api/src/metadataNft/getNftIds.ts @@ -0,0 +1,46 @@ +import { GetNftIds, Network, WenError } from '@build-5/interfaces'; +import { + Bech32Helper, + INftOutput, + IndexerPluginClient, + NFT_ADDRESS_TYPE, + TransactionHelper, +} from '@iota/iota.js-next'; +import { Converter, HexHelper } from '@iota/util.js-next'; +import Joi from 'joi'; +import { of } from 'rxjs'; +import { CommonJoi, getQueryParams } from '../common'; +import { EMPTY_NFT_ID, getShimmerClient } from './wallet'; + +const getNftIdsSchema = Joi.object({ + network: Joi.string().valid(Network.SMR, Network.RMS), + collectionId: CommonJoi.uid(), +}); + +export const getNftIds = async (url: string) => { + const body = getQueryParams(url, getNftIdsSchema); + try { + const client = await getShimmerClient(body.network); + const indexer = new IndexerPluginClient(client); + + const collectionOutputId = (await indexer.nft(body.collectionId)).items[0]; + const { nftId: collectionId } = (await client.output(collectionOutputId)).output as INftOutput; + const issuerAddress = Bech32Helper.toBech32( + NFT_ADDRESS_TYPE, + Converter.hexToBytes(HexHelper.stripPrefix(collectionId)), + body.network || Network.SMR, + ); + + const nftOutputIds = (await indexer.nfts({ issuerBech32: issuerAddress })).items; + const promises = nftOutputIds.map(async (outputId) => { + const output = (await client.output(outputId)).output as INftOutput; + if (output.nftId === EMPTY_NFT_ID) { + return TransactionHelper.resolveIdFromOutputId(outputId); + } + return output.nftId; + }); + return of(await Promise.all(promises)); + } catch (error) { + throw { code: 400, message: WenError.invalid_collection_id.key }; + } +}; diff --git a/packages/api/src/metadataNft/getNftMutableMetadata.ts b/packages/api/src/metadataNft/getNftMutableMetadata.ts new file mode 100644 index 0000000000..267d44791a --- /dev/null +++ b/packages/api/src/metadataNft/getNftMutableMetadata.ts @@ -0,0 +1,24 @@ +import { GetNftMutableData, Network, WenError } from '@build-5/interfaces'; +import { INftOutput, IndexerPluginClient } from '@iota/iota.js-next'; +import Joi from 'joi'; +import { of } from 'rxjs'; +import { CommonJoi, getQueryParams } from '../common'; +import { getMutableMetadata, getShimmerClient } from './wallet'; + +const getNftMutableDataSchema = Joi.object({ + network: Joi.string().valid(Network.SMR, Network.RMS), + nftId: CommonJoi.uid(), +}); + +export const getNftMutableMetadata = async (url: string) => { + const body = getQueryParams(url, getNftMutableDataSchema); + try { + const client = await getShimmerClient(body.network); + const indexer = new IndexerPluginClient(client); + const outputId = (await indexer.nft(body.nftId)).items[0]; + const output = (await client.output(outputId)).output as INftOutput; + return of(getMutableMetadata(output)); + } catch { + throw { code: 400, message: WenError.invalid_nft_id.key }; + } +}; diff --git a/packages/api/src/metadataNft/getNftMutableMetadataHistory.ts b/packages/api/src/metadataNft/getNftMutableMetadataHistory.ts new file mode 100644 index 0000000000..c3d246b8d9 --- /dev/null +++ b/packages/api/src/metadataNft/getNftMutableMetadataHistory.ts @@ -0,0 +1,75 @@ +import { GetNftMutableMetadatHistory, Network, WenError } from '@build-5/interfaces'; +import { + INftOutput, + IOutputResponse, + ITransactionPayload, + IndexerPluginClient, + NFT_OUTPUT_TYPE, + SingleNodeClient, + TransactionHelper, +} from '@iota/iota.js-next'; +import Joi from 'joi'; +import { isEqual, last } from 'lodash'; +import { of } from 'rxjs'; +import { CommonJoi, getQueryParams } from '../common'; +import { EMPTY_NFT_ID, getMutableMetadata, getShimmerClient } from './wallet'; + +const getNftMutableMetadataHistorySchema = Joi.object({ + network: Joi.string().valid(Network.SMR, Network.RMS), + nftId: CommonJoi.uid(), +}); + +export const getNftMutableMetadataHistory = async (url: string) => { + const body = getQueryParams(url, getNftMutableMetadataHistorySchema); + const history: any[] = []; + try { + const client = await getShimmerClient(body.network); + const indexer = new IndexerPluginClient(client); + const outputId = (await indexer.nft(body.nftId)).items[0]; + let outputResponse: IOutputResponse | undefined = await client.output(outputId); + do { + const metadata = getMutableMetadata(outputResponse.output as INftOutput); + if (!isEqual(metadata, last(history))) { + history.push(metadata); + } + outputResponse = await getPrevNftOutput(client, outputResponse); + } while (outputResponse !== undefined); + + history.reverse(); + const response = history.reduce((acc, act, i) => ({ ...acc, [i]: act }), {}); + return of(response); + } catch { + throw { code: 400, message: WenError.invalid_nft_id.key }; + } +}; + +const getPrevNftOutput = async (client: SingleNodeClient, output: IOutputResponse) => { + if ((output.output as INftOutput).nftId === EMPTY_NFT_ID) { + return undefined; + } + const block = await client.block(output.metadata.blockId); + const inputs = (block.payload as ITransactionPayload).essence.inputs; + const prevOutputIds = inputs.map(({ transactionId, transactionOutputIndex }) => + TransactionHelper.outputIdFromTransactionData(transactionId, transactionOutputIndex), + ); + for (const prevOutputId of prevOutputIds) { + const prevOutputResponse = await client.output(prevOutputId); + const prevOutput = prevOutputResponse.output; + if (prevOutput.type !== NFT_OUTPUT_TYPE) { + continue; + } + const prevNftId = getNftId(prevOutputId, prevOutput); + if (prevNftId === (output.output as INftOutput).nftId) { + return prevOutputResponse; + } + } + + return undefined; +}; + +const getNftId = (outputId: string, output: INftOutput) => { + if (output.nftId === EMPTY_NFT_ID) { + return TransactionHelper.resolveIdFromOutputId(outputId); + } + return output.nftId; +}; diff --git a/packages/api/src/metadataNft/wallet.ts b/packages/api/src/metadataNft/wallet.ts new file mode 100644 index 0000000000..4f0c0921ba --- /dev/null +++ b/packages/api/src/metadataNft/wallet.ts @@ -0,0 +1,51 @@ +import { Network } from '@build-5/interfaces'; +import { + IMetadataFeature, + INftOutput, + METADATA_FEATURE_TYPE, + SingleNodeClient, +} from '@iota/iota.js-next'; +import { Converter } from '@iota/util.js-next'; + +const RMS_API_ENDPOINTS = ['https://rms1.svrs.io/']; + +const SMR_API_ENDPOINTS = ['https://smr1.svrs.io/', 'https://smr3.svrs.io/']; + +export const getShimmerClient = async (network = Network.SMR) => { + let url = ''; + for (let i = 0; i < 5; ++i) { + url = getEndpointUrl(network); + try { + const client = new SingleNodeClient(url); + const healty = await client.health(); + if (healty) { + return client; + } + } catch (error) { + console.warn(`Could not connect to client ${network}`, url, error); + } + await new Promise((resolve) => setTimeout(resolve, Math.floor(Math.random() * 1000 + 500))); + } + console.error(`Could not connect to client ${network}`, url); + throw Error(`Could not connect to any client ${network}`); +}; + +const getEndpointUrl = (network: Network) => { + const urls = network === Network.SMR ? SMR_API_ENDPOINTS : RMS_API_ENDPOINTS; + return getRandomElement(urls); +}; + +const getRandomElement = (array: T[]) => array[Math.floor(Math.random() * array.length)]; + +export const EMPTY_NFT_ID = '0x0000000000000000000000000000000000000000000000000000000000000000'; + +export const getMutableMetadata = (output: INftOutput) => { + const hexMetadata = ( + output?.features?.find((f) => f.type === METADATA_FEATURE_TYPE) + ); + if (!hexMetadata?.data) { + return {}; + } + const mutableMetadata = JSON.parse(Converter.hexToUtf8(hexMetadata.data) || '{}'); + return mutableMetadata; +}; diff --git a/packages/interfaces/src/api/base.ts b/packages/interfaces/src/api/base.ts index 196ef5643a..f62aae2cb0 100644 --- a/packages/interfaces/src/api/base.ts +++ b/packages/interfaces/src/api/base.ts @@ -67,6 +67,10 @@ export enum ApiRoutes { GET_ADDRESSES = '/addresses', GET_TOP_MILESTONES = '/getTopMilestones', KEEP_ALIVE = '/keepAlive', + + GET_NFT_MUTABLE_METADATA = '/getNftMutableMetadata', + GET_NFT_IDS = '/getNftIds', + GET_NFT_MUTABLE_METADATA_HISTORY = '/getNftMutableMetadataHistory', } export const PING_INTERVAL = 10000; diff --git a/packages/interfaces/src/api/request.ts b/packages/interfaces/src/api/request.ts index d999aa6559..c6ac5aab5d 100644 --- a/packages/interfaces/src/api/request.ts +++ b/packages/interfaces/src/api/request.ts @@ -229,3 +229,36 @@ export interface GetPriceChangeResponse { export interface GetTopMilestonesRequest {} export type GetTopMilestonesResponse = { [key: string]: Milestone }; + +/** + * Get mutable metadata for an nft + */ +export interface GetNftMutableData { + /** + * Network, set it only if nft was not minted on mainnet + */ + readonly network?: Network; + readonly nftId: string; +} + +/** + * Get all nft ids belonging to a collection + */ +export interface GetNftIds { + /** + * Network, set it only if nft was not minted on mainnet + */ + readonly network?: Network; + readonly collectionId: string; +} + +/** + * Get historycal mutable metadata for an nft + */ +export interface GetNftMutableMetadatHistory { + /** + * Network, set it only if nft was not minted on mainnet + */ + readonly network?: Network; + readonly nftId: string; +}