@silvana-one/nft
Version:
Mina NFT library
829 lines (809 loc) • 24.4 kB
text/typescript
import { Field, Poseidon, Struct, Experimental, PublicKey, UInt64 } from "o1js";
import { fieldFromString } from "../interfaces/index.js";
import { Text } from "./text.js";
import { MinaAddress } from "./address.js";
import { MetadataTree } from "./tree.js";
export {
Metadata,
MetadataMap,
MetadataFieldType,
MetadataFieldTypeValues,
MetadataValue,
ColorPlugin,
};
/**
* The height of the metadata Merkle tree.
*/
const METADATA_HEIGHT = 20;
const IndexedMerkleMap = Experimental.IndexedMerkleMap;
type IndexedMerkleMap = Experimental.IndexedMerkleMap;
/**
* A specialized IndexedMerkleMap for storing metadata.
*/
class MetadataMap extends IndexedMerkleMap(METADATA_HEIGHT) {}
/**
* The possible types for metadata fields.
*/
type MetadataFieldType =
| "string"
| "text"
| "image"
| "url"
| "field" // Field
| "number" // UInt64
| "address" // PublicKey
| "map"
| "tree";
/**
* Represents a metadata value with its type and associated data.
*/
class MetadataValue extends Struct({
value: Field,
type: Field,
length: Field,
height: Field,
}) {
/**
* Creates a new MetadataValue instance.
* @param params - The parameters including value and type.
* @returns A new MetadataValue.
*/
static new(params: {
value: Field | Text | Metadata | MetadataTree | PublicKey | UInt64;
type: MetadataFieldType;
}) {
const { value, type } = params;
let valueField: Field;
let length = Field(0);
let height = Field(0);
switch (type) {
case "string":
if (!(value instanceof Field)) throw new Error(`Invalid value type`);
valueField = value;
break;
case "text":
case "image":
case "url":
if (!(value instanceof Text)) throw new Error(`Invalid value type`);
valueField = value.root;
length = Field(value.size);
height = Field(value.height);
break;
case "field":
if (!(value instanceof Field)) throw new Error(`Invalid value type`);
valueField = value;
break;
case "number":
if (!(value instanceof UInt64)) throw new Error(`Invalid value type`);
valueField = value.value;
break;
case "address":
if (!(value instanceof PublicKey))
throw new Error(`Invalid value type`);
const address = new MinaAddress(value);
valueField = address.hash;
length = Field(2);
height = Field(0);
break;
case "map":
if (!(value instanceof Metadata)) throw new Error(`Invalid value type`);
valueField = value.map.root;
length = Field(value.map.length);
height = Field(value.map.height);
break;
case "tree":
if (!(value instanceof MetadataTree))
throw new Error(`Invalid value type`);
valueField = value.root;
length = Field(value.values.length);
height = Field(value.height);
break;
default:
throw new Error(`Unknown value type`);
}
return new MetadataValue({
value: valueField,
type: Field(MetadataFieldTypeValues[type].code),
length,
height,
});
}
/**
* Computes the Poseidon hash of the metadata value.
* @returns The hash as a Field.
*/
hash(): Field {
return Poseidon.hash(MetadataValue.toFields(this));
}
}
/**
* Abstract class for creating custom metadata plugins.
*/
abstract class MetadataPlugin {
/**
* The name of the plugin.
*/
readonly name: string;
/**
* Retrieves the trait representation of the metadata value.
* @param params - The parameters including key, type, value, and isPrivate.
* @returns An object containing the key, value, and canonical representation.
*/
abstract getTrait(params: {
key: string;
type: string;
value: unknown;
isPrivate?: boolean;
}): {
key: Field;
value: MetadataValue;
canonicalRepresentation: unknown;
};
/**
* Converts the value to JSON.
* @param value - The value to convert.
* @returns The JSON representation.
*/
abstract toJSON(value: unknown): string | object;
/**
* Parses the value from JSON.
* @param value - The JSON value to parse.
* @returns The parsed value.
*/
abstract fromJSON(value: string | object): unknown;
}
/**
* Type representing color values.
*/
type Color = "blue" | "red" | "green" | "yellow" | "purple" | "orange" | "pink";
/**
* A plugin for handling color metadata.
*/
class ColorPlugin extends MetadataPlugin {
/**
* The name of the plugin.
*/
readonly name = "color";
/**
* Converts a color name or value into its numeric representation.
* @param value - The color value (name, string, or number).
* @returns The numeric representation of the color.
*/
getColor(value: Color | string | number): number {
if (
typeof value === "string" &&
["blue", "red", "green", "yellow", "purple", "orange", "pink"].includes(
value
)
) {
const colors: { [key in Color]: number } = {
blue: 0x0000ff,
red: 0xff0000,
green: 0x00ff00,
yellow: 0xffff00,
purple: 0x800080,
orange: 0xffa500,
pink: 0xffc0cb,
};
return colors[value as Color];
} else if (typeof value === "number") {
return value;
} else if (typeof value === "string") {
try {
// parse hex color like #0000ff
return parseInt(value.slice(1), 16);
} catch (e) {
throw new Error("Invalid color value");
}
}
throw new Error("Invalid color value");
}
/**
* Retrieves the trait representation of the color value.
* @param params - The parameters including key, type, and value.
* @returns An object containing the key, value, and canonical representation.
*/
getTrait(params: {
key: string;
type: string;
value: Color | string | number;
}): {
key: Field;
value: MetadataValue;
canonicalRepresentation: number;
} {
const { key, value } = params;
const color = this.getColor(value);
return {
key: fieldFromString(key),
value: new MetadataValue({
value: Field(color),
type: Field(10),
length: Field(0),
height: Field(0),
}),
canonicalRepresentation: color,
};
}
/**
* Converts the color value to a JSON string.
* @param value - The color value.
* @returns The JSON string representation.
*/
toJSON(value: Color | string | number): string {
return this.getColor(value).toString(16);
}
/**
* Parses the color value from a JSON string or object.
* @param value - The JSON value.
* @returns The numeric representation of the color.
*/
fromJSON(value: string | object): number {
if (typeof value !== "string") throw new Error("Invalid color value");
return this.getColor(parseInt(value, 16));
}
}
/**
* Represents the metadata for an NFT, including traits and associated data.
*/
class Metadata {
/**
* The underlying map storing the metadata key-value pairs.
*/
readonly map: MetadataMap;
/**
* The name of the NFT.
*/
readonly name: string;
/**
* The image associated with the NFT.
*/
image: string;
/**
* Optional banner image for the NFT.
*/
banner?: string;
/**
* Optional description of the NFT.
*/
description?: string;
/**
* Array of metadata plugins used for custom traits.
*/
plugins: MetadataPlugin[];
/**
* Object containing the traits of the NFT.
*/
traits: {
[key: string]: {
type: string;
value:
| string
| Field
| Metadata
| MetadataTree
| UInt64
| PublicKey
| unknown;
isPrivate: boolean;
};
} = {};
/**
* Creates a new Metadata instance.
* @param params - The parameters for the metadata, including name, image, description, banner, and plugins.
*/
constructor(params: {
name: string;
image: string;
description?: string;
banner?: string;
plugins?: MetadataPlugin[];
}) {
const { name, description, image, banner, plugins } = params;
this.plugins = plugins ?? [];
this.map = new MetadataMap();
this.addTrait({
key: "name",
type: "string",
value: name,
});
this.addTrait({
key: "image",
type: "image",
value: image,
});
if (description) {
this.addTrait({
key: "description",
type: "text",
value: description,
});
}
if (banner) {
this.addTrait({
key: "banner",
type: "image",
value: banner,
});
}
this.name = name;
this.image = image;
this.banner = banner;
this.description = description;
}
/**
* Adds a trait to the metadata.
* @param params - The parameters including key, type, value, and isPrivate.
* @returns An object containing the key and the metadata value.
*/
addTrait(params: {
key: string;
type: string;
value:
| string
| Field
| Metadata
| MetadataTree
| UInt64
| PublicKey
| bigint
| number
| unknown;
isPrivate?: boolean;
}): {
key: Field;
value: MetadataValue;
} {
const { key, type, value, isPrivate = false } = params;
let keyField = fieldFromString(key);
let metadataValue: MetadataValue;
let canonicalRepresentation: unknown = value;
if (type in MetadataFieldTypeValues) {
let valueObject:
| Field
| Text
| Metadata
| MetadataTree
| UInt64
| PublicKey;
switch (type) {
case "string":
if (typeof value !== "string")
throw new Error(`Invalid trait value type`);
valueObject = fieldFromString(value);
break;
case "text":
case "image":
case "url":
if (typeof value !== "string")
throw new Error(`Invalid trait value type`);
valueObject = new Text(value);
break;
case "field":
if (
!(
value instanceof Field ||
typeof value === "bigint" ||
typeof value === "number" ||
typeof value === "string"
)
)
throw new Error(`Invalid trait value type`);
valueObject = Field(value);
break;
case "number":
if (
!(
value instanceof UInt64 ||
typeof value === "bigint" ||
typeof value === "number" ||
typeof value === "string"
)
)
throw new Error(`Invalid trait value type`);
valueObject = UInt64.from(value);
break;
case "address":
if (!(value instanceof PublicKey || typeof value === "string"))
throw new Error(`Invalid trait value type`);
valueObject =
typeof value === "string" ? PublicKey.fromBase58(value) : value;
break;
case "map":
if (!(value instanceof Metadata))
throw new Error(`Invalid trait value type`);
valueObject = value;
break;
case "tree":
if (!(value instanceof MetadataTree))
throw new Error(`Invalid trait value type`);
valueObject = value;
break;
default:
throw new Error(`Unknown trait value type - internal error`);
}
metadataValue = MetadataValue.new({ value: valueObject, type });
} else {
const index = this.plugins.findIndex((plugin) => plugin.name === type);
if (index !== -1) {
const plugin = this.plugins[index];
const pluginTrait = plugin.getTrait({ key, type, value, isPrivate });
metadataValue = pluginTrait.value;
keyField = pluginTrait.key;
canonicalRepresentation = pluginTrait.canonicalRepresentation;
} else throw new Error(`Unknown trait type`);
}
this.map.set(keyField, metadataValue.hash());
this.traits[key] = { type, value: canonicalRepresentation, isPrivate };
return { key: keyField, value: metadataValue };
}
/**
* Converts the metadata to a JSON representation.
* @param includePrivateTraits - Whether to include private traits.
* @returns The JSON representation of the metadata.
*/
toJSON(includePrivateTraits = false): {
name: string;
image: string;
description?: string;
banner?: string;
metadataRoot: string;
traits: {
key: string;
type: string;
value: string | object;
isPrivate?: boolean;
}[];
} {
return {
name: this.name,
description: this.description,
image: this.image,
banner: this.banner,
metadataRoot: this.map.root.toJSON(),
traits: Object.entries(this.traits)
.filter(([_, { isPrivate }]) => includePrivateTraits || !isPrivate)
.map(([key, { type, value, isPrivate }]) => {
let jsonValue: string | object;
switch (type) {
case "string":
case "text":
case "image":
case "url":
if (typeof value !== "string")
throw new Error(`Invalid trait value type`);
jsonValue = value;
break;
case "field":
if (!(value instanceof Field))
throw new Error(`Invalid trait value type`);
jsonValue = value.toJSON();
break;
case "number":
if (!(value instanceof UInt64))
throw new Error(`Invalid trait value type`);
jsonValue = value.toJSON();
break;
case "address":
if (!(value instanceof PublicKey))
throw new Error(`Invalid trait value type`);
jsonValue = value.toBase58();
break;
case "map":
if (!(value instanceof Metadata))
throw new Error(`Invalid trait value type`);
jsonValue = value.toJSON(includePrivateTraits);
break;
case "tree":
if (!(value instanceof MetadataTree))
throw new Error(`Invalid trait value type`);
jsonValue = value.toJSON();
break;
default:
const plugin = this.plugins.find(
(plugin) => plugin.name === type
);
if (!plugin) throw new Error(`Unknown trait type`);
jsonValue = plugin.toJSON(value);
}
return {
key,
type,
...(isPrivate ? { isPrivate } : {}),
value: jsonValue,
};
}),
};
}
/**
* Constructs a Metadata instance from JSON data.
* @param params - The parameters including json data, checkRoot flag, and plugins.
* @returns A new Metadata instance.
*/
static fromJSON(params: {
json: {
name: string;
image: string;
description?: string;
banner?: string;
metadataRoot: string;
traits?: {
key: string;
type: string;
value: string | object;
isPrivate?: boolean;
}[];
};
checkRoot?: boolean;
plugins?: MetadataPlugin[];
}): Metadata {
const { json, checkRoot = false, plugins } = params;
const {
name,
description,
image,
banner,
metadataRoot,
traits = [],
} = json;
if (!name) throw new Error(`Metadata name is required`);
if (typeof name !== "string") throw new Error(`Invalid metadata name`);
if (!image || typeof image !== "string")
throw new Error(`Invalid metadata image`);
if (description && typeof description !== "string")
throw new Error(`Invalid metadata description`);
if (banner && typeof banner !== "string")
throw new Error(`Invalid metadata banner`);
if (!metadataRoot || typeof metadataRoot !== "string")
throw new Error(`Invalid metadata root`);
if (!traits || !Array.isArray(traits))
throw new Error(`Metadata traits are required`);
for (const { key, type, value, isPrivate } of traits) {
if (!key || typeof key !== "string") throw new Error(`Invalid trait key`);
if (!type || typeof type !== "string")
throw new Error(`Invalid trait type`);
if (!value || (typeof value !== "string" && typeof value !== "object"))
throw new Error(`Invalid trait value`);
if (isPrivate && typeof isPrivate !== "boolean")
throw new Error(`Invalid trait isPrivate`);
}
const metadata = new Metadata({
name,
description,
image,
banner,
plugins,
});
for (const { key, type, value, isPrivate } of traits) {
let valueField:
| string
| Field
| Metadata
| MetadataTree
| UInt64
| PublicKey
| unknown;
switch (type) {
case "string":
case "text":
case "image":
case "url":
if (typeof value !== "string")
throw new Error(`Invalid trait value type`);
valueField = value;
break;
case "field":
if (typeof value !== "string")
throw new Error(`Invalid trait value type`);
valueField = Field.fromJSON(value);
break;
case "number":
if (typeof value !== "string")
throw new Error(`Invalid trait value type`);
valueField = UInt64.fromJSON(value);
break;
case "address":
if (typeof value !== "string")
throw new Error(`Invalid trait value type`);
valueField = PublicKey.fromBase58(value);
break;
case "map":
if (typeof value !== "object")
throw new Error(`Invalid trait value type`);
valueField = Metadata.fromJSON({
json: value as unknown as {
name: string;
image: string;
description?: string;
metadataRoot: string;
traits: {
key: string;
type: string;
value: string | object;
isPrivate?: boolean;
}[];
},
checkRoot,
});
break;
case "tree":
if (typeof value !== "object")
throw new Error(`Invalid trait value type`);
valueField = MetadataTree.fromJSON(
value as unknown as {
height: number;
root: string;
values: { key: string; value: string }[];
}
);
break;
default:
const plugin = metadata.plugins.find(
(plugin) => plugin.name === type
);
if (!plugin) throw new Error(`Unknown trait type`);
valueField = plugin.fromJSON(value);
}
metadata.addTrait({
key,
type,
value: valueField,
isPrivate: isPrivate ?? false,
});
}
if (checkRoot === true && metadata.map.root.toJSON() !== metadataRoot) {
throw new Error(
`Invalid metadata root:${JSON.stringify(
{
params,
root: metadata.map.root.toJSON(),
checkRoot,
metadata: metadata.toJSON(true),
},
null,
2
)}`
);
}
return metadata;
}
/**
* Constructs a Metadata instance from OpenAPI JSON data (without calculated root).
* @param params - The parameters including json data, checkRoot flag, and plugins.
* @returns A new Metadata instance.
*/
static fromOpenApiJSON(params: {
json: {
name: string;
image: string;
description?: string;
banner?: string;
traits?: {
key: string;
type: string;
value: string | object;
isPrivate?: boolean;
}[];
};
plugins?: MetadataPlugin[];
}): Metadata {
const { json, plugins } = params;
const { name, description, image, banner, traits = [] } = json;
if (!name) throw new Error(`Metadata name is required`);
if (typeof name !== "string") throw new Error(`Invalid metadata name`);
if (!image || typeof image !== "string")
throw new Error(`Invalid metadata image`);
if (description && typeof description !== "string")
throw new Error(`Invalid metadata description`);
if (banner && typeof banner !== "string")
throw new Error(`Invalid metadata banner`);
if (!traits || !Array.isArray(traits))
throw new Error(`Metadata traits are required`);
for (const { key, type, value, isPrivate } of traits) {
if (!key || typeof key !== "string") throw new Error(`Invalid trait key`);
if (!type || typeof type !== "string")
throw new Error(`Invalid trait type`);
if (!value || (typeof value !== "string" && typeof value !== "object"))
throw new Error(`Invalid trait value`);
if (isPrivate && typeof isPrivate !== "boolean")
throw new Error(`Invalid trait isPrivate`);
}
const metadata = new Metadata({
name,
description,
image,
banner,
plugins,
});
for (const { key, type, value, isPrivate } of traits) {
let valueField:
| string
| Field
| Metadata
| MetadataTree
| UInt64
| PublicKey
| unknown;
switch (type) {
case "string":
case "text":
case "image":
case "url":
if (typeof value !== "string")
throw new Error(`Invalid trait value type`);
valueField = value;
break;
case "field":
if (typeof value !== "string")
throw new Error(`Invalid trait value type`);
valueField = Field.fromJSON(value);
break;
case "number":
if (typeof value !== "string")
throw new Error(`Invalid trait value type`);
valueField = UInt64.fromJSON(value);
break;
case "address":
if (typeof value !== "string")
throw new Error(`Invalid trait value type`);
valueField = PublicKey.fromBase58(value);
break;
case "map":
if (typeof value !== "object")
throw new Error(`Invalid trait value type`);
valueField = Metadata.fromOpenApiJSON({
json: value as unknown as {
name: string;
image: string;
description?: string;
traits: {
key: string;
type: string;
value: string | object;
isPrivate?: boolean;
}[];
},
});
break;
case "tree":
if (typeof value !== "object")
throw new Error(`Invalid trait value type`);
valueField = MetadataTree.fromJSON(
value as unknown as {
height: number;
root: string;
values: { key: string; value: string }[];
}
);
break;
default:
const plugin = metadata.plugins.find(
(plugin) => plugin.name === type
);
if (!plugin) throw new Error(`Unknown trait type`);
valueField = plugin.fromJSON(value);
}
metadata.addTrait({
key,
type,
value: valueField,
isPrivate: isPrivate ?? false,
});
}
return metadata;
}
}
/**
* Mapping of metadata field types to their code values and associated types.
*/
const MetadataFieldTypeValues = {
string: { code: 1n, inputType: "string", storedType: Field }, // Field
text: { code: 2n, inputType: "string", storedType: Text }, // Text
image: { code: 3n, inputType: "string", storedType: Text }, // Text
url: { code: 4n, inputType: "string", storedType: Text }, // Text
field: { code: 5n, inputType: Field, storedType: Field }, // Field
map: { code: 6n, inputType: Metadata, storedType: Metadata }, // Metadata
tree: { code: 7n, inputType: MetadataTree, storedType: MetadataTree }, // MetadataTree
number: { code: 8n, inputType: UInt64, storedType: UInt64 }, // UInt64
address: { code: 9n, inputType: PublicKey, storedType: MinaAddress }, // MinaAddress
} as const;