Skip to content

Commit

Permalink
feat: implement NFT collections
Browse files Browse the repository at this point in the history
  • Loading branch information
wkolod committed Dec 7, 2023
1 parent a10dd35 commit 3254618
Show file tree
Hide file tree
Showing 10 changed files with 301 additions and 13 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 48 additions & 1 deletion src/__tests__/nft.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
});
});
6 changes: 4 additions & 2 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ export enum objectType {
FOLDER = "Folder",
NOTE = "Note",
PROFILE = "Profile",
NFT = "NFT"
NFT = "NFT",
COLLECTION = "Collection"
};

export enum status {
Expand Down Expand Up @@ -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";
Expand Down
197 changes: 195 additions & 2 deletions src/core/nft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -40,11 +46,10 @@ class NFTService extends NodeService<NFT> {
...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([]);
Expand Down Expand Up @@ -93,6 +98,184 @@ class NFTService extends NodeService<NFT> {
const nft = new NFT(await this.api.getNode<NFT>(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<MintCollectionResponse> {

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<Collection>(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,
(<any>metadata.banner).name ? (<any>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,
(<any>metadata.thumbnail).name ? (<any>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<Collection>(
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<Paginated<Collection>> {
const listOptions = {
...this.defaultListOptions,
...options
}
const response = await this.api.getNodesByVaultId<Collection>(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<Array<Collection>> {
const list = async (options: ListOptions & { vaultId: string }) => {
return await this.listCollections(options.vaultId, options);
}
return await paginate<Collection>(list, { ...options, vaultId });
}
};

export const nftMetadataToTags = (metadata: NFTMetadata): Tags => {
Expand Down Expand Up @@ -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
}
3 changes: 3 additions & 0 deletions src/core/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> extends Service {
objectType: NodeType;
Expand Down Expand Up @@ -255,6 +256,8 @@ class NodeService<T> 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 {
Expand Down
4 changes: 2 additions & 2 deletions src/types/asset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[],
}
Expand Down
40 changes: 40 additions & 0 deletions src/types/collection.ts
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ export * from "./file-version";
export * from "./udl";
export * from "./nft";
export * from "./asset";
export * from "./collection";
Loading

0 comments on commit 3254618

Please sign in to comment.