From 025e79fbf34dc1c6cbc869d969bad3dade548cab Mon Sep 17 00:00:00 2001 From: Mark Paul Date: Thu, 12 Sep 2024 20:25:17 +1000 Subject: [PATCH] feature: Intial test build on Solana cNFT minting option --- package.json | 4 +- src/cnft-sol-minter.ts | 225 +++++++++++++++++++++++++++++++++++++++ src/common/mint-utils.ts | 104 ++++++++++++++++++ src/config.ts | 6 ++ src/contract-sol.ts | 19 ++++ src/index.ts | 3 + src/minter-sol.ts | 14 +++ 7 files changed, 373 insertions(+), 2 deletions(-) create mode 100644 src/cnft-sol-minter.ts create mode 100644 src/contract-sol.ts create mode 100644 src/minter-sol.ts diff --git a/package.json b/package.json index 08466bc..4965b6b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@itheum/sdk-mx-data-nft", - "version": "3.6.1", - "description": "SDK for Itheum's Data NFT Technology on MultiversX Blockchain", + "version": "3.7.0-alpha.1", + "description": "SDK for Itheum's Data NFT Technology on MultiversX and Solana", "main": "out/index.js", "types": "out/index.d.js", "files": [ diff --git a/src/cnft-sol-minter.ts b/src/cnft-sol-minter.ts new file mode 100644 index 0000000..8adde96 --- /dev/null +++ b/src/cnft-sol-minter.ts @@ -0,0 +1,225 @@ +import { + dataNFTDataStreamAdvertise, + storeToIpfsOnlyImg, + createIpfsMetadataSolCNft, + storeToIpfsFullSolCNftMetadata +} from './common/mint-utils'; +import { checkTraitsUrl, checkUrlIsUp } from './common/utils'; +import { ErrArgumentNotSet } from './errors'; +import { MinterSol } from './minter-sol'; +import { StringValidator, validateResults } from './common/validator'; + +export class CNftSolMinter extends MinterSol { + /** + * Creates a new instance of the `SftMinter` class, which can be used to interact with the Data NFT-FT minter smart contract + * @param env 'devnet' | 'mainnet' | 'testnet' + */ + constructor(env: string) { + super(env); + } + + /** + * Creates a `mint` transaction + * + * 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 creatorAddress the address of the creator who we mint a CNft for + * @param tokenName the name of the DataNFT-FT. Between 3 and 20 alphanumeric characters, no spaces. + * @param dataMarshalUrl the url of the data marshal. A live HTTPS URL that returns a 200 OK HTTP code. + * @param dataStreamUrl the url of the data stream to be encrypted. A live HTTPS URL that returns a 200 OK HTTP code. + * @param dataPreviewUrl the url of the data preview. A live HTTPS URL that returns a 200 OK HTTP code. + * @param datasetTitle the title of the dataset. Between 10 and 60 alphanumeric characters. + * @param datasetDescription the description of the dataset. Between 10 and 400 alphanumeric characters. + * @param options [optional] below parameters are optional or required based on use case + * - 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 + * - extraAssets: [optional] extra URIs to attached to the NFT. Can be media files, documents, etc. These URIs are public + * - imgGenBg: [optional] the custom series bg to influence the image generation service + * - imgGenSet: [optional] the custom series layer set to influence the image generation service + * + */ + async mint( + creatorAddress: string, + tokenName: string, + dataMarshalUrl: string, + dataStreamUrl: string, + dataPreviewUrl: string, + datasetTitle: string, + datasetDescription: string, + options?: { + imageUrl?: string; + traitsUrl?: string; + nftStorageToken?: string; + extraAssets?: string[]; + imgGenBg?: string; + imgGenSet?: string; + } + ): Promise<{ imageUrl: string; metadataUrl: string; mintMeta: object }> { + const { + imageUrl, + traitsUrl, + nftStorageToken, + extraAssets, + imgGenBg, + imgGenSet + } = options ?? {}; + + const tokenNameValidator = new StringValidator() + .notEmpty() + .alphanumeric() + .minLength(3) + .maxLength(20) + .validate(tokenName); + + const datasetTitleValidator = new StringValidator() + .notEmpty() + .minLength(10) + .maxLength(60) + .validate(datasetTitle.trim()); + + const datasetDescriptionValidator = new StringValidator() + .notEmpty() + .minLength(10) + .maxLength(400) + .validate(datasetDescription); + + validateResults([ + tokenNameValidator, + datasetTitleValidator, + datasetDescriptionValidator + ]); + + // deep validate all mandatory URLs + try { + await checkUrlIsUp(dataPreviewUrl, [200]); + await checkUrlIsUp(dataMarshalUrl + '/health-check', [200]); + } catch (error) { + throw error; + } + + let imageOnIpfsUrl: string; + let metadataOnIpfsUrl: string; + + const { dataNftHash, dataNftStreamUrlEncrypted } = + await dataNFTDataStreamAdvertise( + dataStreamUrl, + dataMarshalUrl, + creatorAddress // the caller is the Creator + ); + + if (!imageUrl) { + if (!nftStorageToken) { + throw new ErrArgumentNotSet( + 'nftStorageToken', + 'NFT Storage token is required when not using custom image and traits' + ); + } + + // create the img generative service API based on user options + let imgGenServiceApi = `${this.imageServiceUrl}/v1/generateNFTArt?hash=${dataNftHash}`; + + if (imgGenBg && imgGenBg.trim() !== '') { + imgGenServiceApi += `&bg=${imgGenBg.trim()}`; + } + + if (imgGenSet && imgGenSet.trim() !== '') { + imgGenServiceApi += `&set=${imgGenSet.trim()}`; + } + + let resImgCall: any = ''; + let dataImgCall: any = ''; + let _imageFile: Blob = new Blob(); + + resImgCall = await fetch(imgGenServiceApi); + dataImgCall = await resImgCall.blob(); + _imageFile = dataImgCall; + + const traitsFromImgHeader = resImgCall.headers.get('x-nft-traits') || ''; + + const { imageOnIpfsUrl: imgOnIpfsUrl } = await storeToIpfsOnlyImg( + nftStorageToken, + _imageFile + ); + + const cNftMetadataContent = createIpfsMetadataSolCNft( + tokenName, + datasetTitle, + datasetDescription, + imgOnIpfsUrl, + creatorAddress, + dataNftStreamUrlEncrypted, + dataPreviewUrl, + dataMarshalUrl, + traitsFromImgHeader, + extraAssets ?? [] + ); + + const { metadataIpfsUrl } = await storeToIpfsFullSolCNftMetadata( + nftStorageToken, + cNftMetadataContent + ); + + imageOnIpfsUrl = imgOnIpfsUrl; + metadataOnIpfsUrl = metadataIpfsUrl; + } else { + if (!traitsUrl) { + throw new ErrArgumentNotSet( + 'traitsUrl', + 'Traits URL is required when using custom image' + ); + } + + await checkTraitsUrl(traitsUrl); + + imageOnIpfsUrl = imageUrl; + metadataOnIpfsUrl = traitsUrl; + } + + // we not make a call to our private cNFt minter API + let mintMeta: any = {}; + + try { + const postHeaders = new Headers(); + postHeaders.append('Content-Type', 'application/json'); + + const raw = JSON.stringify({ + metadataOnIpfsUrl, + tokenName, + mintForSolAddr: creatorAddress, + solSignature: 'solSignature', + signatureNonce: 'signatureNonce' + }); + + const requestOptions = { + method: 'POST', + headers: postHeaders, + body: raw + }; + + let resMintCall: any = ''; + let dataMintCall: any = ''; + + resMintCall = await fetch(this.solCNftMinterServiceUrl, requestOptions); + dataMintCall = await resMintCall.text(); + mintMeta = dataMintCall; + + // .then((response) => response.text()) + // .then((result) => console.log(result)) + // .catch((error) => console.error(error)); + } catch (e: any) { + mintMeta = { error: true, errMsg: e.toString() }; + throw e; + } + + return { + imageUrl: imageOnIpfsUrl, + metadataUrl: metadataOnIpfsUrl, + mintMeta + }; + } +} diff --git a/src/common/mint-utils.ts b/src/common/mint-utils.ts index caadeff..709723c 100644 --- a/src/common/mint-utils.ts +++ b/src/common/mint-utils.ts @@ -52,6 +52,37 @@ export async function storeToIpfs( } } +export async function storeToIpfsFullSolCNftMetadata( + storageToken: string, + metadataStructureSolCNft: object +): Promise<{ metadataIpfsUrl: string }> { + try { + const metadataIpfsHash = await storeTraitsToIpfs( + metadataStructureSolCNft, + storageToken + ); + return { + metadataIpfsUrl: `https://ipfs.io/ipfs/${metadataIpfsHash}` + }; + } catch (error) { + throw error; + } +} + +export async function storeToIpfsOnlyImg( + storageToken: string, + image: Blob +): Promise<{ imageOnIpfsUrl: string }> { + try { + const imageHash = await storeImageToIpfs(image, storageToken); + return { + imageOnIpfsUrl: `https://ipfs.io/ipfs/${imageHash}` + }; + } catch (error) { + throw error; + } +} + async function storeImageToIpfs(image: Blob, storageToken: string) { const form = new FormData(); form.append('file', image); @@ -127,6 +158,49 @@ export function createIpfsMetadata( return metadata; } +export function createIpfsMetadataSolCNft( + tokenName: string, + datasetTitle: string, + datasetDescription: string, + imageOnIpfsUrl: string, + creatorAddress: string, + dataNFTStreamUrl: string, + dataNFTStreamPreviewUrl: string, + dataNFTDataMarshalUrl: string, + traits: string, + extraAssets: string[] +) { + const metadata: Record = { + name: tokenName, + description: `${datasetTitle} : ${datasetDescription}`, + image: imageOnIpfsUrl, + itheum_creator: creatorAddress, + itheum_data_stream_url: dataNFTStreamUrl, + itheum_data_preview_url: dataNFTStreamPreviewUrl, + itheum_data_marshal_url: dataNFTDataMarshalUrl, + attributes: [] as object[] + }; + + if (extraAssets && extraAssets.length > 0) { + metadata.extra_assets = extraAssets; + } + + const attributes = traits + .split(',') + .filter((element) => element.trim() !== ''); + + const metadataAttributes = []; + + for (const attribute of attributes) { + const [key, value] = attribute.split(':'); + const trait = { trait_type: key.trim(), value: value.trim() }; + metadataAttributes.push(trait); + } + + metadata.attributes = metadataAttributes; + return metadata; +} + export async function createFileFromUrl( url: string, datasetTitle: string, @@ -154,3 +228,33 @@ export async function createFileFromUrl( const _traitsFile = traits; return { image: _imageFile, traits: _traitsFile }; } + +export async function createFileFromUrlSolCNft( + url: string, + datasetTitle: string, + datasetDescription: string, + dataNFTStreamPreviewUrl: string, + address: string, + extraAssets: string[] +) { + let res: any = ''; + let data: any = ''; + let _imageFile: Blob = new Blob(); + + if (url) { + res = await fetch(url); + data = await res.blob(); + _imageFile = data; + } + + const traits = createIpfsMetadata( + res.headers.get('x-nft-traits') || '', + datasetTitle, + datasetDescription, + dataNFTStreamPreviewUrl, + address, + extraAssets + ); + const _traitsFile = traits; + return { image: _imageFile, traits: _traitsFile }; +} diff --git a/src/config.ts b/src/config.ts index d55a468..91ec1a0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -81,6 +81,12 @@ export const imageService: { [key in EnvironmentsEnum]: string } = { testnet: '' }; +export const solCNftMinterService: { [key in EnvironmentsEnum]: string } = { + devnet: 'https://api.itheumcloud-stg.com/datadexapi/solNftUtils/mintNft', + mainnet: 'https://api.itheumcloud.com/datadexapi/solNftUtils/mintNft', + testnet: '' +}; + export const marshalUrls = { devnet: 'https://api.itheumcloud-stg.com/datamarshalapi/router/v1', mainnet: 'https://api.itheumcloud.com/datamarshalapi/router/v1', diff --git a/src/contract-sol.ts b/src/contract-sol.ts new file mode 100644 index 0000000..9b6aecf --- /dev/null +++ b/src/contract-sol.ts @@ -0,0 +1,19 @@ +import { EnvironmentsEnum, networkConfiguration } from './config'; +import { ErrNetworkConfig } from './errors'; + +export abstract class ContractSol { + readonly chainID: string; + readonly env: string; + + protected constructor(env: string) { + if (!(env in EnvironmentsEnum)) { + throw new ErrNetworkConfig( + `Invalid environment: ${env}, Expected: 'devnet' | 'mainnet' | 'testnet'` + ); + } + + this.env = env; + const networkConfig = networkConfiguration[env as EnvironmentsEnum]; + this.chainID = networkConfig.chainID; + } +} diff --git a/src/index.ts b/src/index.ts index eb81c43..e821819 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,9 +3,12 @@ export * from './datanft'; export * from './interfaces'; export * from './marketplace'; export * from './minter'; +export * from './minter-sol'; export * from './nft-minter'; export * from './sft-minter'; +export * from './cnft-sol-minter'; export * from './bond'; export * from './contract'; +export * from './contract-sol'; export * from './liveliness-stake'; export { parseTokenIdentifier, createTokenIdentifier } from './common/utils'; diff --git a/src/minter-sol.ts b/src/minter-sol.ts new file mode 100644 index 0000000..cf8cc61 --- /dev/null +++ b/src/minter-sol.ts @@ -0,0 +1,14 @@ +import { EnvironmentsEnum, imageService, solCNftMinterService } from './config'; +import { ContractSol } from './contract-sol'; + +export abstract class MinterSol extends ContractSol { + readonly imageServiceUrl: string; + readonly solCNftMinterServiceUrl: string; + + protected constructor(env: string) { + super(env); + this.imageServiceUrl = imageService[env as EnvironmentsEnum]; + this.solCNftMinterServiceUrl = + solCNftMinterService[env as EnvironmentsEnum]; + } +}