diff --git a/README.md b/README.md index 1f6e223..ce96a21 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ This SDK is currently focused on interacting with the Itheum's Data NFT technolo ## Usage in 3rd party dApps -- install this SDK via `npm i @itheum/sdk-mx-data-nft` +- Install this SDK via `npm i @itheum/sdk-mx-data-nft` - Methods supported are given below is `SDK Docs` ## SDK DOCS @@ -50,13 +50,12 @@ response.forEach(async (nft) => { dataNfts.push(data); }); -// Retrives the DataNfts owned by a address +// Retrieves the DataNfts owned by a address const address = 'address'; const dataNfts = []; dataNfts = await DataNft.ownedByAddress(address); -// Retrives the DataNft message from marshal to sign - +// Retrieves the DataNft message from marshal to sign const dataNft = DataNft.createFromApi(nonce); const message = await dataNft.messageToSign(); @@ -64,7 +63,7 @@ const message = await dataNft.messageToSign(); const signature = 'signature'; // Unlock the data inside the dataNft -dataNft.viewData(message, signature); +dataNft.viewData(message, signature); // optional params "stream" (stream out data instead of downloading file), "fwdAllHeaders"/"fwdHeaderKeys" can be used to pass headers like Authorization to origin servers ``` ### 2. Interacting with Data NFT Minter @@ -74,27 +73,62 @@ import { DataNftMinter } from '@itheum/sdk-mx-data-nft'; const dataNftMinter = new DataNftMinter('devnet' | 'testnet' | 'mainnet'); -// View minter smart contract rewquirements +// View minter smart contract requirements const requirements = await dataNftMinter.viewMinterRequirements('address'); // View contract pause state const result = await dataNftMarket.viewContractPauseState(); +``` + +#### Create a mint transaction -// Create a mint transaction +Method 1: Mint a new Data NFT with Ithuem generated image and traits. +Currently only supports [nft.storage](https://nft.storage/docs/quickstart/#get-an-api-token). + +```typescript const transaction = await dataNftMarket.mint( - new Address('erd1'), - 'TEST-TOKEN', - 'https://marshal.com', - 'https://streamdata.com', - 'https://previewdata', - 15, - 1000, - 'Test Title', - 'Test Description', - 10 +new Address('erd1'), +'TEST-TOKEN', +'https://marshal.com', +'https://streamdata.com', +'https://previewdata', +15, +1000, +'Test Title', +'Test Description', +10000000000, +options: { +nftStorageToken:"API TOKEN", +} +); +``` + +Method 2: Mint a new Data NFT with custom image and traits. +Traits should be compliant with the Itheum [traits structure](#traits-structure). + +```typescript +const transaction = await dataNftMarket.mint( +new Address('erd1'), +'TEST-TOKEN', +'https://marshal.com', +'https://streamdata.com', +'https://previewdata', +15, +1000, +'Test Title', +'Test Description', +10000000000, +options: { +imageUrl:"https://imageurl.com", +traitsUrl:"https://traitsurl.com", +} + ); +``` -// Create a burn transaction +#### Create a burn transaction + +```typescript const transaction = await dataNftMarket.burn( new Address('erd1'), dataNftNonce, @@ -161,3 +195,26 @@ const result = dataNftMarket.withdrawCancelledOffer(new Address(''), 0); // Create changeOfferPrice transaction const result = dataNftMarket.changeOfferPrice(new Address(''), 0, 0); ``` + +### Traits structure + +```json +{ + "description": "Data NFT description", //required + "attributes": [ + { + "trait_type": "Creator", //required + "value": "creator address" + }, + { + "trait_type": "Data Preview URL", //required + "value": "https://previewdata.com" + }, + { + "trait_type": "extra trait", + "value": "extra trait value" + }, + ... + ] +} +``` diff --git a/package-lock.json b/package-lock.json index 47466e8..8c27e08 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,17 @@ { "name": "@itheum/sdk-mx-data-nft", - "version": "0.0.7", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@itheum/sdk-mx-data-nft", - "version": "0.0.7", + "version": "0.1.0", "license": "GPL-3.0-only", "dependencies": { "@multiversx/sdk-core": "12.6.0", "@multiversx/sdk-network-providers": "1.5.0", "bignumber.js": "^9.1.1", - "dotenv": "^16.0.3", "nft.storage": "^7.0.3" }, "devDependencies": { @@ -2073,14 +2072,6 @@ "receptacle": "^1.3.2" } }, - "node_modules/dotenv": { - "version": "16.0.3", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.0.3.tgz", - "integrity": "sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==", - "engines": { - "node": ">=12" - } - }, "node_modules/electron-fetch": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/electron-fetch/-/electron-fetch-1.9.1.tgz", diff --git a/package.json b/package.json index 6112ff9..a43d42f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@itheum/sdk-mx-data-nft", - "version": "0.0.8", + "version": "0.1.0", "description": "SDK for Itheum's Data NFT Technology on MultiversX Blockchain", "main": "out/index.js", "types": "out/index.d.js", @@ -19,7 +19,6 @@ "@multiversx/sdk-core": "12.6.0", "@multiversx/sdk-network-providers": "1.5.0", "bignumber.js": "^9.1.1", - "dotenv": "^16.0.3", "nft.storage": "^7.0.3" }, "devDependencies": { diff --git a/src/config.ts b/src/config.ts index b4353a4..6536810 100644 --- a/src/config.ts +++ b/src/config.ts @@ -60,3 +60,9 @@ export const networkConfiguration: { [key in EnvironmentsEnum]: Config } = { mainnet: mainnetNetworkConfig, testnet: testnetNetworkConfig }; + +export const imageService: { [key in EnvironmentsEnum]: string } = { + devnet: 'https://api.itheumcloud-stg.com/datadexapi', + mainnet: 'https://api.itheumcloud.com/datadexapi', + testnet: '' +}; diff --git a/src/datanft.ts b/src/datanft.ts index 643307c..67e46a2 100644 --- a/src/datanft.ts +++ b/src/datanft.ts @@ -107,29 +107,25 @@ export class DataNft { } /** - * Creates a DataNft from a API response containing the NFT details. + * Creates a DataNft or an array of DataNft from either a single NFT details API response or an array of NFT details API response. * - * Useful for creating an array of DataNft. - * @param payload NFT details API response + * @param payload NFT details API response, can be a single item or an array of items */ - static createFromApiResponse(payload: NftType): DataNft { - const dataNft = parseDataNft(payload); - - return dataNft; - } - - /** - * Creates an array of DataNft from an array of NFT details API response. - * - * @param payload NFT details API response - */ - static createFromApiResponseBulk(payload: NftType[]): DataNft[] { + static createFromApiResponseOrBulk(payload: NftType | NftType[]): DataNft[] { const dataNfts: DataNft[] = []; - payload.forEach((nft: NftType) => { - dataNfts.push(this.createFromApiResponse(nft)); - }); - return dataNfts; + const parseNft = (nft: NftType) => { + const dataNft = parseDataNft(nft); + dataNfts.push(dataNft); + }; + + if (Array.isArray(payload)) { + payload.forEach(parseNft); + return dataNfts; + } else { + parseNft(payload as NftType); + return dataNfts; + } } /** @@ -175,10 +171,8 @@ export class DataNft { `${this.apiConfiguration}/accounts/${address}/nfts?size=10000&collections=${identifier}&withSupply=true` ); const data = await res.json(); - const dataNfts: DataNft[] = []; - data.forEach((nft: NftType) => { - dataNfts.push(this.createFromApiResponse(nft)); - }); + const dataNfts: DataNft[] = this.createFromApiResponseOrBulk(data); + return dataNfts; } diff --git a/src/marketplace.ts b/src/marketplace.ts index 3349e3f..3b4c594 100644 --- a/src/marketplace.ts +++ b/src/marketplace.ts @@ -34,7 +34,7 @@ export class DataNftMarket { readonly env: string; /** - * Creates a new instance of the DataNftMarket which can be used to interact with the DataNFT-FTs inside the marketplace + * Creates a new instance of the DataNftMarket which can be used to interact with the marketplace smart contract * @param env 'devnet' | 'mainnet' | 'testnet' * @param timeout Timeout for the network provider (DEFAULT = 10000ms) */ diff --git a/src/minter.ts b/src/minter.ts index c25f26d..67784a7 100644 --- a/src/minter.ts +++ b/src/minter.ts @@ -14,10 +14,14 @@ import { StringValue, BooleanValue } from '@multiversx/sdk-core/out'; -import { ApiNetworkProvider } from '@multiversx/sdk-network-providers/out'; +import { + ApiNetworkProvider, + TransactionStatus +} from '@multiversx/sdk-network-providers/out'; import { EnvironmentsEnum, dataNftTokenIdentifier, + imageService, itheumTokenIdentifier, minterContractAddress, networkConfiguration @@ -26,21 +30,24 @@ import dataNftMintAbi from './abis/datanftmint.abi.json'; import { MinterRequirements } from './interfaces'; import { NFTStorage } from 'nft.storage'; import { File } from '@web-std/file'; +import { checkTraitsUrl } from './utils'; export class DataNftMinter { readonly contract: SmartContract; readonly chainID: string; readonly networkProvider: ApiNetworkProvider; readonly env: string; + readonly imageServiceUrl: string; /** - * Creates a new instance of the `DataNftMinter` which can be used to interact with the DataNFT-FTs inside the marketplace - * @param env 'DEVNET' | 'MAINNET' + * Creates a new instance of the `DataNftMinter` which can be used to interact with the Data NFT-FT minter smart contract + * @param env 'devnet' | 'mainnet' | 'testnet' * @param timeout Timeout for the network provider (DEFAULT = 10000ms) */ constructor(env: string, timeout: number = 10000) { this.env = env; const networkConfig = networkConfiguration[env as EnvironmentsEnum]; + this.imageServiceUrl = imageService[env as EnvironmentsEnum]; this.chainID = networkConfig.chainID; this.networkProvider = new ApiNetworkProvider( networkConfig.networkProvider, @@ -161,6 +168,10 @@ export class DataNftMinter { * * NOTE: The `dataStreamUrl` is being encrypted and the `media` and `metadata` urls are build and uploaded to IPFS * + * NOTE: The `options.nftStorageToken` is required when not using custom image and traits, when using custom image and traits the traits should be compliant with the `traits` structure + * + * For more information, see the [README documentation](https://github.com/Itheum/sdk-mx-data-nft#create-a-mint-transaction). + * * @param senderAddress the address of the user * @param tokenName the name of the DataNFT-FT * @param dataMarshalUrl the url of the data marshal @@ -170,11 +181,13 @@ export class DataNftMinter { * @param supply the supply of the Data NFT-FT * @param datasetTitle the title of the dataset * @param datasetDescription the description of the dataset - * @param antiSpamTax the anti spam tax to be set for the Data NFT-FT - * @param options optional parameters - * - imageUrl: the URL of the image for the Data NFT (optional, should be stored on `IPFS`) - * - imageDescription: a description for the image (optional, should be passed if the `imageUrl` is passed) + * @param antiSpamTax the anti spam tax to be set for the Data NFT-FT with decimals + * @param options optional parameters + * - imageUrl: the URL of the image for the Data NFT + * - traitsUrl: the URL of the traits for the Data NFT + * - nftStorageToken: the nft storage token to be used to upload the image and metadata to IPFS * - antiSpamTokenIdentifier: the anti spam token identifier to be used for the minting (default = `ITHEUM` token identifier based on the {@link EnvironmentsEnum}) + * */ async mint( senderAddress: IAddress, @@ -189,46 +202,57 @@ export class DataNftMinter { antiSpamTax: number, options?: { imageUrl?: string; - imageDescription?: string; + traitsUrl?: string; + nftStorageToken?: string; antiSpamTokenIdentifier?: string; } ): Promise { const { - imageUrl = '', - imageDescription = '', + imageUrl, + traitsUrl, + nftStorageToken, antiSpamTokenIdentifier = itheumTokenIdentifier[ this.env as EnvironmentsEnum ] } = options ?? {}; - if (imageUrl && !imageUrl.startsWith('https://ipfs.io/ipfs')) { - throw new Error('Invalid image url'); - } - - if (!imageDescription && imageUrl) { - throw new Error('Invalid image description'); - } + let imageOnIpfsUrl: string; + let metadataOnIpfsUrl: string; const { dataNftHash, dataNftStreamUrlEncrypted } = - await this.dataNFTDataStreamAdvertise(dataStreamUrl); + await this.dataNFTDataStreamAdvertise(dataStreamUrl, dataMarshalUrl); - const { image, traits } = await this.createFileFromUrl( - imageUrl || - `${process.env.REACT_APP_ENV_DATADEX_API}/v1/generateNFTArt?hash=${dataNftHash}`, - datasetTitle, - datasetDescription, - dataPreviewUrl, - senderAddress.bech32(), - imageDescription!, - Boolean(imageUrl) - ); + if (!imageUrl) { + if (!nftStorageToken) { + throw new Error( + 'NFT Storage token is required when not using custom image and traits' + ); + } + const { image, traits } = await this.createFileFromUrl( + `${this.imageServiceUrl}/v1/generateNFTArt?hash=${dataNftHash}`, + datasetTitle, + datasetDescription, + dataPreviewUrl, + senderAddress.bech32() + ); - let { imageOnIpfsUrl, metadataOnIpfsUrl } = await this.storeToIpfs( - traits, - imageUrl ? undefined : image - ); + const { + imageOnIpfsUrl: imageIpfsUrl, + metadataOnIpfsUrl: metadataIpfsUrl + } = await this.storeToIpfs(nftStorageToken, traits, image); - imageOnIpfsUrl = imageUrl ? imageUrl : imageOnIpfsUrl; + imageOnIpfsUrl = imageIpfsUrl; + metadataOnIpfsUrl = metadataIpfsUrl; + } else { + if (!traitsUrl) { + throw new Error('Traits URL is required when using custom image'); + } + + await checkTraitsUrl(traitsUrl); + + imageOnIpfsUrl = imageUrl; + metadataOnIpfsUrl = traitsUrl; + } let data; if (antiSpamTax > 0) { @@ -276,7 +300,8 @@ export class DataNftMinter { } private async dataNFTDataStreamAdvertise( - dataNFTStreamUrl: string + dataNFTStreamUrl: string, + dataMarshalUrl: string ): Promise<{ dataNftHash: string; dataNftStreamUrlEncrypted: string }> { /* 1) Call the data marshal and get a encrypted data stream url and hash of url (s1) @@ -296,10 +321,7 @@ export class DataNftMinter { }; try { - const res = await fetch( - `${process.env.REACT_APP_ENV_DATAMARSHAL_API}/generate`, - requestOptions - ); + const res = await fetch(`${dataMarshalUrl}/generate`, requestOptions); const data = await res.json(); if (data && data.encryptedMessage && data.messageHash) { @@ -316,36 +338,24 @@ export class DataNftMinter { } private async storeToIpfs( + storageToken: string, traits: File, - image?: File + image: File ): Promise<{ imageOnIpfsUrl: string; metadataOnIpfsUrl: string }> { let res; try { const nftstorage = new NFTStorage({ - token: process.env.REACT_APP_ENV_NFT_STORAGE_KEY || '' + token: storageToken }); - res = await nftstorage.storeDirectory(image ? [image, traits] : [traits]); + const dir = [image, traits]; + res = await nftstorage.storeDirectory(dir); } catch { throw new Error('Error while uploading to IPFS'); } - - if (!res) { - return { - imageOnIpfsUrl: '', - metadataOnIpfsUrl: '' - }; - } - if (image) { - return { - imageOnIpfsUrl: `https://ipfs.io/ipfs/${res}/image.png`, - metadataOnIpfsUrl: `https://ipfs.io/ipfs/${res}/metadata.json` - }; - } else { - return { - imageOnIpfsUrl: '', - metadataOnIpfsUrl: `https://ipfs.io/ipfs/${res}/metadata.json` - }; - } + return { + imageOnIpfsUrl: `https://ipfs.io/ipfs/${res}/image.png`, + metadataOnIpfsUrl: `https://ipfs.io/ipfs/${res}/metadata.json` + }; } private createIpfsMetadata( @@ -353,9 +363,7 @@ export class DataNftMinter { datasetTitle: string, datasetDescription: string, dataNFTStreamPreviewUrl: string, - address: string, - imageDescription: string, - hasCustomImage = false + address: string ) { const metadata = { description: `${datasetTitle} : ${datasetDescription}`, @@ -374,12 +382,6 @@ export class DataNftMinter { trait_type: 'Data Preview URL', value: dataNFTStreamPreviewUrl }); - if (hasCustomImage) { - metadataAttributes.push({ - trait_type: 'Image Description', - value: imageDescription - }); - } metadataAttributes.push({ trait_type: 'Creator', value: address }); metadata.attributes = metadataAttributes; return metadata; @@ -390,9 +392,7 @@ export class DataNftMinter { datasetTitle: string, datasetDescription: string, dataNFTStreamPreviewUrl: string, - address: string, - imageDescription: string, - hasCustomImage = false + address: string ) { let res: any = ''; let data: any = ''; @@ -407,9 +407,7 @@ export class DataNftMinter { datasetTitle, datasetDescription, dataNFTStreamPreviewUrl, - address, - imageDescription, - hasCustomImage + address ); const _traitsFile = new File([JSON.stringify(traits)], 'metadata.json', { type: 'application/json' diff --git a/src/utils.ts b/src/utils.ts index 7a1f5d4..07da5e5 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,6 @@ -import BigNumber from "bignumber.js"; -import { DataNft } from "./datanft"; -import { NftType, Offer } from "./interfaces"; +import BigNumber from 'bignumber.js'; +import { DataNft } from './datanft'; +import { NftType, Offer } from './interfaces'; export function numberToPaddedHex(value: BigNumber.Value) { let hex = new BigNumber(value).toString(16); @@ -12,18 +12,18 @@ export function createNftIdentifier(collection: string, nonce: number) { } export function isPaddedHex(input: string) { - input = input || ""; - let decodedThenEncoded = Buffer.from(input, "hex").toString("hex"); + input = input || ''; + let decodedThenEncoded = Buffer.from(input, 'hex').toString('hex'); return input.toUpperCase() == decodedThenEncoded.toUpperCase(); } export function zeroPadStringIfOddLength(input: string): string { - input = input || ""; - + input = input || ''; + if (input.length % 2 == 1) { - return "0" + input; + return '0' + input; } - + return input; } @@ -37,7 +37,7 @@ export function parseOffer(value: any): Offer { wantedTokenIdentifier: value.wanted_token_identifier.toString(), wantedTokenNonce: value.wanted_token_nonce.toString(), wantedTokenAmount: value.wanted_token_amount.toFixed(0), - quantity: value.quantity.toNumber(), + quantity: value.quantity.toNumber() }; } @@ -51,6 +51,38 @@ export function parseDataNft(value: NftType): DataNft { nonce: value.nonce, collection: value.collection, balance: value.balance ? Number(value.balance) : 0, - ...DataNft.decodeAttributes(value.attributes), + ...DataNft.decodeAttributes(value.attributes) }); } + +export async function checkTraitsUrl(traitsUrl: string) { + const traitsResponse = await fetch(traitsUrl); + const traits = await traitsResponse.json(); + + if (!traits.description) { + throw new Error('Traits description is empty'); + } + + if (!Array.isArray(traits.attributes)) { + throw new Error('Traits attributes must be an array'); + } + + const requiredTraits = ['Creator', 'Data Preview URL']; + const traitsAttributes = traits.attributes; + + for (const requiredTrait of requiredTraits) { + if ( + !traitsAttributes.some( + (attribute: any) => attribute.trait_type === requiredTrait + ) + ) { + throw new Error(`Missing required trait: ${requiredTrait}`); + } + } + + for (const attribute of traitsAttributes) { + if (!attribute.value) { + throw new Error(`Empty value for trait: ${attribute.trait_type}`); + } + } +} diff --git a/tests/minter.test.ts b/tests/minter.test.ts index 7961de8..198b353 100644 --- a/tests/minter.test.ts +++ b/tests/minter.test.ts @@ -1,7 +1,5 @@ import { Address, Transaction } from '@multiversx/sdk-core/out'; import { DataNftMinter, MinterRequirements } from '../src'; -import dotenv from 'dotenv'; -dotenv.config(); describe('Data Nft Minter Test', () => { test('#getAddress', async () => { @@ -42,31 +40,4 @@ describe('Data Nft Minter Test', () => { const result = await dataNftMarket.viewContractPauseState(); expect(typeof result).toBe('boolean'); }); - - test('mint throws an error when an invalid image URL is provided', async () => { - const dataNftMarket = new DataNftMinter('devnet'); - - const invalidImageUrl = 'invalid_url'; - - const mintPromise = dataNftMarket.mint( - new Address( - 'erd10uavg8hd92620mfll2lt4jdmrg6xlf60awjp9ze5gthqjjhactvswfwuv8' - ), - 'TEST-TOKEN', - 'https://marshal.com', - 'https://streamdata.com', - 'https://previewdata', - 15, - 1000, - 'Test Title', - 'Test Description', - 10, - { - imageUrl: invalidImageUrl, - imageDescription: 'Test Image Description' - } - ); - - await expect(mintPromise).rejects.toThrowError('Invalid image url'); - }, 100000); }); diff --git a/tests/traits-check.test.ts b/tests/traits-check.test.ts new file mode 100644 index 0000000..694db84 --- /dev/null +++ b/tests/traits-check.test.ts @@ -0,0 +1,22 @@ +import { checkTraitsUrl } from '../src'; + +describe('Traits strucutre test', () => { + test('#json traits strucutre check', async () => { + try { + await checkTraitsUrl( + 'https://ipfs.io/ipfs/bafybeih7bvpcfj42nawm7g4bkbu25cqxbhlzth5sxm6qjwis3tke23p7ty/metadata.json' + ); + expect(true).toBe(true); + } catch (error) {} + }, 100000); + + test('#json traits strucutre check', async () => { + try { + await checkTraitsUrl( + 'https://ipfs.io/ipfs/bafybeicbmpiehja5rjk425ol4rmrorrg5xh62vcbeqigv3zjcrfk4rtggm/metadata.json' + ); + } catch (error) { + expect(error).toStrictEqual(Error('Missing required trait: Creator')); + } + }, 100000); +});