diff --git a/src/ingestors/index.ts b/src/ingestors/index.ts index fda2eac..6d15fe8 100644 --- a/src/ingestors/index.ts +++ b/src/ingestors/index.ts @@ -9,6 +9,7 @@ import { FoundationIngestor } from './foundation'; import { CoinbaseWalletIngestor } from './coinbase-wallet'; import { ZoraInternalIngestor } from './zora-internal'; import { RodeoIngestor } from './rodeo'; +import { VvIngestor } from './vv'; export type MintIngestionMap = { [key: string]: MintIngestor; @@ -24,7 +25,8 @@ export const ALL_MINT_INGESTORS: MintIngestionMap = { transient: new TransientIngestor(), highlight: new HighlightIngestor(), foundation: new FoundationIngestor(), - 'coinbase-wallet': new CoinbaseWalletIngestor() + 'coinbase-wallet': new CoinbaseWalletIngestor(), + vv: new VvIngestor(), }; export * from './'; diff --git a/src/ingestors/vv/abi.ts b/src/ingestors/vv/abi.ts new file mode 100644 index 0000000..542af5f --- /dev/null +++ b/src/ingestors/vv/abi.ts @@ -0,0 +1,48 @@ +export const MINT_CONTRACT_ABI = [ + { + inputs: [ + { internalType: 'uint256', name: 'tokenId', type: 'uint256' }, + { internalType: 'uint256', name: 'amount', type: 'uint256' }, + ], + name: 'mint', + outputs: [], + stateMutability: 'payable', + type: 'function', + }, + { + inputs: [{ internalType: 'uint256', name: 'tokenId', type: 'uint256' }], + name: 'get', + outputs: [ + { internalType: 'string', name: 'name', type: 'string' }, + { internalType: 'string', name: 'description', type: 'string' }, + { internalType: 'address[]', name: 'artifact', type: 'address[]' }, + { internalType: 'uint32', name: 'renderer', type: 'uint32' }, + { internalType: 'uint32', name: 'mintedBlock', type: 'uint32' }, + { internalType: 'uint64', name: 'closeAt', type: 'uint64' }, + { internalType: 'uint128', name: 'data', type: 'uint128' }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'latestTokenId', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'contractURI', + outputs: [{ internalType: 'string', name: '', type: 'string' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'owner', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, +]; diff --git a/src/ingestors/vv/index.ts b/src/ingestors/vv/index.ts new file mode 100644 index 0000000..7dea584 --- /dev/null +++ b/src/ingestors/vv/index.ts @@ -0,0 +1,150 @@ +import { MintContractOptions, MintIngestor, MintIngestorResources } from '../../lib/types/mint-ingestor'; +import { MintIngestionErrorName, MintIngestorError } from '../../lib/types/mint-ingestor-error'; +import { MintInstructionType, MintTemplate } from '../../lib/types/mint-template'; +import { MintTemplateBuilder } from '../../lib/builder/mint-template-builder'; +import { + getVvMintPriceInWei, + getVvLatestTokenId, + getVvCollectionCreator, + getVvCollectionMetadata, +} from './onchain-metadata'; +import { getVvCollection } from './onchain-metadata'; +import { MINT_CONTRACT_ABI } from './abi'; + +export class VvIngestor implements MintIngestor { + async supportsUrl(resources: MintIngestorResources, url: string): Promise { + const splitUrl = url.split('/'); + const tokenId = splitUrl.pop(); + const address = splitUrl.pop(); + if (!tokenId || !address) { + return false; + } + + const collection = await getVvCollection(resources.alchemy, address as string, +tokenId); + if (!collection) return false; + + const urlPattern = /^https:\/\/mint\.vv\.xyz\/0x[a-fA-F0-9]{40}\/\d+$/; + return ( + new URL(url).hostname === 'www.mint.vv.xyz' || new URL(url).hostname === 'mint.vv.xyz' || urlPattern.test(url) + ); + } + + async supportsContract(resources: MintIngestorResources, contractOptions: MintContractOptions): Promise { + if (!(contractOptions.chainId === 1 || contractOptions.chainId === 8453)) { + return false; + } + const collection = await getVvCollection( + resources.alchemy, + contractOptions.contractAddress, + contractOptions.tokenId ? +contractOptions.tokenId : undefined, + ); + if (!collection) { + return false; + } + return true; + } + + async createMintForContract( + resources: MintIngestorResources, + contractOptions: MintContractOptions, + ): Promise { + const mintBuilder = new MintTemplateBuilder() + .setMintInstructionType(MintInstructionType.EVM_MINT) + .setPartnerName('Highlight'); + + if (contractOptions.url) { + mintBuilder.setMarketingUrl(contractOptions.url); + } + + const { contractAddress } = contractOptions; + + // Use latestTokenId as default, see: https://docs.mint.vv.xyz/guide/contracts/mint#token-count + const tokenId = contractOptions.tokenId ?? (await getVvLatestTokenId(resources.alchemy, contractAddress)); + + const collection = await getVvCollection(resources.alchemy, contractAddress, tokenId ? +tokenId : undefined); + + if (!collection) { + throw new MintIngestorError(MintIngestionErrorName.CouldNotResolveMint, 'Collection not found'); + } + + const metadata = await getVvCollectionMetadata(resources.alchemy, contractAddress); + + if (!metadata) { + throw new MintIngestorError(MintIngestionErrorName.CouldNotResolveMint, 'Collection metadata not found'); + } + + mintBuilder.setName(metadata.name).setDescription(metadata.description).setFeaturedImageUrl(metadata.image); + mintBuilder.setMintOutputContract({ chainId: contractOptions.chainId ?? 1, address: contractAddress }); + + const creatorData = await getVvCollectionCreator(resources.alchemy, contractAddress); + + if (!creatorData) { + throw new MintIngestorError(MintIngestionErrorName.MissingRequiredData, 'Error finding creator'); + } + + const { creator, name: creatorName } = creatorData; + + mintBuilder.setCreator({ + name: creatorName || '', + walletAddress: creator, + }); + + mintBuilder.setMintOutputContract({ chainId: 1, address: contractAddress }); + + const totalPriceWei = await getVvMintPriceInWei(resources.alchemy, contractAddress, collection.mintedBlock); + + if (!totalPriceWei) { + throw new MintIngestorError(MintIngestionErrorName.MissingRequiredData, 'Price not available'); + } + + mintBuilder.setMintInstructions({ + chainId: contractOptions.chainId ?? 1, + contractAddress, + contractMethod: 'mint', + contractParams: `[${tokenId}, quantity]`, + abi: MINT_CONTRACT_ABI, + priceWei: totalPriceWei, + supportsQuantity: true, + }); + + const { closeAt } = collection; + + // Tokens are open to be minted for 24 hours after token creation. + const startTimestamp = new Date(closeAt * 1000 - 24 * 60 * 60 * 1000); + const liveDate = +new Date() > +startTimestamp ? new Date() : startTimestamp; + mintBuilder + .setAvailableForPurchaseStart(new Date(startTimestamp || Date.now())) + .setAvailableForPurchaseEnd(new Date(closeAt * 1000)) + .setLiveDate(liveDate); + + return mintBuilder.build(); + } + + async createMintTemplateForUrl(resources: MintIngestorResources, url: string): Promise { + const isCompatible = await this.supportsUrl(resources, url); + if (!isCompatible) { + throw new MintIngestorError(MintIngestionErrorName.IncompatibleUrl, 'Incompatible URL'); + } + + // Example URL: https://mint.vv.xyz/0xcb52f0fe1d559cd2869db7f29753e8951381b4a3/1 + const splits = url.split('/'); + const id = splits.pop(); + const contract = splits.pop(); + + if (!id || !contract) { + throw new MintIngestorError(MintIngestionErrorName.CouldNotResolveMint, 'Url error'); + } + + const collection = await getVvCollection(resources.alchemy, contract, +id); + + if (!collection) { + throw new MintIngestorError(MintIngestionErrorName.CouldNotResolveMint, 'No such collection'); + } + + return this.createMintForContract(resources, { + chainId: 1, + contractAddress: contract, + url, + }); + } +} diff --git a/src/ingestors/vv/onchain-metadata.ts b/src/ingestors/vv/onchain-metadata.ts new file mode 100644 index 0000000..dc70928 --- /dev/null +++ b/src/ingestors/vv/onchain-metadata.ts @@ -0,0 +1,89 @@ +import { Alchemy, AlchemyProvider, Contract } from 'alchemy-sdk'; +import { MINT_CONTRACT_ABI } from './abi'; + +const CONTRACT_ADDRESS = '0x8087039152c472Fa74F47398628fF002994056EA'; + +const getContract = async ( + alchemy: Alchemy, + contractAddress: string, +): Promise<{ contract: Contract; provider: AlchemyProvider }> => { + const ethersProvider = await alchemy.config.getProvider(); + const contract = new Contract(contractAddress, MINT_CONTRACT_ABI, ethersProvider); + return { + contract, + provider: ethersProvider, + }; +}; + +export const getVvMintPriceInWei = async ( + alchemy: Alchemy, + contractAddress: string, + blockNumber: number, +): Promise => { + try { + const { provider } = await getContract(alchemy, contractAddress); + const block = await provider.getBlock(blockNumber); + + const baseFeePerGas = block.baseFeePerGas; + if (!baseFeePerGas) { + throw new Error('Unable to fetch baseFee'); + } + + // Mint price is calculated as baseFee * 60_000, see: https://docs.mint.vv.xyz/guide/contracts/mint#purchasing-tokens + return `${+baseFeePerGas * 60000}`; + } catch (error) {} +}; + +export const getVvLatestTokenId = async (alchemy: Alchemy, contractAddress: string): Promise => { + try { + const { contract } = await getContract(alchemy, contractAddress); + const tokenId = await contract.functions.latestTokenId(); + return +tokenId; + } catch (error) {} +}; + +export const getVvCollection = async (alchemy: Alchemy, contractAddress: string, vectorId?: number) => { + try { + const { contract } = await getContract(alchemy, contractAddress); + // Use latestTokenId as default, see: https://docs.mint.vv.xyz/guide/contracts/mint#token-count + const collection = await contract.functions.get(vectorId ?? (await getVvLatestTokenId(alchemy, contractAddress))); + const { name, description, artifact, renderer, mintedBlock, closeAt, data } = collection; + return { name, description, artifact, renderer, mintedBlock, closeAt, data }; + } catch (error) {} +}; + +export const getVvCollectionMetadata = async (alchemy: Alchemy, contractAddress: string, vectorId?: number) => { + try { + const { contract } = await getContract(alchemy, contractAddress); + const uri = await contract.functions.contractURI(); + + // Decode base64 + const rawContent = uri[0].split(',')[1]; + let jsonString = atob(rawContent); + + const { name, symbol, description, image: imageBase64 } = JSON.parse(jsonString); + + // Decode again image + const rawImage = imageBase64.split(',')[1]; + + // Image is stored as a svg in the contract + const image = atob(rawImage); + + return { name, symbol, description, image }; + } catch (error) {} +}; + +export const getVvCollectionCreator = async (alchemy: Alchemy, contractAddress: string) => { + try { + const { contract, provider } = await getContract(alchemy, contractAddress); + const owner = await contract.functions.owner(); + + // Lookup ens name for creator + const name = await provider.lookupAddress(owner[0]); + + return { + creator: owner[0], + name, + }; + } catch (error) {} +}; diff --git a/src/lib/resources.ts b/src/lib/resources.ts index 04daa10..3d979be 100644 --- a/src/lib/resources.ts +++ b/src/lib/resources.ts @@ -9,7 +9,7 @@ export const mintIngestorResources = (): MintIngestorResources => { } const settings = { apiKey: ALCHEMY_API_KEY, - network: Network.BASE_MAINNET, // Replace with the correct network + network: Network.ETH_MAINNET, // Replace with the correct network }; const alchemy = new Alchemy(settings); diff --git a/test/ingestors/vv.test.ts b/test/ingestors/vv.test.ts new file mode 100644 index 0000000..8abd525 --- /dev/null +++ b/test/ingestors/vv.test.ts @@ -0,0 +1,80 @@ +import { expect } from 'chai'; +import { VvIngestor } from '../../src/ingestors/vv'; +import { mintIngestorResources } from '../../src/lib/resources'; +import { EVMMintInstructions } from '../../src/lib/types/mint-template'; +import { MintTemplateBuilder } from '../../src/lib/builder/mint-template-builder'; +import { basicIngestorTests } from '../shared/basic-ingestor-tests'; + +const resources = mintIngestorResources(); + +describe('vv', function () { + basicIngestorTests(new VvIngestor(), resources, { + successUrls: ['https://mint.vv.xyz/0xcb52f0fe1d559cd2869db7f29753e8951381b4a3/1'], + failureUrls: [ + 'https://mint.vv.xyz/0xcb52f0fe1d559cd2869db7f29753e8951381b4a3', + 'https://foundation.app/mint/eth/0xcc5C8eb0108d85f091e4468999E0D6fd4273eD99', + ], + successContracts: [{ chainId: 1, contractAddress: '0xcb52f0fe1d559cd2869db7f29753e8951381b4a3' }], + failureContracts: [], + }); + it('supportsUrl: Returns false for an unsupported URL', async function () { + const ingestor = new VvIngestor(); + const url = 'https://example.com'; + const resources = mintIngestorResources(); + const result = await ingestor.supportsUrl(resources, url); + expect(result).to.be.false; + }); + + it('supportsUrl: Returns true for a supported URL', async function () { + const ingestor = new VvIngestor(); + const url = 'https://mint.vv.xyz/0xcb52f0fe1d559cd2869db7f29753e8951381b4a3/1'; + const resources = mintIngestorResources(); + const result = await ingestor.supportsUrl(resources, url); + expect(result).to.be.true; + }); + + // TODO Contract works for latest token id + it('supportsContract: Returns true for a supported contract', async function () { + const ingestor = new VvIngestor(); + const resources = mintIngestorResources(); + const contract = { + chainId: 1, + contractAddress: '0xcb52f0fe1d559cd2869db7f29753e8951381b4a3', + }; + + const supported = await ingestor.supportsContract(resources, contract); + expect(supported).to.be.true; + }); + + it('createMintTemplateForUrl: Returns a mint template for a supported URL with frame contract', async function () { + const ingestor = new VvIngestor(); + const url = 'https://mint.vv.xyz/0xcb52f0fe1d559cd2869db7f29753e8951381b4a3/1'; + const resources = mintIngestorResources(); + const template = await ingestor.createMintTemplateForUrl(resources, url); + + // Verify that the mint template passed validation + const builder = new MintTemplateBuilder(template); + builder.validateMintTemplate(); + + expect(template.name).to.equal('Artifacts'); + expect(template.description).to.contain('To mint is a human right.'); + const mintInstructions = template.mintInstructions as EVMMintInstructions; + + expect(mintInstructions.contractAddress).to.equal('0xcb52f0fe1d559cd2869db7f29753e8951381b4a3'); + expect(mintInstructions.contractMethod).to.equal('mint'); + expect(mintInstructions.contractParams).to.equal('[1, quantity]'); + + // Gas price at block #21167990 + expect(mintInstructions.priceWei).to.equal(String(60000 * 39874264171)); + + expect(template.creator?.name).to.equal('visualizevalue.eth'); + expect(template.creator?.walletAddress).to.equal('0xc8f8e2F59Dd95fF67c3d39109ecA2e2A017D4c8a'); + + // Image is stored onchain as an svg + expect(template.featuredImageUrl).to.contain('