-
Notifications
You must be signed in to change notification settings - Fork 8
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
VV Mint Ingestor #65
Open
pbkompasz
wants to merge
11
commits into
floornfts:main
Choose a base branch
from
pbkompasz:ingestor-vv
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
VV Mint Ingestor #65
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
6c05827
chore: test start
pbkompasz 5cb13f9
chore: udpate abi
pbkompasz 8efa25f
chore: add vv to list
pbkompasz 203b8ef
feat: onchain methods
pbkompasz 967507f
feat: ingestor v0.1
pbkompasz bee5a30
chore: remove console logs
pbkompasz ec5736f
fix: tests
pbkompasz be9d79e
fix: price calculations
pbkompasz b50701d
chore: temporary
pbkompasz f03e7cd
chore: fix comment
pbkompasz 438b93a
chore: add comments
pbkompasz File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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', | ||
}, | ||
]; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<boolean> { | ||
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<boolean> { | ||
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<MintTemplate> { | ||
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<MintTemplate> { | ||
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, | ||
}); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<string | undefined> => { | ||
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<number | undefined> => { | ||
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) {} | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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('<svg width="64" height="64"'); | ||
|
||
expect(template.marketingUrl).to.equal(url); | ||
expect(template.availableForPurchaseStart?.getTime()).to.equal(+new Date('2024-11-12T02:21:23+02:00')); | ||
expect(template.availableForPurchaseEnd?.getTime()).to.equal(+new Date('2024-11-13T02:21:23+02:00')); | ||
}); | ||
}); |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, this is not safe
I prepared this update to handle this scenario:
#48
I'm rebasing it against main now to see if it will merge cleanly & then you should be able to use it
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(This change was global to all ingestors & broke all the other Base ingestors 🤣 )
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I will update it after #48 is merged.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I will take a closer look and update the codebase, since opening this PR they implemented a couple of new features.
It looks like the contract's renderer has a JS script that decodes the nft's assets.