import { Web3Provider, Provider } from '@ethersproject/providers'; import { Contract, Signer, ethers } from 'ethers'; import { InfinityMintBundle, InfinityMintDeployedProject, InfinityMintDeploymentLive, InfinityMintProjectAsset, InfinityMintProjectPath, } from 'infinitymint/dist/app/interfaces'; import { getConfig } from '../main'; import { InfinityMintClient } from '../client'; import { getApiEndpoint, getMimeType, log, MimeType, toProjectFullName, warning, } from '../utils/helpers'; import { InfinityMint } from 'infinitymint/dist/typechain-types/InfinityMint'; import { InfinityMintStorage } from 'infinitymint/dist/typechain-types/InfinityMintStorage'; import { InfinityMintValues } from 'infinitymint/dist/typechain-types/InfinityMintValues'; import { InfinityMintProject } from 'infinitymint/dist/typechain-types/InfinityMintProject'; import { InfinityMintLinker } from 'infinitymint/dist/typechain-types/InfinityMintLinker'; import { InfinityMintApi } from 'infinitymint/dist/typechain-types/InfinityMintApi'; import { Dictionary } from 'infinitymint/dist/app/helpers'; import { Resources } from './resources'; import { Token } from './token'; import storageController from './storage'; import { fetchImport } from './imports'; export type ProjectSource = | 'ipfs' | 'public' | 'api' | 'any' | 'localstorage' | 'unknown'; export class Project { public source: ProjectSource = 'unknown'; public deployedProject: InfinityMintDeployedProject; public client: InfinityMintClient; private contracts: { [key: string]: Contract } = {}; private signedContracts: { [key: string]: Contract } = {}; private provider: Web3Provider | Provider | ethers.providers.Web3Provider; private staticProvider: Provider | Web3Provider; private defaultSigner: Signer; private resources: Resources; private tokens: Dictionary = {}; constructor( deployedProject: InfinityMintDeployedProject, client: InfinityMintClient ) { this.deployedProject = deployedProject; this.client = client; } public hasCustomRenderer() { return ( ((this.deployedProject.settings as any)?.defaultRenderer as any) !== undefined ); } public getCustomRenderer() { return (this.deployedProject.settings as any)?.defaultRenderer as any; } public setProvider( provider: Web3Provider | Provider | ethers.providers.Web3Provider ) { this.provider = provider; } public setStaticProvider(provider: Provider | Web3Provider) { this.staticProvider = provider; } public get name(): string { return this.deployedProject.name; } public get version(): { tag: string; version: string } { return this.deployedProject.version; } public get description(): string { return this.deployedProject?.information?.description || ''; } public async initializeResources(): Promise { this.resources = new Resources(this); await this.resources.load(); return this.resources; } public get projectFullName() { return toProjectFullName( this.name, this.version.version, this.deployedProject.network.name ); } public get projectNameAndVersion() { return `${this.name}@${this.version.version}`; } public get collectionName() { return this.deployedProject.information?.fullName || this.name; } public get collectionShortName() { return this.deployedProject.information.shortName || this.name; } public getImports(): Dictionary { return this.deployedProject.imports; } public getBundle(): InfinityMintBundle { return this.deployedProject.bundles; } public getGem(gemName: string) { return this.deployedProject.gems[gemName]; } /** * Returns a list of gem names that are vailable for this project, optionally filtering out disabled gems * @param filterDisabled * @returns */ public getGemKeys(filterDisabled?: boolean): string[] { if (filterDisabled) return Object.keys(this.deployedProject.gems).filter( (gemName) => this.deployedProject.gems[gemName].enabled ); return Object.keys(this.deployedProject.gems); } public hasProvider() { return this.provider !== undefined; } public getProvider() { return this.provider; } /** * Used in some api endpoints to find the project * @returns */ public sourceName() { return this.deployedProject.source.base; } public hasGemEnabled(gemName: string) { return this.deployedProject.gems[gemName]?.enabled ?? false; } public getPath(pathId: number) { return this.deployedProject.paths[pathId]; } public async getTokenPrice(format = true) { let result = await this.erc721().tokenPrice(); if (format) return ethers.utils.formatEther(result.toString()); return result.toString(); } public async maxSupply() { return await this.erc721().totalSupply(); } public async hasToken(tokenId: number) { return await this.erc721().exists(tokenId); } public async totalMints() { return await this.erc721().currentTokenId(); } public async getToken( tokenId: number, fetchPathExport = true, abortController?: AbortController, force?: boolean ) { let token = this.createToken(tokenId); if (!this.hasToken(tokenId)) throw new Error(`Token ${tokenId} does not exist`); if (token.hasLoaded() && !force && !token.loadError) return token; await token.load(fetchPathExport, abortController); if (token.loadError) console.warn(`Token ${tokenId} failed to load`, token.loadError); return token; } private createToken(tokenId: number) { if (this.tokens[tokenId]) return this.tokens[tokenId]; let token = new Token(this, tokenId); this.tokens[tokenId] = token; return token; } public getPathExportObject(pathId: number) { return this.deployedProject.paths[pathId].export; } public getAsset(assetId: number) { return Object.values(this.deployedProject.assets).find( (val) => val.assetId === assetId ); } public getAssetExportObject(assetId: number) { return this.getAsset(assetId).export; } public getAssetSections() { let sections: Dictionary = {}; let assets = Object.values(this.deployedProject.assets || {}); if (assets.length === 0) return {}; assets.forEach((asset) => { if (!asset.section) return; if (!sections[asset.section]) sections[asset.section] = []; sections[asset.section].push(asset); }); if (Object.values(sections).length === 0) return { default: this.deployedProject.assets, } as Dictionary; return sections; } public async fetchAssetExport( assetId?: number, type: string = 'path', abortController?: AbortController, source: ProjectSource = 'any' ) { if (source === 'ipfs' && !this.isAssetIPFS(assetId)) { warning(`Asset ${assetId} is not available on IPFS`); source = 'api'; } if (source === 'api' && !this.client.apiAccess) { warning(`Path ${assetId} is not available on API`); source = 'public'; } let asset = this.getAsset(assetId); return await this.fetchExport(asset, type, abortController, source); } /** * Will return the path export from the pathId. Will be in the form of a blob * @param pathId * @param type * @param abortController * @param source * @returns */ public async fetchExport( path?: InfinityMintProjectPath | InfinityMintProjectAsset, type: string = 'path', abortController?: AbortController, source: ProjectSource = 'any' ) { abortController = abortController || new AbortController(); let fetchedExport = type === 'path' ? path?.export : path?.content[type]?.export; if (!fetchedExport) throw new Error(`Export '${type}' not found`); let result = await fetchImport( this.deployedProject, fetchedExport, abortController, source !== 'any' && source === 'ipfs', source !== 'any' && source === 'api', source !== 'any' && source === 'public' ); return result; } /** * Will return the path export from the pathId. Will be in the form of a blob * @param pathId * @param type * @param abortController * @param source * @returns */ public async fetchPathExport( pathId?: number, type: string = 'path', abortController?: AbortController, source: ProjectSource = 'any' ) { abortController = abortController || new AbortController(); if (source === 'ipfs' && !this.isPathIPFS(pathId)) { warning(`Path ${pathId} is not available on IPFS`); source = 'api'; } if (source === 'api' && !this.client.apiAccess) { warning(`Path ${pathId} is not available on API`); source = 'public'; } let path = this.getPath(pathId); return await this.fetchExport(path, type, abortController, source); } public getPathExportExtension(pathId: number, type: string = 'path') { return this.getPathLocalSource(pathId, type)?.extension; } public getPathLocalSource(pathId: number, type: string = 'path') { if (!type || type === 'path') return this.deployedProject.paths[pathId]?.source; return this.deployedProject.paths[pathId]?.content[type]?.source; } public getAssetExportExtension(assetId: number, type: string = 'path') { let asset = this.getAsset(assetId); if (!type || type === 'path') return asset.source?.extension; else return asset?.content[type]?.source?.extension; } public getAssetIPFSLocation(assetId: number): string { let asset = this.getAssetExportObject(assetId); return asset.external.ipfs.url; } public getAssetPublicLocation(assetId: number): string { let asset = this.getAssetExportObject(assetId); return this.getBundle().imports[asset.key].bundle; } public getPathIPFSLocation(pathId: number): string { let pathExport = this.getPathExportObject(pathId); return pathExport.external.ipfs.url; } public getPathPublicLocation(pathId: number): string { let pathExport = this.getPathExportObject(pathId); return this.getBundle().imports[pathExport.key].bundle; } public isPathAudio(pathId: number) { return ( this.getPathMimeType(pathId) === MimeType.MP3 || this.getPathMimeType(pathId) === MimeType.WAV || this.getPathMimeType(pathId) === MimeType.OGG || this.getPathMimeType(pathId) === MimeType.FLAC ); } public isPathIPFS(pathId: number) { return this.getPathExportObject(pathId).external.ipfs !== undefined; } public isAssetIPFS(assetId: number) { return this.getAssetExportObject(assetId)?.external?.ipfs !== undefined; } public isPathVideo(pathId: number) { return ( this.getPathMimeType(pathId) === MimeType.MP4 || this.getPathMimeType(pathId) === MimeType.WEBM ); } public async isAdmin(address?: string) { if (!address) return false; return await this.erc721().isAuthenticated(address); } public getPathMimeType(pathId: number, type = 'path'): MimeType { return getMimeType(this.getPathExportExtension(pathId, type)); } public getAssetMimeType(assetId: number, type = 'path'): MimeType { return getMimeType(this.getPathExportExtension(assetId, type)); } /** * Returns a contract for you to read values from. Will use the static provider by default unless specified otherwise. * @param contractName * @param provider * @param force * @param useStaticProvider * @returns */ public getContract( contractName: string, provider?: Web3Provider | Provider | ethers.providers.Web3Provider, force?: boolean, useStaticProvider: boolean = true ) { provider = provider || this.staticProvider || this.provider; if (useStaticProvider && this.staticProvider) { provider = this.staticProvider; } else if (!useStaticProvider && this.provider) { provider = this.provider; } if (!provider) throw new Error( `No provider found. Please provide a provider or set a static provider` ); if (this.contracts[contractName] && !force) return this.contracts[contractName]; let deployment = this.getDeployment(contractName); if (!deployment) throw new Error(`No deployment found for ${contractName}`); let contract = new Contract( deployment.address, deployment.abi, provider ); log( `Creating ${ useStaticProvider ? 'static' : '' } contract ${contractName} => ${contract.address}` ); this.contracts[contractName] = contract; return contract; } public getSignedContract( contractName: string, signer: Signer, provider?: Web3Provider | Provider, force?: boolean ) { (provider as any) = provider || this.provider || signer.provider; if (this.signedContracts[contractName] && !force) return this.signedContracts[contractName]; else delete this.signedContracts[contractName]; let contract = new Contract( this.getDeployment(contractName).address, this.getDeployment(contractName).abi, provider ); log( `Signing contract ${contractName} => ${ this.getDeployment(contractName).address }` ); this.signedContracts[contractName] = contract.connect(signer); return this.signedContracts[contractName]; } public async getBalance(address?: string) { address = address || this.client.getCurrentAddress(); return await this.erc721().balanceOf(address); } public collectionToken(plural?: boolean) { if (plural) return this.deployedProject.information.tokenMultiple; return this.deployedProject.information.tokenSingular; } public get collectionSymbol() { return this.deployedProject.information.tokenSymbol; } public getDeployment(contractName: string): InfinityMintDeploymentLive { return this.deployedProject.deployments[contractName]; } public isApproved(contractName: string, address: string): boolean { return ( Object.values( (this.deployedProject.deployments[contractName] as any) .permissions ).filter((addr) => addr === address).length > 0 ); } public async fetchDeployment( contractName: string, abortController?: AbortController ): Promise { abortController = abortController || new AbortController(); let config = getConfig(); let { useInfinityMintApi, forcePublic } = config.deployments; let url = `/deployments/${this.deployedProject.network}/${this.projectNameAndVersion}/${contractName}.json`; if (!forcePublic && useInfinityMintApi) url = getApiEndpoint( `/deployments/get?project=${this.projectNameAndVersion}&network=${this.deployedProject.network}&contract=${contractName}` ); let response = await fetch(url, { signal: abortController.signal, }); let result = await response.json(); return result; } public getModuleDeployment(moduleName: string): InfinityMintDeploymentLive { return this.deployedProject.deployments[ this.deployedProject.modules[moduleName] ]; } public getModule(moduleName: string, signer: Signer): Contract { return this.getSignedContract( this.deployedProject.modules[moduleName], signer ); } public erc721( signer?: Signer, useStaticProvider: boolean = true ): InfinityMint { if ((!signer && !this.defaultSigner) || useStaticProvider) return this.getContract( 'InfinityMint', null, null, useStaticProvider ) as InfinityMint; return this.getSignedContract( 'InfinityMint', signer || this.defaultSigner ) as InfinityMint; } public storage( signer?: Signer, useStaticProvider: boolean = true ): InfinityMintStorage { if ((!signer && !this.defaultSigner) || useStaticProvider) return this.getContract( 'InfinityMintStorage', null, null, useStaticProvider ) as InfinityMintStorage; return this.getSignedContract( 'InfinityMintStorage', signer || this.defaultSigner, null, true ) as InfinityMintStorage; } /** * Fetches a project fron the web3 provider. If the project is a JSON string, it will parse it and return it. Otherwise, it will fetch it from the URL. * @param abortController * @returns */ public async fetchProjectFromWeb3( abortController?: AbortController ): Promise { let project = await this.project().getProject(); project = ethers.utils.toUtf8String(project); let isLocal = false; if (project.indexOf('%JSON%') !== -1) { project = project.replace('%JSON%', ''); log('Chain project is json! Parsing...'); try { let result = JSON.parse(project); if (!result.local) return result; //assume project } catch (error) { warning('Bad parse assuming local...'); } isLocal = true; } else if (project.indexOf('%CID%') !== -1) { project = project.replace('%CID%', ''); if (project === 'undefined' || !project) { warning('Bad CID assuming local...'); isLocal = true; } else project = 'https://ipfs.io/ipfs/' + project + '/' + this.projectFullName + '.json'; } if (isLocal) return ( await fetchProject( this.deployedProject.name, this.deployedProject.version.version, this.deployedProject.network.name, this.client, abortController, true ) ).deployedProject; let result = await fetch(project, { signal: abortController?.signal, }); return (await result.json()) as InfinityMintDeployedProject; } public project( signer?: Signer, useStaticProvider: boolean = true ): InfinityMintProject { if ((!signer && !this.defaultSigner) || useStaticProvider) return this.getContract( 'InfinityMintProject', null, null, useStaticProvider ) as InfinityMintProject; return this.getSignedContract( 'InfinityMintProject', signer || this.defaultSigner, null, true ) as InfinityMintProject; } public values( signer?: Signer, useStaticProvider: boolean = true ): InfinityMintValues { if ((!signer && !this.defaultSigner) || useStaticProvider) return this.getContract( 'InfinityMintValues', null, null, useStaticProvider ) as InfinityMintValues; return this.getSignedContract( 'InfinityMintValues', signer || this.defaultSigner, null, true ) as InfinityMintValues; } /** * Will save the project to the local storage */ public save() { storageController.setGlobalPreference( this.projectFullName, this.deployedProject ); storageController.save(); } public setLocalStorage(key: string, value: string) { storageController.set(key, value, this.projectFullName); storageController.save(); } public getLocalStorage(key: string) { return storageController.get(key, this.projectFullName); } /** * Will remove the project from the local storage */ public wipe() { storageController.setGlobalPreference(this.projectFullName, null); storageController.save(); this.client.removeLocalRequiredProject(this.projectFullName); } /** * Returns a new project instance with the same configuration but a new version and/or network * @param newVersion * @param network * @returns */ public clone( newVersion?: string, network?: { name: string; chainId: number; url?: string; tokenSymbol?: string; } ): Project { let newProject = { ...this.deployedProject, }; newProject.network = network || newProject.network; newProject.version.version = newVersion || this.deployedProject.version.version; return new Project(newProject, this.client); } public api( signer?: Signer, useStaticProvider: boolean = true ): InfinityMintApi { if ((!signer && !this.defaultSigner) || useStaticProvider) return this.getContract( 'InfinityMintApi', null, null, useStaticProvider ) as InfinityMintApi; return this.getSignedContract( 'InfinityMintApi', signer || this.defaultSigner, null, true ) as InfinityMintApi; } public linker( signer?: Signer, useStaticProvider: boolean = true ): InfinityMintLinker { if ((!signer && !this.defaultSigner) || useStaticProvider) return this.getContract( 'InfinityMintLinker', null, null, useStaticProvider ) as InfinityMintLinker; return this.getSignedContract( 'InfinityMintLinker', signer || this.defaultSigner, null, true ) as InfinityMintLinker; } public async checkForUpdates(): Promise<{ hasUpdated: boolean; currentVersion?: string; newVersion?: string; }> { let result; try { result = await this.fetchProjectFromWeb3(); } catch (error) { warning('fetchProjectFromWeb3 failed: ', error.stack); result = false; } if (!result) return { hasUpdated: false, currentVersion: this.deployedProject.version.version, }; let newVersion = result.version.version; if (newVersion !== this.deployedProject.version.version) { return { hasUpdated: true, newVersion, currentVersion: this.deployedProject.version.version, }; } return { hasUpdated: false, currentVersion: this.deployedProject.version.version, }; } static create( project: InfinityMintDeployedProject, client: InfinityMintClient ): Project { let newProject = new Project(project, client); return newProject; } /** * Will load a project from the local storage * @param projectName * @param projectVersion * @param client * @param network * @returns */ static load( projectName: string, projectVersion: string, client: InfinityMintClient, network?: string ): Project { network = network || client.defaultNetwork; let projectFullName = toProjectFullName( projectName, projectVersion, network ); let project = storageController.getGlobalPreference( projectFullName ) as InfinityMintDeployedProject; if (!project) return null; let res = new Project(project, client); res.source = 'localstorage'; return res; } } export const fetchProject = async ( projectName: string, projectVersion: string, network: string, client: InfinityMintClient, abortController?: AbortController, usePublic: boolean = false ): Promise => { abortController = abortController || new AbortController(); let config = getConfig(); let projectNameAndVersion = `${projectName}@${projectVersion}`; let { useInfinityMintApi, forcePublic } = config.projects; let url = `/projects/deployed/${projectNameAndVersion}_${network}.json`; if (!usePublic && !forcePublic && useInfinityMintApi) url = getApiEndpoint( `/projects/get?source=${projectName}&version=${projectVersion}&network=${network}` ); log('fetching project', url, { useInfinityMintApi, forcePublic, usePublic, }); let promise = new Promise((resolve, reject) => { fetch(url, { signal: abortController.signal, }) .then((response) => { if (!response.ok) reject( new Error( `Failed to fetch project ${projectNameAndVersion}_${network}` ) ); return response.json(); }) .then((project) => { let res = Project.create(project, client); res.source = useInfinityMintApi ? 'api' : 'public'; resolve(res); }) .catch((error) => { reject(error); }); }); try { return await promise; } catch (error) { if (error.name === 'AbortError') { return null; } if (useInfinityMintApi && !usePublic && !forcePublic) { return fetchProject( projectName, projectVersion, network, client, abortController, true ); } throw error; } }; export const hasSavedProject = ( projectName: string, projectVersion: string, projectNetwork: string ) => { let projectFullName = `${projectName}@${projectVersion}_${projectNetwork}`; let project = storageController.getGlobalPreference( projectFullName ) as InfinityMintDeployedProject; return !!project; };