From 3254618098181460f80de10bcfd1ecd01dcc14a8 Mon Sep 17 00:00:00 2001 From: Weronika K Date: Thu, 7 Dec 2023 21:48:14 +0100 Subject: [PATCH] feat: implement NFT collections --- README.md | 2 +- src/__tests__/nft.test.ts | 49 +++++++++- src/constants.ts | 6 +- src/core/nft.ts | 197 +++++++++++++++++++++++++++++++++++++- src/core/node.ts | 3 + src/types/asset.ts | 4 +- src/types/collection.ts | 40 ++++++++ src/types/index.ts | 1 + src/types/nft.ts | 4 +- src/types/node.ts | 8 +- 10 files changed, 301 insertions(+), 13 deletions(-) create mode 100644 src/types/collection.ts diff --git a/README.md b/README.md index 64cc52cb..83a58a39 100644 --- a/README.md +++ b/README.md @@ -1333,7 +1333,7 @@ The atomic asset can be minted with the option to attach the [Universal Data Lic #### `mint(vaultId, asset, metadata, options)` - `vaultId` (`string`, required) -- `asset` ([`FileLike`][file-like], required) - asset data +- `asset` ([`FileSource`][file-source], required) - asset data - `metadata` (`NFTMetadata`, required) - NFT metadata: name, ticker, description, owner, creator, etc. - `options` (`FileUploadOptions`, optional) - ex: UDL terms - returns `Promise<{ nftId, transactionId }>` - Promise with new nft id & corresponding transaction id diff --git a/src/__tests__/nft.test.ts b/src/__tests__/nft.test.ts index bcd91542..03be0c5d 100644 --- a/src/__tests__/nft.test.ts +++ b/src/__tests__/nft.test.ts @@ -1,4 +1,4 @@ -import { Akord, NFTMetadata, StorageType, UDL } from "../index"; +import { Akord, CollectionMetadata, NFTMetadata, StorageType, UDL } from "../index"; import faker from '@faker-js/faker'; import { initInstance, testDataPath } from './common'; import { email, password } from './data/test-credentials'; @@ -63,4 +63,51 @@ describe("Testing NFT functions", () => { expect(assetUri).toBeTruthy(); expect(assetUri).toEqual(nft.asset.getUri(StorageType.ARWEAVE)); }); + + it.skip("should mint a collection", async () => { + + const { vaultId } = await akord.vault.create(faker.random.words(), { + public: true, + cacheOnly: false + }); + + const nftName = "IMG_7476.jpeg"; + const file = await NodeJs.File.fromPath(testDataPath + nftName); + + const collectionMetadata = { + name: "Flora Fantasy Test", + creator: "xxxx", + owner: "yyyy", + collection: "Flora Fantasy Test", + description: "A rare digital representation of the mythical Golden Orchid", + type: "image", + topics: ["floral", "nature"], + banner: file, + } as CollectionMetadata; + + const udl = { + licenseFee: { type: "One-Time", value: 10 } + } as UDL; + + const { data } = await akord.nft.mintCollection( + vaultId, + [{ asset: file, metadata: { name: "Golden Orchid #1" } }], + collectionMetadata, + { udl: udl } + ); + + console.log("Collection id: " + data.collectionId); + console.log("Collection object: " + data.object); + console.log("Minted NFTs: " + data.items); + }); + + it.skip("should list all nfts & collections for given vault", async () => { + + const vaultId = "SlhbTDRGVztlusw-p0adsqbRcss605OB64EczSEdSzE"; + + const nfts = await akord.nft.listAll(vaultId); + console.log(nfts); + const collections = await akord.nft.listAllCollections(vaultId); + console.log(collections); + }); }); diff --git a/src/constants.ts b/src/constants.ts index 3013a957..f4fa6033 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -17,7 +17,8 @@ export enum objectType { FOLDER = "Folder", NOTE = "Note", PROFILE = "Profile", - NFT = "NFT" + NFT = "NFT", + COLLECTION = "Collection" }; export enum status { @@ -81,7 +82,8 @@ export enum actionRefs { NOTE_RESTORE = "NOTE_RESTORE", NOTE_DELETE = "NOTE_DELETE", PROFILE_UPDATE = "PROFILE_UPDATE", - NFT_MINT = "NFT_MINT" + NFT_MINT = "NFT_MINT", + NFT_MINT_COLLECTION = "NFT_MINT_COLLECTION" }; export const AKORD_TAG = "Akord-Tag"; diff --git a/src/core/nft.ts b/src/core/nft.ts index 2fdc6eef..588a3896 100644 --- a/src/core/nft.ts +++ b/src/core/nft.ts @@ -3,10 +3,16 @@ import { FileVersion, StackCreateOptions, StorageType, nodeType } from "../types import { FileSource } from "../types/file"; import { FileGetOptions, FileService, createFileLike } from "./file"; import { NFT, NFTMetadata } from "../types/nft"; +import { Collection, CollectionMetadata } from "../types/collection"; import { actionRefs, functions, smartweaveTags } from "../constants"; import { Tag, Tags } from "../types/contract"; import { assetTags } from "../types/asset"; import { BadRequest } from "../errors/bad-request"; +import { Paginated } from "../types/paginated"; +import { ListOptions } from "../types/query-options"; +import { mergeState, paginate } from "./common"; +import { v4 as uuidv4 } from "uuid"; +import { StackService } from "./stack"; const DEFAULT_TICKER = "ATOMIC"; const DEFAULT_CONTRACT_SRC = "Of9pi--Gj7hCTawhgxOwbuWnFI1h24TTgO5pw8ENJNQ"; // Atomic asset contract source @@ -40,11 +46,10 @@ class NFTService extends NodeService { ...this.defaultCreateOptions, ...options } - const service = new NFTService(this.wallet, this.api); + const service = new NFTService(this.wallet, this.api, this); service.setVault(vault); service.setVaultId(vaultId); service.setIsPublic(vault.public); - await service.setMembershipKeys(vault); service.setActionRef(actionRefs.NFT_MINT); service.setFunction(functions.NODE_CREATE); service.setAkordTags([]); @@ -93,6 +98,184 @@ class NFTService extends NodeService { const nft = new NFT(await this.api.getNode(nftId, this.objectType)); return nft.getUri(type); } + + /** + * Mint a collection of Atomic NFTs, note that each NFT will inherit collection metadata setup + * @param {string} vaultId + * @param {{asset:FileSource,metadata:NFTMetadata,options:StackCreateOptions}[]} items + * @param {CollectionMetadata} metadata + * @param {StackCreateOptions} options + * @returns Promise with corresponding transaction id + */ + public async mintCollection( + vaultId: string, + items: { asset: FileSource, metadata?: NFTMetadata, options?: StackCreateOptions }[], + metadata: CollectionMetadata, + options: StackCreateOptions = this.defaultCreateOptions + ): Promise { + + const vault = await this.api.getVault(vaultId); + if (!vault.public || vault.cacheOnly) { + throw new BadRequest("NFT module applies only to public permanent vaults."); + } + + const mintedItems = [] as string[]; + const nfts = [] as MintCollectionResponse["data"]["items"]; + const errors = [] as MintCollectionResponse["errors"]; + + const service = new NFTService(this.wallet, this.api); + service.setVault(vault); + service.setVaultId(vaultId); + service.setIsPublic(vault.public); + service.setActionRef(actionRefs.NFT_MINT_COLLECTION); + service.setFunction(functions.NODE_CREATE); + service.setAkordTags([]); + service.setObjectType("Collection"); + + service.setGroupRef(uuidv4()); + + const collectionState = { + owner: metadata.owner, + creator: metadata.creator, + name: metadata.name, + description: metadata.description, + code: metadata.code, + udl: options.udl, + ucm: options.ucm, + } as any; + + const { nodeId: collectionId } = await service.nodeCreate(collectionState, { parentId: options.parentId }); + + for (let nft of items) { + try { + const nftService = new NFTService(this.wallet, this.api, service); + service.setObjectType("NFT"); + const { nftId, transactionId, object } = await nftService.mint( + vaultId, + nft.asset, + { ...metadata, ...nft.metadata }, + { parentId: collectionId, ...options, ...nft.options } + ); + mintedItems.push(object.getUri(StorageType.ARWEAVE)); + nfts.push({ nftId, transactionId, object }); + } catch (error) { + errors.push({ name: nft.metadata.name, message: error.message, error: error }); + } + } + + const collectionMintedState = { + type: "Collection", + items: mintedItems + } as any; + + const collectionTags = [ + { name: 'Data-Protocol', value: "Collection" }, + { name: 'Content-Type', value: "application/json" }, + { name: smartweaveTags.APP_NAME, value: 'SmartWeaveContract' }, + { name: smartweaveTags.APP_VERSION, value: '0.3.0' }, + { name: smartweaveTags.CONTRACT_SOURCE, value: metadata.contractTxId || DEFAULT_CONTRACT_SRC }, + { name: smartweaveTags.INIT_STATE, value: JSON.stringify(collectionMintedState) }, + { name: assetTags.TITLE, value: metadata.name }, + { name: 'Name', value: metadata.name }, + { name: assetTags.DESCRIPTION, value: metadata.description }, + { name: assetTags.TYPE, value: "Document" }, + { name: 'Contract-Manifest', value: '{"evaluationOptions":{"sourceType":"redstone-sequencer","allowBigInt":true,"internalWrites":true,"unsafeClient":"skip","useConstructor":true}}' }, + { name: 'Vault-Id', value: vaultId }, + ]; + + if (metadata.creator) { + collectionTags.push({ name: 'Creator', value: metadata.creator }); + } + + if (metadata.code) { + collectionTags.push({ name: 'Collection-Code', value: metadata.code }); + } + + if (metadata.banner) { + const bannerService = new StackService(this.wallet, this.api, service); + const { object: banner } = await bannerService.create( + vaultId, + metadata.banner, + (metadata.banner).name ? (metadata.banner).name : "Collection banner", + { parentId: collectionId } + ); + collectionTags.push({ name: 'Banner', value: banner.getUri(StorageType.ARWEAVE) }); + collectionMintedState.bannerUri = banner.versions[0].resourceUri; + } else { + // if not provided, set the first NFT as a collection banner + collectionTags.push({ name: 'Banner', value: nfts[0].object.asset.getUri(StorageType.ARWEAVE) }); + collectionMintedState.bannerUri = nfts[0].object.asset.resourceUri; + } + + if (metadata.thumbnail) { + const thumbnailService = new StackService(this.wallet, this.api, service); + const { object: thumbnail } = await thumbnailService.create( + vaultId, + metadata.thumbnail, + (metadata.thumbnail).name ? (metadata.thumbnail).name : "Collection thumbnail", + { parentId: collectionId } + ); + collectionTags.push({ name: 'Thumbnail', value: thumbnail.getUri(StorageType.ARWEAVE) }); + collectionMintedState.thumbnailUri = thumbnail.versions[0].resourceUri; + } + + service.setObjectType("Collection"); + service.setObjectId(collectionId); + service.setActionRef(actionRefs.NFT_MINT_COLLECTION); + service.setFunction(functions.NODE_UPDATE); + service.arweaveTags = await service.getTxTags(); + + const mergedState = mergeState(collectionState, collectionMintedState); + + const ids = await service.api.uploadData([{ data: mergedState, tags: collectionTags }]); + + const { id, object } = await service.api.postContractTransaction( + service.vaultId, + { function: service.function, data: ids[0] }, + service.arweaveTags + ); + + return { + data: { + items: nfts, + collectionId: collectionId, + transactionId: id, + object: object + }, + errors, + } + } + + /** + * @param {string} vaultId + * @param {ListOptions} options + * @returns Promise with paginated collections within given vault + */ + public async listCollections(vaultId: string, options: ListOptions = this.defaultListOptions): Promise> { + const listOptions = { + ...this.defaultListOptions, + ...options + } + const response = await this.api.getNodesByVaultId(vaultId, "Collection", listOptions); + + return { + items: response.items, + nextToken: response.nextToken, + errors: [] + } + } + + /** + * @param {string} vaultId + * @param {ListOptions} options + * @returns Promise with all collections within given vault + */ + public async listAllCollections(vaultId: string, options: ListOptions = this.defaultListOptions): Promise> { + const list = async (options: ListOptions & { vaultId: string }) => { + return await this.listCollections(options.vaultId, options); + } + return await paginate(list, { ...options, vaultId }); + } }; export const nftMetadataToTags = (metadata: NFTMetadata): Tags => { @@ -135,6 +318,16 @@ export const nftMetadataToTags = (metadata: NFTMetadata): Tags => { return nftTags; } +export interface MintCollectionResponse { + data: { + object: Collection, + collectionId: string, + transactionId: string, + items: Array<{ nftId: string, transactionId: string, object: NFT }> + } + errors: Array<{ name?: string, message: string, error: Error }> +} + export { NFTService } \ No newline at end of file diff --git a/src/core/node.ts b/src/core/node.ts index b025bad0..ed6d0db8 100644 --- a/src/core/node.ts +++ b/src/core/node.ts @@ -13,6 +13,7 @@ import { Folder } from '../types/folder'; import { Stack } from '../types/stack'; import { Memo } from '../types/memo'; import { NFT } from '../types/nft'; +import { Collection } from '../types/collection'; class NodeService extends Service { objectType: NodeType; @@ -255,6 +256,8 @@ class NodeService extends Service { return new Stack(nodeProto, keys); } else if (this.objectType === "Memo") { return new Memo(nodeProto, keys); + } else if (this.objectType === "Collection") { + return new Collection(nodeProto); } else if (this.objectType === "NFT") { return new NFT(nodeProto); } else { diff --git a/src/types/asset.ts b/src/types/asset.ts index ae3418cd..eb440c2c 100644 --- a/src/types/asset.ts +++ b/src/types/asset.ts @@ -9,8 +9,8 @@ export enum assetTags { }; export type AssetMetadata = { - type: AssetType, - name: string, // max 150 characters + type?: AssetType, + name?: string, // max 150 characters description?: string, // optional description, max 300 characters topics?: string[], } diff --git a/src/types/collection.ts b/src/types/collection.ts new file mode 100644 index 00000000..cb081635 --- /dev/null +++ b/src/types/collection.ts @@ -0,0 +1,40 @@ +import { AssetMetadata } from "./asset"; +import { Node } from "./node"; +import { UDL } from "../types"; +import { FileSource } from "./file"; + +export class Collection extends Node { + name: string; + description: string; + code: string; + creator: string; + owner: string; + bannerUri: string[]; + thumbnailUri: string[]; + udl?: UDL; + ucm?: boolean; + items: string[]; + + constructor(collectionProto: any) { + super(collectionProto, null); + this.name = collectionProto.name; + this.description = collectionProto.description; + this.code = collectionProto.code; + this.creator = collectionProto.creator; + this.owner = collectionProto.owner; + this.bannerUri = collectionProto.bannerUri; + this.thumbnailUri = collectionProto.thumbnailUri; + this.udl = collectionProto.udl; + this.ucm = collectionProto.ucm; + this.items = collectionProto.items; + } +} + +export type CollectionMetadata = { + owner: string // NFT owner address + creator?: string, // NFT creator address, if not present, default to owner + code?: string // NFT collection code + contractTxId?: string, // default to "Of9pi--Gj7hCTawhgxOwbuWnFI1h24TTgO5pw8ENJNQ" + banner?: FileSource, + thumbnail?: FileSource, +} & AssetMetadata \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index 8dd7a56f..04c8ff49 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -12,3 +12,4 @@ export * from "./file-version"; export * from "./udl"; export * from "./nft"; export * from "./asset"; +export * from "./collection"; diff --git a/src/types/nft.ts b/src/types/nft.ts index 18f76b31..08f82ca0 100644 --- a/src/types/nft.ts +++ b/src/types/nft.ts @@ -40,9 +40,9 @@ export type Claim = { } export type NFTMetadata = { - owner: string // NFT owner address + owner?: string // NFT owner address creator?: string, // NFT creator address, if not present, default to owner collection?: string // NFT collection code - contractTxId?: string, // default to "foOzRR7kX-zGzD749Lh4_SoBogVefsFfao67Rurc2Tg" + contractTxId?: string, // default to "Of9pi--Gj7hCTawhgxOwbuWnFI1h24TTgO5pw8ENJNQ" ticker?: string, // default to "ATOMIC" } & AssetMetadata \ No newline at end of file diff --git a/src/types/node.ts b/src/types/node.ts index 49f2c9f5..68f410e1 100644 --- a/src/types/node.ts +++ b/src/types/node.ts @@ -7,16 +7,18 @@ import { Note } from "./note"; import { Memo } from "./memo"; import { Tags } from "./contract"; import { NFT } from "./nft"; +import { Collection } from "./collection"; export enum nodeType { STACK = "Stack", FOLDER = "Folder", MEMO = "Memo", NOTE = "Note", - NFT = "NFT" + NFT = "NFT", + COLLECTION = "Collection" } -export type NodeType = "Stack" | "Folder" | "Memo" | "Note" | "NFT"; +export type NodeType = "Stack" | "Folder" | "Memo" | "Note" | "NFT" | "Collection"; export abstract class Node extends Encryptable { id: string; @@ -75,7 +77,7 @@ export abstract class Version extends Encryptable { } } -export type NodeLike = Folder | Stack | Note | Memo | NFT +export type NodeLike = Folder | Stack | Note | Memo | NFT | Collection export class NodeFactory { static instance(nodeLike: { new(raw: K, keys: Array): NodeLike }, data: K, keys: Array): any {