diff --git a/src/ingestors/highlight/abi.ts b/src/ingestors/highlight/abi.ts new file mode 100644 index 0000000..b0761fa --- /dev/null +++ b/src/ingestors/highlight/abi.ts @@ -0,0 +1,42 @@ +export const MINT_CONTRACT_ABI = [ + { + inputs: [{ internalType: 'uint256', name: 'vectorId', type: 'uint256' }], + name: 'getAbridgedVector', + outputs: [ + { + components: [ + { internalType: 'address', name: 'contractAddress', type: 'address' }, + { internalType: 'uint48', name: 'startTimestamp', type: 'uint48' }, + { internalType: 'uint48', name: 'endTimestamp', type: 'uint48' }, + { internalType: 'address', name: 'paymentRecipient', type: 'address' }, + { internalType: 'uint48', name: 'maxTotalClaimableViaVector', type: 'uint48' }, + { internalType: 'uint48', name: 'totalClaimedViaVector', type: 'uint48' }, + { internalType: 'address', name: 'currency', type: 'address' }, + { internalType: 'uint48', name: 'tokenLimitPerTx', type: 'uint48' }, + { internalType: 'uint48', name: 'maxUserClaimableViaVector', type: 'uint48' }, + { internalType: 'uint192', name: 'pricePerToken', type: 'uint192' }, + { internalType: 'uint48', name: 'editionId', type: 'uint48' }, + { internalType: 'bool', name: 'editionBasedCollection', type: 'bool' }, + { internalType: 'bool', name: 'requireDirectEOA', type: 'bool' }, + { internalType: 'bytes32', name: 'allowlistRoot', type: 'bytes32' }, + ], + internalType: 'struct IAbridgedMintVector.AbridgedVector', + name: '', + type: 'tuple', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { internalType: 'uint256', name: 'vectorId', type: 'uint256' }, + { internalType: 'uint48', name: 'numTokensToMint', type: 'uint48' }, + { internalType: 'address', name: 'mintRecipient', type: 'address' }, + ], + name: 'vectorMint721', + outputs: [], + stateMutability: 'payable', + type: 'function', + }, +]; diff --git a/src/ingestors/highlight/index.ts b/src/ingestors/highlight/index.ts new file mode 100644 index 0000000..07ee59a --- /dev/null +++ b/src/ingestors/highlight/index.ts @@ -0,0 +1,156 @@ +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 { getHighlightMetadata, getHighlightMintPriceInWei } from './onchain-metadata'; +import { + getHighlightCollectionByAddress, + getHighlightCollectionById, + getHighlightCollectionOwnerDetails, + getHighlightVectorId, +} from './offchain-metadata'; +import { MINT_CONTRACT_ABI } from './abi'; + +const CONTRACT_ADDRESS = '0x8087039152c472Fa74F47398628fF002994056EA'; + +export class HighlightIngestor implements MintIngestor { + async supportsUrl(resources: MintIngestorResources, url: string): Promise { + const id = url.split('/').pop(); + if (!id) { + return false; + } + + const collection = await getHighlightCollectionById(resources, id); + + if (!collection || collection.chainId !== 8453) { + return false; + } + + const urlPattern = /^https:\/\/highlight\.xyz\/mint\/[a-f0-9]{24}$/; + return ( + new URL(url).hostname === 'www.highlight.xyz' || new URL(url).hostname === 'highlight.xyz' || urlPattern.test(url) + ); + } + + async supportsContract(resources: MintIngestorResources, contractOptions: MintContractOptions): Promise { + if (contractOptions.chainId !== 8453) { + return false; + } + const collection = await getHighlightCollectionByAddress(resources, contractOptions); + 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 collection = await getHighlightCollectionByAddress(resources, contractOptions); + + if (!collection) { + throw new MintIngestorError(MintIngestionErrorName.CouldNotResolveMint, 'Collection not found'); + } + + const contractAddress = collection.contract; + const description = collection?.description; + + mintBuilder + .setName(collection.name) + .setDescription(description) + .setFeaturedImageUrl(collection.image.split('?')[0]); + mintBuilder.setMintOutputContract({ chainId: 8453, address: contractAddress }); + + if (collection.sampleImages.length) { + collection.sampleImages.forEach((url, index) => { + mintBuilder.addImage(url, `Sample image #${index}`); + }); + } + + if (!collection.creator) { + throw new MintIngestorError(MintIngestionErrorName.MissingRequiredData, 'Error finding creator'); + } + + const creator = await getHighlightCollectionOwnerDetails(resources, collection.highlightCollection.id); + + mintBuilder.setCreator({ + name: creator.creatorAccountSettings.displayName, + walletAddress: collection.creator, + imageUrl: creator.creatorAccountSettings.displayAvatar, + }); + + const vectorId = await getHighlightVectorId(resources, collection.highlightCollection.id); + + if (!vectorId) { + throw new MintIngestorError(MintIngestionErrorName.MissingRequiredData, 'Id not available'); + } + + const totalPriceWei = await getHighlightMintPriceInWei(+vectorId, resources.alchemy); + + if (!totalPriceWei) { + throw new MintIngestorError(MintIngestionErrorName.MissingRequiredData, 'Price not available'); + } + + mintBuilder.setMintInstructions({ + chainId: 8453, + contractAddress: CONTRACT_ADDRESS, + contractMethod: 'vectorMint721', + contractParams: `[${vectorId}, 1, address]`, + abi: MINT_CONTRACT_ABI, + priceWei: totalPriceWei, + }); + + const metadata = await getHighlightMetadata(+vectorId, resources.alchemy); + + if (!metadata) { + throw new MintIngestorError(MintIngestionErrorName.MissingRequiredData, 'Missing timestamps'); + } + + const { startTimestamp, endTimestamp } = metadata; + + const liveDate = +new Date() > startTimestamp * 1000 ? new Date() : new Date(startTimestamp * 1000); + mintBuilder + .setAvailableForPurchaseStart(new Date(startTimestamp * 1000 || Date.now())) + .setAvailableForPurchaseEnd(new Date(endTimestamp * 1000 || '2030-01-01')) + .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://highlight.xyz/mint/665fa33f07b3436991e55632 + const splits = url.split('/'); + const id = splits.pop(); + const chain = splits.pop(); + + if (!id) { + throw new MintIngestorError(MintIngestionErrorName.CouldNotResolveMint, 'Url error'); + } + + const collection = await getHighlightCollectionById(resources, id); + + if (!collection) { + throw new MintIngestorError(MintIngestionErrorName.CouldNotResolveMint, 'No such collection'); + } + + return this.createMintForContract(resources, { + chainId: collection.chainId, + contractAddress: collection.address, + url, + }); + } +} diff --git a/src/ingestors/highlight/offchain-metadata.ts b/src/ingestors/highlight/offchain-metadata.ts new file mode 100644 index 0000000..001cac1 --- /dev/null +++ b/src/ingestors/highlight/offchain-metadata.ts @@ -0,0 +1,160 @@ +import { MintContractOptions, MintIngestorResources } from 'src/lib'; +import { Collection, CollectionByAddress, CollectionByAddress1 } from './types'; + +export const getHighlightCollectionById = async ( + resources: MintIngestorResources, + id: string, +): Promise => { + const url = 'https://api.highlight.xyz:8080/'; + + const headers = { + accept: 'application/json', + 'content-type': 'application/json', + }; + + const data = { + operationName: 'GetCollectionDetails', + variables: { + collectionId: id, + }, + query: ` + query GetCollectionDetails($collectionId: String!) { + getPublicCollectionDetails(collectionId: $collectionId) { + id + name + description + collectionImage + marketplaceId + accountId + address + symbol + chainId + status + baseUri + onChainBaseUri + } + } + `, + }; + + try { + const resp = await resources.fetcher.post(url, data, { headers }); + if (!resp.data.data) { + throw new Error('Empty response'); + } + return resp.data.data.getPublicCollectionDetails; + } catch (error) {} +}; + +export const getHighlightCollectionByAddress = async ( + resources: MintIngestorResources, + contractOptioons: MintContractOptions, +): Promise => { + try { + const resp = await resources.fetcher( + `https://marketplace.highlight.xyz/reservoir/base/collections/v7?id=${contractOptioons.contractAddress}&normalizeRoyalties=false`, + ); + const collection1: CollectionByAddress1 = resp.data.collections.find((c: CollectionByAddress) => { + return c.id.toLowerCase() === contractOptioons.contractAddress.toLowerCase() && c.chainId === 8453; + }); + + const resp2 = await resources.fetcher( + `https://marketplace.highlight.xyz/reservoir/base/tokens/v7?collection=${contractOptioons.contractAddress}&limit=1&normalizeRoyalties=false`, + ); + const collection2 = resp2.data.tokens[0]; + return { + ...collection1, + ...collection2, + }; + } catch (error) {} +}; + +export const getHighlightVectorId = async (resources: MintIngestorResources, id: string): Promise => { + const url = 'https://api.highlight.xyz:8080/'; + + const headers = { + accept: 'application/json', + 'content-type': 'application/json', + }; + + const data = { + operationName: 'GetCollectionSaleDetails', + variables: { + collectionId: id, + }, + query: ` + query GetCollectionSaleDetails($collectionId: String!) { + getPublicCollectionDetails(collectionId: $collectionId) { + size + mintVectors { + name + start + end + paused + price + currency + chainId + paymentCurrency { + address + decimals + symbol + type + mintFee + } + onchainMintVectorId + } + } + } + `, + }; + + try { + const resp = await resources.fetcher.post(url, data, { headers }); + const vectorString = resp.data.data.getPublicCollectionDetails.mintVectors.find( + (c: { chainId: number }) => c.chainId === 8453, + ).onchainMintVectorId; + const vectorId = vectorString.split(':').pop(); + return vectorId; + } catch (error) {} +}; + +export const getHighlightCollectionOwnerDetails = async (resources: MintIngestorResources, id: string) => { + const url = 'https://api.highlight.xyz:8080/'; + const data = { + operationName: 'GetCollectionCreatorDetails', + variables: { + withEns: true, + collectionId: id, + }, + query: `query GetCollectionCreatorDetails($collectionId: String!, $withEns: Boolean) { + getPublicCollectionDetails(collectionId: $collectionId) { + id + creatorAddresses { + address + name + } + creatorEns + creatorAccountSettings(withEns: $withEns) { + verified + imported + displayAvatar + displayName + walletAddresses + } + } + }`, + }; + + const headers = { + accept: 'application/json', + 'content-type': 'application/json', + }; + + try { + const resp = await resources.fetcher.post(url, data, { headers }); + if (resp.data.errors) { + throw new Error("Error fetching owner"); + } + return resp.data.data.getPublicCollectionDetails; + } catch (error) {} +}; diff --git a/src/ingestors/highlight/onchain-metadata.ts b/src/ingestors/highlight/onchain-metadata.ts new file mode 100644 index 0000000..4ce5524 --- /dev/null +++ b/src/ingestors/highlight/onchain-metadata.ts @@ -0,0 +1,37 @@ +import { Alchemy, Contract } from 'alchemy-sdk'; +import { MINT_CONTRACT_ABI } from './abi'; + +const CONTRACT_ADDRESS = '0x8087039152c472Fa74F47398628fF002994056EA'; + +const getContract = async (alchemy: Alchemy): Promise => { + const ethersProvider = await alchemy.config.getProvider(); + const contract = new Contract(CONTRACT_ADDRESS, MINT_CONTRACT_ABI, ethersProvider); + return contract; +}; + +export const getHighlightMintPriceInWei = async (vectorId: number, alchemy: Alchemy): Promise => { + try { + const contract = await getContract(alchemy); + const data = await contract.functions.getAbridgedVector(vectorId); + const { pricePerToken } = data[0]; + + const fee = 800000000000000; + const totalFee = parseInt(pricePerToken.toString()) + fee; + + return `${totalFee}`; + } catch (error) { + console.log(error); + } +}; + +export const getHighlightMetadata = async ( + vectorId: number, + alchemy: Alchemy, +): Promise<{ startTimestamp: number; endTimestamp: number } | undefined> => { + try { + const contract = await getContract(alchemy); + const metadata = await contract.functions.getAbridgedVector(vectorId); + const { startTimestamp, endTimestamp } = metadata[0]; + return { startTimestamp, endTimestamp }; + } catch (error) {} +}; diff --git a/src/ingestors/highlight/types.ts b/src/ingestors/highlight/types.ts new file mode 100644 index 0000000..1265c14 --- /dev/null +++ b/src/ingestors/highlight/types.ts @@ -0,0 +1,42 @@ +export type Collection = { + id: string; + name: string; + description: string; + collectionImage: string; + marketplaceId: string; + accountId: string; + address: string; + symbol: string; + chainId: number; + status: string; + baseUri: string, +}; + +export type CollectionByAddress1 = { + chainId: number; + id: string; + createdAt: string; + updatedAt: string; + contractDeployedAt: string; + name: string; + image: string; + symbol: string; + description: string; + sampleImages: string[]; + creator: string; +} + +export type CollectionByAddress2 = { + chainId: number; + contract: string; + highlightCollection: { + // Vector id + id: string; + name: string; + owner: string; + imageUrl: string; + animationUrl: string; + } +} + +export type CollectionByAddress = CollectionByAddress1 & CollectionByAddress2 diff --git a/src/ingestors/index.ts b/src/ingestors/index.ts index dc051fd..d21ff5e 100644 --- a/src/ingestors/index.ts +++ b/src/ingestors/index.ts @@ -1,6 +1,7 @@ -import { FxHashIngestor } from './fxhash'; import { MintIngestor } from '../lib/types/mint-ingestor'; import { ProhibitionDailyIngestor } from './prohibition-daily'; +import { FxHashIngestor } from './fxhash'; +import { HighlightIngestor } from './highlight'; import { TransientIngestor } from './transient-base'; export type MintIngestionMap = { @@ -10,6 +11,7 @@ export type MintIngestionMap = { export const ALL_MINT_INGESTORS: MintIngestionMap = { 'prohibition-daily': new ProhibitionDailyIngestor(), fxhash: new FxHashIngestor(), + highlight: new HighlightIngestor(), transient: new TransientIngestor(), }; diff --git a/test/ingestors/highlight.test.ts b/test/ingestors/highlight.test.ts new file mode 100644 index 0000000..2e9eb60 --- /dev/null +++ b/test/ingestors/highlight.test.ts @@ -0,0 +1,119 @@ +import { expect } from 'chai'; +import { HighlightIngestor } from '../../src/ingestors/highlight'; +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('highlight', function () { + basicIngestorTests(new HighlightIngestor(), resources, { + successUrls: [ + 'https://highlight.xyz/mint/665fa33f07b3436991e55632', + 'https://highlight.xyz/mint/66856628ff8a01fdccc132f4', + ], + failureUrls: [ + 'https://highlight.xyz/mint/66963c500b48236f1acf322b', + 'https://foundation.app/mint/base/the-billows', + ], + successContracts: [ + { chainId: 8453, contractAddress: '0x0E5DDe3De7cf2761d8a81Ee68F48410425e2dBbA' }, + { chainId: 8453, contractAddress: '0xBE96B2572CA0F1ac8ec6323Bc9037AffD270bA7F' }, + ], + failureContracts: [{ chainId: 5000, contractAddress: '0x62F8C536De24ED32611f128f64F6eAbd9b82176c' }], + }); + it('supportsUrl: Returns false for an unsupported URL', async function () { + const ingestor = new HighlightIngestor(); + 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 HighlightIngestor(); + const url = 'https://highlight.xyz/mint/66966e909562252851632a96'; + const resources = mintIngestorResources(); + const result = await ingestor.supportsUrl(resources, url); + expect(result).to.be.true; + + const url2 = 'https://highlight.xyz/mint/66966e909562252851632a96'; + const result2 = await ingestor.supportsUrl(resources, url2); + expect(result2).to.be.true; + }); + + it('createMintTemplateForUrl: Returns a mint template for a supported URL', async function () { + const ingestor = new HighlightIngestor(); + const url = 'https://highlight.xyz/mint/665fa33f07b3436991e55632'; + 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('COMBAT MODE by Emily Xie'); + expect(template.description).to.contain( + 'It depicts two creatures in battle, melding the nostalgia of old school video games with the contemporary possibilities of digital illustration.', + ); + const mintInstructions = template.mintInstructions as EVMMintInstructions; + + expect(mintInstructions.contractAddress).to.equal('0x8087039152c472Fa74F47398628fF002994056EA'); + expect(mintInstructions.contractMethod).to.equal('vectorMint721'); + expect(mintInstructions.contractParams).to.equal('[866, 1, address]'); + expect(mintInstructions.priceWei).to.equal('2100000000000000'); + + expect(template.featuredImageUrl).to.equal( + 'https://img.reservoir.tools/images/v2/base/z9JRSpLYGu7%2BCZoKWtAuANCXTgWBry4OTpgBkNYv7UVX%2FOELQ1B1IQGOoFgJBPmEzWQJa5hKPeiypcjXnSgXVEhZJDeOg9vk5slunBxp8ABMKIlkw3COL8nejLu9cx7f5QrJHJecqNaXIZCHlWY311DY%2F4e9zjeJnyY%2Fvp3J%2FivCSdJShfdu2%2FoCfqed8TvVTrlrElK7Wp8owCwKnZNhaw%3D%3D', + ); + + if (template.creator) { + expect(template.creator.name).to.equal('Emily Xie'); + expect(template.creator.walletAddress).to.equal('0x591a0b1994e8880215b89c5b9cd8d0738e5c0f1e'); + expect(template.creator.imageUrl).to.equal( + 'https://highlight-creator-assets.highlight.xyz/main/image/91eaf712-b9de-49e4-8674-85f37dd823e0.png', + ); + } + + expect(template.marketingUrl).to.equal(url); + expect(template.availableForPurchaseStart?.getTime()).to.equal(+new Date('2024-06-05T16:00:00.000Z')); + expect(template.availableForPurchaseEnd?.getTime()).to.equal(+new Date('2024-08-31T16:00:00.000Z')); + }); + + it('createMintTemplateForUrl: Returns a mint template for a supported URL with free price', async function () { + const ingestor = new HighlightIngestor(); + const url = 'https://highlight.xyz/mint/66744e64e610ed36adeb1a64'; + 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('RGB Friends'); + expect(template.description).to.contain('RGB Friends is an infinite, generative PFP collection'); + const mintInstructions = template.mintInstructions as EVMMintInstructions; + + expect(mintInstructions.contractAddress).to.equal('0x8087039152c472Fa74F47398628fF002994056EA'); + expect(mintInstructions.contractMethod).to.equal('vectorMint721'); + expect(mintInstructions.contractParams).to.equal('[977, 1, address]'); + expect(mintInstructions.priceWei).to.equal('800000000000000'); + + expect(template.featuredImageUrl).to.equal( + 'https://img.reservoir.tools/images/v2/base/z9JRSpLYGu7%2BCZoKWtAuAKM5v2dthdDNgoFYsopVhfXBHjSfVbMXHiaW1XsdogS5oNzhOcvyJcxoIKiiKqHsNxiXyJX%2B%2BppNtkeQvHYCslZTqG21HhITlOtTV8jhhZhOQdWST4CHb1DA%2B5K8ZAHTSu9b0MV4dWJJsqPVJ439DhVcURxmw1fKJ4pAhC3iCwl1DOXK1xnEOnLO0il04rMAPA%3D%3D', + ); + + if (template.creator) { + expect(template.creator.name).to.equal('RGB'); + expect(template.creator.walletAddress).to.equal('0xf3e2399c5d1c698a6c1dfa195adbd12a6afd1899'); + expect(template.creator.imageUrl).to.equal( + 'https://highlight-creator-assets.highlight.xyz/main/image/47cfb18b-a189-41f2-8013-1cee51c0c08d.png', + ); + } + + expect(template.marketingUrl).to.equal(url); + expect(template.availableForPurchaseStart?.getTime()).to.equal(+new Date('2024-06-20T17:00:04.000Z')); + expect(template.availableForPurchaseEnd?.getTime()).to.equal(+new Date('2030-01-01T00:00:00.000Z')); + }); +});