Skip to content

Commit

Permalink
Add limit field to editions.csv (#173)
Browse files Browse the repository at this point in the history
* Parse edition limit from CSV

* Remote --templates flag

* Reject editions that exceed size limit

* Add changeset
  • Loading branch information
psiemens authored Dec 16, 2022
1 parent 003d52b commit 7346304
Show file tree
Hide file tree
Showing 4 changed files with 68 additions and 108 deletions.
5 changes: 5 additions & 0 deletions .changeset/sour-cougars-bathe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'freshmint': minor
---

Add support for edition_limit field
19 changes: 3 additions & 16 deletions packages/freshmint/commands/mint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import * as metadata from '@freshmint/core/metadata';
import { FlowGateway, FlowNetwork } from '../flow';
import { ContractType, FreshmintConfig, loadConfig } from '../config';
import { parsePositiveIntegerArgument } from '../arguments';
import { FreshmintError } from '../errors';

import { StandardMinter } from '../mint/minters/StandardMinter';
import { EditionMinter } from '../mint/minters/EditionMinter';
Expand All @@ -24,7 +23,6 @@ export default new Command('mint')
.option('-o, --output <csv-path>', 'The location of the output CSV file')
.option('-b, --batch-size <number>', 'The number of NFTs to mint per batch', parsePositiveIntegerArgument, 10)
.option('-c, --claim', 'Generate a claim key for each NFT')
.option('-t, --templates', 'Create edition templates only, skip minting (for edition-based contracts only)')
.option('-n, --network <network>', "Network to mint to. Either 'emulator', 'testnet' or 'mainnet'", 'emulator')
.action(mint);

Expand All @@ -34,14 +32,12 @@ async function mint({
output,
claim,
batchSize,
templates,
}: {
network: FlowNetwork;
input: string | undefined;
output: string | undefined;
claim: boolean;
batchSize: number;
templates: boolean;
}) {
const config = await loadConfig();

Expand All @@ -50,10 +46,6 @@ async function mint({
const csvInputFile = input ?? config.nftDataPath;
const csvOutputFile = output ?? generateOutputFilename(network);

if (templates && config.contract.type !== ContractType.Edition) {
throw new EditionTemplatesOptionError();
}

const metadataProcessor = getMetadataProcessor(config);

const answer = await inquirer.prompt({
Expand All @@ -71,7 +63,7 @@ async function mint({
case ContractType.Standard:
return mintStandard(config, metadataProcessor, flow, csvInputFile, csvOutputFile, claim, batchSize);
case ContractType.Edition:
return mintEdition(config, metadataProcessor, flow, csvInputFile, csvOutputFile, claim, batchSize, templates);
return mintEdition(config, metadataProcessor, flow, csvInputFile, csvOutputFile, claim, batchSize);
}
}

Expand Down Expand Up @@ -148,7 +140,6 @@ async function mintEdition(
csvOutputFile: string,
withClaimKeys: boolean,
batchSize: number,
templatesOnly: boolean,
) {
const minter = new EditionMinter(config.contract.schema, metadataProcessor, flow);

Expand All @@ -157,7 +148,7 @@ async function mintEdition(

let bar: ProgressBar;

await minter.mint(csvInputFile, csvOutputFile, withClaimKeys, batchSize, templatesOnly, {
await minter.mint(csvInputFile, csvOutputFile, withClaimKeys, batchSize, {
onStartDuplicateCheck: () => {
lineBreak();
duplicatesSpinner.start('Checking for duplicates...');
Expand Down Expand Up @@ -189,7 +180,7 @@ async function mintEdition(
}

editionSpinner.succeed(
`Successfully created ${pluralize(count, 'new edition template')} to ${chalk.cyan(config.contract.name)}.`,
`Successfully created ${pluralize(count, 'new edition template')} in ${chalk.cyan(config.contract.name)}.`,
);
},
onStartMinting: (nftCount: number, batchCount: number, batchSize: number) => {
Expand Down Expand Up @@ -227,10 +218,6 @@ async function mintEdition(
});
}

class EditionTemplatesOptionError extends FreshmintError {
message = 'The --templates flag can only be used with an edition-based contract.';
}

function getMetadataProcessor(config: FreshmintConfig): MetadataProcessor {
const metadataProcessor = new MetadataProcessor(config.contract.schema);

Expand Down
2 changes: 1 addition & 1 deletion packages/freshmint/flow/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export type CreateEditionResult = {

export type EditionResult = {
id: string;
limit: number;
limit: number | null;
size: number;
} | null;

Expand Down
150 changes: 59 additions & 91 deletions packages/freshmint/mint/minters/EditionMinter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,26 +10,23 @@ import { readCSV, writeCSV } from '../csv';
import { FreshmintError } from '../../errors';

interface EditionEntry {
limit: number | null;
size: number;
rawMetadata: MetadataMap;
preparedMetadata: MetadataMap;
hash: string;
limit: number | null;
}

interface LimitedEditionEntry extends EditionEntry {
limit: number;
csvLineNumber: number;
}

interface Edition {
id: string;
size: number;
limit: number | null;
currentSize: number;
targetSize: number;
rawMetadata: MetadataMap;
preparedMetadata: MetadataMap;
transactionId?: string;
}

interface LimitedEdition extends Edition {
limit: number;
csvLineNumber: number;
}

type EditionBatch = {
Expand All @@ -47,14 +44,17 @@ export type EditionHooks = {
onComplete: (editionCount: number, nftCount: number) => void;
};

export class EditionLimitMintError extends FreshmintError {
message =
'Cannot mint NFTs into an edition with no defined size.\nYour CSV file contains at least one edition with an "edition_size" of null.\nUse the --templates flag to only create the edition template and skip minting.';
export class InvalidEditionLimitError extends FreshmintError {
constructor(value: string, csvLineNumber: number) {
super(`Invalid edition on line ${csvLineNumber}: size must be a number, received "${value}".`);
}
}

export class InvalidEditionSizeError extends FreshmintError {
constructor(value: string) {
super(`Edition size must be a number, received "${value}".`);
export class ExceededEditionLimitError extends FreshmintError {
constructor(edition: Edition) {
super(
`Invalid edition on line ${edition.csvLineNumber} (with on-chain ID ${edition.id}): target size ${edition.targetSize} exceeds edition limit of ${edition.limit}.`,
);
}
}

Expand All @@ -74,27 +74,18 @@ export class EditionMinter {
csvOutputFile: string,
withClaimKeys: boolean,
batchSize: number,
templatesOnly: boolean,
hooks: EditionHooks,
) {
const entries = await readCSV(path.resolve(process.cwd(), csvInputFile));

const editionEntries = await this.prepareMetadata(entries);

if (templatesOnly) {
await this.createTemplates(editionEntries, csvOutputFile, hooks);
} else {
await this.createTemplatesAndMint(editionEntries, csvOutputFile, withClaimKeys, batchSize, hooks);
}
}

async createTemplates(editionEntries: EditionEntry[], csvOutputFile: string, hooks: EditionHooks) {
hooks.onStartDuplicateCheck();

const { existingEditions, newEditionEntries } = await this.filterDuplicateEditions(editionEntries);

const existingEditionCount = existingEditions.length;
const existingNFTCount = existingEditions.reduce((totalSize, edition) => totalSize + edition.size, 0);
const existingNFTCount = existingEditions.reduce((totalSize, edition) => totalSize + edition.currentSize, 0);

hooks.onCompleteDuplicateCheck(existingEditionCount, existingNFTCount);

Expand All @@ -104,57 +95,32 @@ export class EditionMinter {

hooks.onCompleteEditionCreation(newEditionEntries.length);

await this.writeEditionTemplates(newEditions, csvOutputFile);
// Combine existing and new editions into a single array
const editions = [...existingEditions, ...newEditions];

hooks.onComplete(newEditions.length, 0);
}
// Throw an error if any edition target size is larger than its limit
for (const edition of editions) {
if (edition.limit !== null && edition.targetSize > edition.limit) {
throw new ExceededEditionLimitError(edition);
}
}

async createTemplatesAndMint(
editionEntries: EditionEntry[],
csvOutputFile: string,
withClaimKeys: boolean,
batchSize: number,
hooks: EditionHooks,
) {
const limitedEditionEntries: LimitedEditionEntry[] = editionEntries.map((edition) => {
// Remove editions that have already reached their size limit
const editionsToMint = editions.filter((edition) => {
if (edition.limit === null) {
throw new EditionLimitMintError();
return true;
}

return {
...edition,
limit: edition.limit,
};
return edition.currentSize < edition.limit;
});

hooks.onStartDuplicateCheck();

const { existingEditions, newEditionEntries } = await this.filterDuplicateEditions(limitedEditionEntries);

const existingEditionCount = existingEditions.length;
const existingNFTCount = existingEditions.reduce((totalSize, edition) => totalSize + edition.size, 0);

hooks.onCompleteDuplicateCheck(existingEditionCount, existingNFTCount);

hooks.onStartEditionCreation(newEditionEntries.length);

const newEditions = await this.createEditionTemplates(newEditionEntries);

hooks.onCompleteEditionCreation(newEditionEntries.length);

// Combine existing and new editions into a single array
const editions = [...existingEditions, ...newEditions];

// Remove editions that have already reached their size limit
const editionsToMint = editions.filter((edition) => edition.size < edition.limit);

const nftCount = await this.mintInBatches(editionsToMint, csvOutputFile, withClaimKeys, batchSize, hooks);

hooks.onComplete(newEditions.length, nftCount);
}

async mintInBatches(
editions: LimitedEdition[],
editions: Edition[],
csvOutputFile: string,
withClaimKeys: boolean,
batchSize: number,
Expand Down Expand Up @@ -187,14 +153,6 @@ export class EditionMinter {
return nftCount;
}

async filterDuplicateEditions(
entries: LimitedEditionEntry[],
): Promise<{ existingEditions: LimitedEdition[]; newEditionEntries: LimitedEditionEntry[] }>;

async filterDuplicateEditions(
entries: EditionEntry[],
): Promise<{ existingEditions: Edition[]; newEditionEntries: EditionEntry[] }>;

async filterDuplicateEditions(
entries: EditionEntry[],
): Promise<{ existingEditions: Edition[]; newEditionEntries: EditionEntry[] }> {
Expand All @@ -210,12 +168,15 @@ export class EditionMinter {

if (existingEdition) {
existingEditions.push({
...existingEdition,
// TODO: the on-chain ID needs to be converted to a string in order to be
// used as an FCL argument. Find a better way to sanitize this.
id: existingEdition.id.toString(),
limit: existingEdition.limit,
currentSize: existingEdition.size,
targetSize: entry.size,
rawMetadata: entry.rawMetadata,
preparedMetadata: entry.preparedMetadata,
csvLineNumber: entry.csvLineNumber,
});
} else {
newEditionEntries.push(entry);
Expand All @@ -228,10 +189,6 @@ export class EditionMinter {
};
}

async createEditionTemplates(entries: LimitedEditionEntry[]): Promise<LimitedEdition[]>;

async createEditionTemplates(entries: EditionEntry[]): Promise<Edition[]>;

async createEditionTemplates(entries: EditionEntry[]): Promise<Edition[]> {
if (entries.length === 0) {
return [];
Expand All @@ -255,15 +212,17 @@ export class EditionMinter {
const results = await this.flowGateway.createEditions(mintIds, limits, metadataValues);

return results.map((result, i) => {
const edition = entries[i];
const entry = entries[i];

return {
id: result.id,
limit: result.limit,
size: result.size,
currentSize: result.size,
targetSize: entry.size,
transactionId: result.transactionId,
rawMetadata: edition.rawMetadata,
preparedMetadata: edition.preparedMetadata,
rawMetadata: entry.rawMetadata,
preparedMetadata: entry.preparedMetadata,
csvLineNumber: entry.csvLineNumber,
};
});
}
Expand All @@ -285,13 +244,19 @@ export class EditionMinter {
async prepareMetadata(entries: MetadataMap[]): Promise<EditionEntry[]> {
const preparedEntries = await this.metadataProcessor.prepare(entries);

const csvHeaderLines = 1;

return preparedEntries.map((entry, i) => {
// Attach the edition limit parsed from the input
const editionSize = entries[i]['edition_size'];
const limit = parseEditionLimit(editionSize);
const csvLineNumber = i + 1 + csvHeaderLines;

// Attach the edition limit and size parsed from the input
const size = parseInt(entries[i]['edition_size'], 10);
const limit = parseEditionLimit(entries[i]['edition_limit'], size, csvLineNumber);

return {
...entry,
csvLineNumber,
size,
limit,
};
});
Expand Down Expand Up @@ -347,20 +312,18 @@ export class EditionMinter {
}
}

function createBatches(editions: LimitedEdition[], batchSize: number): EditionBatch[] {
function createBatches(editions: Edition[], batchSize: number): EditionBatch[] {
return editions.flatMap((edition) => createEditionBatches(edition, batchSize));
}

function createEditionBatches(edition: LimitedEdition, batchSize: number): EditionBatch[] {
function createEditionBatches(edition: Edition, batchSize: number): EditionBatch[] {
const batches: EditionBatch[] = [];

let count = edition.size;
let remaining = edition.limit - edition.size;
let remaining = edition.targetSize - edition.currentSize;

while (remaining > 0) {
const size = Math.min(batchSize, remaining);

count = count + size;
remaining = remaining - size;

batches.push({ edition: edition, size });
Expand Down Expand Up @@ -389,14 +352,19 @@ async function savePrivateKeysToFile(filename: string, batch: EditionBatch, priv
//
// The edition limit must be an integer or "null".
//
function parseEditionLimit(value: string): number | null {
function parseEditionLimit(value: string | undefined, size: number, csvLineNumber: number): number | null {
// If limit is undefined, use size as limit
if (value === undefined) {
return size;
}

if (value === 'null') {
return null;
}

const limit = parseInt(value, 10);
if (isNaN(limit)) {
throw new InvalidEditionSizeError(value);
throw new InvalidEditionLimitError(value, csvLineNumber);
}

return limit;
Expand Down

0 comments on commit 7346304

Please sign in to comment.