import path from 'path'; import { cwd, stdout } from 'process'; import { ERC721ContractMetadata } from '@sloikaxyz/erc721-metadata-ajv'; import { PromisePool } from '@supercharge/promise-pool'; import { Command } from 'commander'; import { create as createIPFSClient, IPFSHTTPClient } from 'ipfs-http-client'; import { throttle } from 'lodash'; import fetch from 'node-fetch'; import invariant from 'ts-invariant'; import { Author, AUTHOR_SCHEMA } from '~/author/author.schema'; import { Series, SERIES_SCHEMA } from '~/series/series.schema'; import { loadYamlWithSchema } from '~/utils/loadYamlWithSchema'; import { MediaBundle, UploadedMediaBundle } from './media-bundle.model'; import { MetadataBundle, UploadedMetadataBundle, } from './metadata-bundle.model'; import { pinToRemoteService, makePinName } from './pinning.service'; const program = new Command(); // TODO: Make better const EXTERNAL_URL_BASE = `https://permalink.sloika.xyz/v1/`; if (!EXTERNAL_URL_BASE.endsWith('/')) { throw new Error('EXTERNAL_URL_BASE must end with a slash'); } const MINTING_URL_BASE = 'https://sloika.xyz/studio/mint/review'; const PUBLIC_URL_BASE = 'https://sloika.xyz/'; invariant( typeof process.env.ENSURE_PROFILE_ENDPOINT === 'string', 'ENSURE_PROFILE_ENDPOINT must be set', ); const ENSURE_PROFILE_ENDPOINT = process.env.ENSURE_PROFILE_ENDPOINT; invariant( typeof process.env.UPSERT_DRAFT_ENDPOINT === 'string', 'UPSERT_DRAFT_ENDPOINT must be set', ); const UPSERT_DRAFT_ENDPOINT = process.env.UPSERT_DRAFT_ENDPOINT; invariant( typeof process.env.ENDPOINT_AUTH_TOKEN === 'string', 'ENDPOINT_AUTH_TOKEN must be set', ); const ENDPOINT_AUTH_TOKEN = process.env.ENDPOINT_AUTH_TOKEN; const IPFS_CLIENT = createIPFSClient(); const IPFS_PIN_REMOTE_SERVICE = 'Pinata'; class SeriesBuilder { private totalSupply = 0; constructor( public author: Author, public series: Series, private readonly seriesDir: string, private readonly config: { ipfsClient: IPFSHTTPClient; mediaBundleUploadOptions?: Parameters[1]; mediaBundlePinOptions?: Parameters[4]; metadataBundleUploadOptions?: Parameters[1]; metadataBundlePinOptions?: Parameters[4]; }, ) {} async createMediaBundle(): Promise { const { contract_metadata, entries, extras } = this.series; const mediaBundle = new MediaBundle(); if (contract_metadata.image) { mediaBundle.addFileByStem( this.inSeriesDir(contract_metadata.image), 'collection', ); } if (contract_metadata.banner_image) { mediaBundle.addFileByStem( this.inSeriesDir(contract_metadata.banner_image), 'banner', ); } for (let tokenId = 1; tokenId <= entries.length; tokenId++) { const entry = entries[tokenId - 1]; const { image, animation } = entry; mediaBundle.addFileByStem(this.inSeriesDir(image), tokenId.toString()); if (typeof animation !== 'undefined') { mediaBundle.addFileByStem( this.inSeriesDir(animation), tokenId.toString(), ); } } if (typeof extras !== 'undefined') { for (const extra of extras) { mediaBundle.addFile(this.inSeriesDir(extra), extra); } } const uploadedMediaBundle = await mediaBundle.upload( this.config.ipfsClient, this.config.mediaBundleUploadOptions, ); return uploadedMediaBundle; } async createMetadataBundle( uploadedMediaBundle: UploadedMediaBundle, ): Promise { const metadataBundle = new MetadataBundle(); const { entries, contract_metadata } = this.series; const { name: authorName } = this.author; const { id: permalinkId } = this.series; for (const entry of entries) { const { editions = 1 } = entry; for (let edition = 1; edition <= editions; edition++) { const tokenId = ++this.totalSupply; const defaultExternalUrl = new URL( `${permalinkId}/${tokenId}`, EXTERNAL_URL_BASE, ).toString(); const { name: baseName, description, image, animation, attributes: entryAttributes, external_url = defaultExternalUrl, } = entry; const generatedAttributes = [ { trait_type: 'Artist', value: authorName, }, { trait_type: 'Edition', value: `${edition}/${editions}`, }, ]; const attributes = [ ...(entryAttributes ?? []), ...generatedAttributes.filter( ({ trait_type: generatedTrait }) => !entryAttributes?.some( ({ trait_type: providedTrait }) => providedTrait === generatedTrait, ), ), ]; const imageUri = uploadedMediaBundle.getUriForSrc( this.inSeriesDir(image), ); invariant( typeof imageUri !== 'undefined', "couldn't get the entry.image URI from uploaded media bundle", ); const media: { image: string; animation_url?: string } = { image: imageUri, }; if (animation) { const animationUri = uploadedMediaBundle.getUriForSrc( this.inSeriesDir(animation), ); invariant( typeof animationUri !== 'undefined', "couldn't get the entry.animation URI from uploaded media bundle", ); media.animation_url = animationUri; } metadataBundle.addTokenMetadata(tokenId.toString(), { name: editions > 1 ? `${baseName} #${edition}` : baseName, description, ...media, attributes, external_url, }); } } { const defaultExternalLink = new URL( permalinkId, EXTERNAL_URL_BASE, ).toString(); const { name, symbol, description, image, banner_image, seller_fee_basis_points, fee_recipient, external_link = defaultExternalLink, } = contract_metadata; const media: Pick = {}; if (typeof image !== 'undefined') { const imageUri = uploadedMediaBundle.getUriForSrc( this.inSeriesDir(image), ); invariant( typeof imageUri !== 'undefined', "couldn't get the collection.image URI from uploaded media bundle", ); media.image = imageUri; } if (typeof banner_image !== 'undefined') { const bannerUri = uploadedMediaBundle.getUriForSrc( this.inSeriesDir(banner_image), ); invariant( typeof bannerUri !== 'undefined', "couldn't get the collection.banner_image URI from uploaded media bundle", ); media.banner_image = bannerUri; } const metadata: ERC721ContractMetadata = { name, symbol, description, ...media, seller_fee_basis_points, fee_recipient, external_link, }; metadataBundle.addCollectionMetadata('collection', metadata); } const uploadedMetadataBundle = await metadataBundle.upload( this.config.ipfsClient, this.config.metadataBundleUploadOptions, ); return uploadedMetadataBundle; } async build(): Promise<{ metadata: UploadedMetadataBundle; media: UploadedMediaBundle; totalSupply: number; }> { const uploadedMediaBundle = await this.createMediaBundle(); const uploadedMetadataBundle = await this.createMetadataBundle( uploadedMediaBundle, ); return { metadata: uploadedMetadataBundle, media: uploadedMediaBundle, totalSupply: this.totalSupply, }; } private inSeriesDir(aPath: string): string { return path.join(this.seriesDir, aPath); } } export async function generate( authorPath: string, seriesPath: string, ): Promise<{ metadata: UploadedMetadataBundle['cid']; media: UploadedMediaBundle['cid']; fullSlug: string; totalSupply: number; publicUrl: string; mintingUrl: string; upsertDraftResponseJson: unknown; }> { const author = await loadYamlWithSchema(authorPath, AUTHOR_SCHEMA); const series = await loadYamlWithSchema(seriesPath, SERIES_SCHEMA); const seriesDir = path.dirname(seriesPath); const builder = new SeriesBuilder(author, series, seriesDir, { ipfsClient: IPFS_CLIENT, mediaBundleUploadOptions: { progress: throttle( (progressBytes: number, progressPath?: string) => stdout.write( `Uploading media bundle: ${String( progressPath, )} ${progressBytes}`.padEnd(80) + '\r', ), 5_000, ), }, metadataBundleUploadOptions: { progress: throttle( (progressBytes: number, progressPath?: string) => stdout.write( `Uploading metadata bundle: ${String( progressPath, )} ${progressBytes}`.padEnd(80) + '\r', ), 5_000, ), }, }); const retval = await builder.build(); const { media, metadata, totalSupply } = retval; const fullSlug = `${author.slug}/${series.slug}`; await Promise.all([ pinToRemoteService( IPFS_CLIENT, media.cid, IPFS_PIN_REMOTE_SERVICE, makePinName(fullSlug, 'media'), ), pinToRemoteService( IPFS_CLIENT, metadata.cid, IPFS_PIN_REMOTE_SERVICE, makePinName(fullSlug, 'data'), ), ]); const ensureAuthorPayload = { target: { slug: author.slug, }, payload: { name: author.name, description: author.bio, bannerImage: series.contract_metadata.image ? { url: media.getUriForSrc( path.join(seriesDir, series.contract_metadata.image), ), } : undefined, }, }; const ensureAuthorResponse = await fetch(ENSURE_PROFILE_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${ENDPOINT_AUTH_TOKEN}`, }, body: JSON.stringify(ensureAuthorPayload), }); if (!ensureAuthorResponse.ok) { throw new Error( `Failed to ensure author profile: ${await ensureAuthorResponse.text()}`, ); } // eslint-disable-next-line no-console console.log('Author profile submitted:', await ensureAuthorResponse.text()); const upsertDraftTarget = { authorSlug: author.slug, permalinkId: series.id, }; const upsertDraftPayloadCollection = { // permalinkId: series.id, slug: series.slug, kind: series.entries.every( (entry) => entry.image === series.entries[0].image, ) ? 'Edition' : 'Series', name: series.name, symbol: series.symbol, description: series.contract_metadata.description, image: series.contract_metadata.image ? { url: media.getUriForSrc( path.join(seriesDir, series.contract_metadata.image), ), } : undefined, bannerImage: series.contract_metadata.banner_image ? { url: media.getUriForSrc( path.join(seriesDir, series.contract_metadata.banner_image), ), } : undefined, dropsAt: series.contract_metadata.drop_date, }; // Array with values from 1 to totalSupply const upsertDraftPayloadTokens = Array.from( { length: totalSupply }, (_, i) => i + 1, ).map((tokenId) => ({ tokenId: tokenId.toString(), tokenURI: `ipfs://${metadata.cid.toString()}/${tokenId}`, })); const upsertDraftResponse = await fetch(UPSERT_DRAFT_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${ENDPOINT_AUTH_TOKEN}`, }, body: JSON.stringify({ target: upsertDraftTarget, payload: { collection: upsertDraftPayloadCollection, tokens: upsertDraftPayloadTokens, }, }), }); if (!upsertDraftResponse.ok) { throw new Error( `Failed to upsert draft: ${await upsertDraftResponse.text()}`, ); } const upsertDraftResponseJson: unknown = await upsertDraftResponse.json(); // eslint-disable-next-line no-console console.dir(upsertDraftResponseJson, { depth: null }); const mintingUrl = new URL(MINTING_URL_BASE); mintingUrl.searchParams.set( 'baseTokenURI', `ipfs://${metadata.cid.toString()}/`, ); mintingUrl.searchParams.set('initialSupply', totalSupply.toString()); mintingUrl.searchParams.set('salt', '1'); const publicUrl = new URL(`/${fullSlug}`, PUBLIC_URL_BASE).toString(); return { metadata: metadata.cid, media: media.cid, fullSlug, totalSupply: totalSupply, publicUrl: publicUrl.toString(), mintingUrl: mintingUrl.toString(), upsertDraftResponseJson, }; } const GenerateCommand = program .command('generate') .description('Create an ERC721 series metadata and media bundles') .argument('', 'path to author.yaml') .argument('', 'path to series.yaml') .action(async (authorPath: string, seriesPath: string) => { await generate(authorPath, seriesPath); }); const GenerateAllCommand = program .command('generate:all') .description('Create an ERC721 series metadata and media bundles') .argument('', 'series') .option('--data-dir ', 'root data directory', cwd()) .action(async (series: string[], { dataDir }: { dataDir: string }) => { const results: Awaited>[] = []; const errors: unknown[] = []; await PromisePool.withConcurrency(3) .for(series) .process(async (seriesFullPath) => { const [authorSlug, seriesSlug] = seriesFullPath.split('/'); const authorPath = path.resolve(dataDir, authorSlug, 'author.yaml'); const seriesPath = path.resolve( dataDir, authorSlug, seriesSlug, 'series.yaml', ); // eslint-disable-next-line no-console console.log( 'Generating', seriesFullPath, `(${authorPath}:${seriesPath})`, '...', ); try { results.push(await generate(authorPath, seriesPath)); } catch (err) { // eslint-disable-next-line no-console console.error(`failed to generate ${seriesFullPath}`, err); errors.push(err); throw err; } }); if (errors.length > 0) { // eslint-disable-next-line no-console console.error('Errors occurred while generating bundles'); for (const error of errors) { if (error instanceof Error) { // eslint-disable-next-line no-console console.error(` * ${error.name}: ${error.message}`); } else { // eslint-disable-next-line no-console console.error(error); } } throw new AggregateError( errors, 'Some errors occurred while generating bundles', ); } for (const result of results) { // eslint-disable-next-line no-console console.log('Built', result.fullSlug, result); } }); export { GenerateAllCommand, GenerateCommand };