import { timeoutFetch } from 'utils/net.util' import { getFormattedIpfsUrl } from 'utils/wallet.util' import Web3 from 'web3' import { Contract } from 'web3-eth-contract' import { Account } from 'web3-core' export const ERC721 = 'ERC721' export const ERC721_INTERFACE_ID = '0x80ac58cd' export const ERC721_METADATA_INTERFACE_ID = '0x5b5e139f' export const ERC721_ENUMERABLE_INTERFACE_ID = '0x780e9d63' const abiNft = require('abis/BEBadge.json').abi export class ERC721Reactor { private web3: Web3 private contract: Contract private account: Account constructor({ web3, address }: { web3: Web3; address: string }) { this.web3 = web3 this.account = this.web3.eth.accounts.wallet[0] this.contract = new this.web3.eth.Contract(abiNft, address, { from: this.account.address }) } /** * Query if contract implements ERC721Metadata interface. * * @param address - ERC721 asset contract address. * @returns Promise resolving to whether the contract implements ERC721Metadata interface. */ contractSupportsMetadataInterface = async (address?: string): Promise => { return this.contractSupportsInterface({ address, interfaceId: ERC721_METADATA_INTERFACE_ID }) } /** * Query if contract implements ERC721Enumerable interface. * * @param address - ERC721 asset contract address. * @returns Promise resolving to whether the contract implements ERC721Enumerable interface. */ contractSupportsEnumerableInterface = async (address?: string): Promise => { return this.contractSupportsInterface({ address, interfaceId: ERC721_ENUMERABLE_INTERFACE_ID }) } /** * Query if contract implements ERC721 interface. * * @param address - ERC721 asset contract address. * @returns Promise resolving to whether the contract implements ERC721 interface. */ contractSupportsBase721Interface = async (address?: string): Promise => { return this.contractSupportsInterface({ address, interfaceId: ERC721_INTERFACE_ID }) } /** * Enumerate assets assigned to an owner. * * @param address - ERC721 asset contract address. * @param selectedAddress - Current account public address. * @param index - A collectible counter less than `balanceOf(selectedAddress)`. * @returns Promise resolving to token identifier for the 'index'th asset assigned to 'selectedAddress'. */ getCollectibleTokenId = async ({ address, selectedAddress, index, }: { address?: string selectedAddress: string index: number }): Promise => { const contract = address ? new this.web3.eth.Contract(abiNft, address, { from: this.account.address }) : this.contract return new Promise((resolve, reject) => { contract.methods.tokenOfOwnerByIndex(selectedAddress, index).call((error: Error, result: string) => { /* istanbul ignore if */ if (error) { reject(error) return } resolve(result) }) }) } getBalance = async ({ address, selectedAddress }: { address?: string; selectedAddress: string }): Promise => { const contract = address ? new this.web3.eth.Contract(abiNft, address, { from: this.account.address }) : this.contract return new Promise((resolve, reject) => { contract.methods.balanceOf(selectedAddress).call((error: Error, result: number) => { /* istanbul ignore if */ if (error) { reject(error) return } resolve(result) }) }) } /** * Query for tokenURI for a given asset. * * @param address - ERC721 asset contract address. * @param tokenId - ERC721 asset identifier. * @returns Promise resolving to the 'tokenURI'. */ getTokenURI = async ({ address, tokenId }: { address?: string; tokenId: string }): Promise => { const contract = address ? new this.web3.eth.Contract(abiNft, address, { from: this.account.address }) : this.contract const supportsMetadata = await this.contractSupportsMetadataInterface(address) if (!supportsMetadata) { throw new Error('Contract does not support ERC721 metadata interface.') } return new Promise((resolve, reject) => { contract.methods.tokenURI(tokenId).call((error: Error, result: string) => { /* istanbul ignore if */ if (error) { reject(error) return } resolve(result) }) }) } /** * Query for name for a given asset. * * @param address - ERC721 asset contract address. * @returns Promise resolving to the 'name'. */ getAssetName = async (address?: string): Promise => { const contract = address ? new this.web3.eth.Contract(abiNft, address, { from: this.account.address }) : this.contract return new Promise((resolve, reject) => { contract.methods.name().call((error: Error, result: string) => { /* istanbul ignore if */ if (error) { reject(error) return } resolve(result) }) }) } /** * Query for symbol for a given asset. * * @param address - ERC721 asset contract address. * @returns Promise resolving to the 'symbol'. */ getAssetSymbol = async (address?: string): Promise => { const contract = address ? new this.web3.eth.Contract(abiNft, address, { from: this.account.address }) : this.contract return new Promise((resolve, reject) => { contract.methods.symbol().call((error: Error, result: string) => { /* istanbul ignore if */ if (error) { reject(error) return } resolve(result) }) }) } /** * Query for owner for a given ERC721 asset. * * @param address - ERC721 asset contract address. * @param tokenId - ERC721 asset identifier. * @returns Promise resolving to the owner address. */ async getOwnerOf({ address, tokenId }: { address?: string; tokenId: string }): Promise { const contract = address ? new this.web3.eth.Contract(abiNft, address, { from: this.account.address }) : this.contract return new Promise((resolve, reject) => { contract.methods.ownerOf(tokenId).call((error: Error, result: string) => { /* istanbul ignore if */ if (error) { reject(error) return } resolve(result) }) }) } /** * Query if a contract implements an interface. * * @param address - Asset contract address. * @param interfaceId - Interface identifier. * @returns Promise resolving to whether the contract implements `interfaceID`. */ private contractSupportsInterface = async ({ address, interfaceId, }: { address?: string interfaceId: string }): Promise => { const contract = address ? new this.web3.eth.Contract(abiNft, address, { from: this.account.address }) : this.contract return new Promise((resolve, reject) => { contract.methods.supportsInterface(interfaceId).call((error: Error, result: boolean) => { /* istanbul ignore if */ if (error) { reject(error) return } resolve(result) }) }) } /** * Query if a contract implements an interface. * * @param address - Asset contract address. * @param ipfsGateway - The user's preferred IPFS gateway. * @param tokenId - tokenId of a given token in the contract. * @returns Promise resolving an object containing the standard, tokenURI, symbol and name of the given contract/tokenId pair. */ getDetails = async ({ address, ipfsGateway, tokenId, }: { address?: string ipfsGateway: string tokenId?: string }): Promise<{ standard: string tokenURI: string | undefined symbol: string | undefined name: string | undefined image: string | undefined }> => { const isERC721 = await this.contractSupportsBase721Interface(address) if (!isERC721) { throw new Error("This isn't a valid ERC721 contract") } let tokenURI, image, symbol, name // TODO upgrade to use Promise.allSettled for name/symbol when we can refactor to use es2020 in tsconfig try { symbol = await this.getAssetSymbol(address) } catch { // ignore } try { name = await this.getAssetName(address) } catch { // ignore } if (tokenId) { try { tokenURI = await this.getTokenURI({ address, tokenId }) if (tokenURI.startsWith('ipfs://')) { tokenURI = getFormattedIpfsUrl(ipfsGateway, tokenURI, true) } const response = await timeoutFetch(tokenURI) const object = await response.json() image = object ? object.image : '' if (image.startsWith('ipfs://')) { image = getFormattedIpfsUrl(ipfsGateway, image, true) } } catch { // ignore } } return { standard: ERC721, tokenURI, symbol, name, image, } } async transfer({ address, from, to, tokenId, account, gas, encodeABI = false, }: { address?: string from: string to: string tokenId: string account?: string gas?: number encodeABI?: boolean }) { const contract = address ? new this.web3.eth.Contract(abiNft, address, { from: account || this.account.address }) : this.contract if (encodeABI) { return contract.methods.safeTransferFrom(from, to, tokenId).encodeABI() } return contract.methods.safeTransferFrom(from, to, tokenId).send({ from, gas: gas || 1000000, }) } async mint({ address, to, tokenId, encodeABI = false, }: { address?: string to: string tokenId: string encodeABI?: boolean }) { const contract = address ? new this.web3.eth.Contract(abiNft, address, { from: this.account.address }) : this.contract if (encodeABI) { return contract.methods.mint(to, tokenId).encodeABI() } let gas = await contract.methods.mint(to, tokenId).estimateGas({ gas: 1000000 }) return contract.methods.mint(to, tokenId).send({ gas: gas | 0 }) } async batchMint({ account, address, to, count, encodeABI = false, }: { account?: string address?: string to: string count: number encodeABI?: boolean }) { const contract = address ? new this.web3.eth.Contract(abiNft, address, { from: account || this.account.address }) : this.contract const countStr = count + '' if (encodeABI) { return contract.methods.batchMint(to, countStr).encodeABI() } let gas = await contract.methods.batchMint(to, countStr).estimateGas({ from: account || this.account.address }) return contract.methods.batchMint(to, countStr).send({ gas: gas | 0 }) } async getPastEvents({ address, fromBlock }: { address?: string; fromBlock: number }) { const contract = address ? new this.web3.eth.Contract(abiNft, address, { from: this.account.address }) : this.contract return contract.getPastEvents('BatchMint', { fromBlock, toBlock: fromBlock + 50000, }) } }