Skip to content
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
wants to merge 11 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/ingestors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 './';
48 changes: 48 additions & 0 deletions src/ingestors/vv/abi.ts
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',
},
];
150 changes: 150 additions & 0 deletions src/ingestors/vv/index.ts
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,
});
}
}
89 changes: 89 additions & 0 deletions src/ingestors/vv/onchain-metadata.ts
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) {}
};
2 changes: 1 addition & 1 deletion src/lib/resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Contributor

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

Copy link
Contributor

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 🤣 )

Copy link
Contributor Author

@pbkompasz pbkompasz Dec 6, 2024

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.

Copy link
Contributor Author

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.

};
const alchemy = new Alchemy(settings);

Expand Down
80 changes: 80 additions & 0 deletions test/ingestors/vv.test.ts
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'));
});
});