Skip to content

Commit

Permalink
Merge pull request #34 from pbkompasz/ingestor-coinbase-wallet
Browse files Browse the repository at this point in the history
Ingestor wallet.coinbase
  • Loading branch information
chrismaddern authored Aug 28, 2024
2 parents 6ffcdc8 + 679eac7 commit 63dc472
Show file tree
Hide file tree
Showing 7 changed files with 361 additions and 0 deletions.
41 changes: 41 additions & 0 deletions src/ingestors/coinbase-wallet/abi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
export const MINT_CONTRACT_ABI = [
{
inputs: [
{ internalType: 'address', name: 'to', type: 'address' },
{ internalType: 'uint256', name: 'quantity', type: 'uint256' },
{ internalType: 'string', name: 'comment', type: 'string' },
],
name: 'mintWithComment',
outputs: [],
stateMutability: 'payable',
type: 'function',
},
{
inputs: [],
name: 'metadata',
outputs: [
{ internalType: 'address', name: 'creator', type: 'address' },
{ internalType: 'string', name: 'name', type: 'string' },
{ internalType: 'string', name: 'description', type: 'string' },
{ internalType: 'string', name: 'symbol', type: 'string' },
{ internalType: 'string', name: 'image', type: 'string' },
{ internalType: 'string', name: 'animation_url', type: 'string' },
{ internalType: 'string', name: 'mintType', type: 'string' },
{ internalType: 'uint128', name: 'maxSupply', type: 'uint128' },
{ internalType: 'uint128', name: 'maxPerWallet', type: 'uint128' },
{ internalType: 'uint256', name: 'cost', type: 'uint256' },
{ internalType: 'uint256', name: 'startTime', type: 'uint256' },
{ internalType: 'uint256', name: 'endTime', type: 'uint256' },
{ internalType: 'uint256', name: 'nonce', type: 'uint256' },
],
stateMutability: 'view',
type: 'function',
},
{
inputs: [{ internalType: 'uint256', name: 'quantity', type: 'uint256' }],
name: 'cost',
outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }],
stateMutability: 'view',
type: 'function',
},
];
124 changes: 124 additions & 0 deletions src/ingestors/coinbase-wallet/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
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 { getCoinbaseWalletCreator } from './offchain-metadata';
import { MINT_CONTRACT_ABI } from './abi';
import { getCoinbaseWalletMetadata, getCoinbaseWalletPriceInWei } from './onchain-metadata';

export class CoinbaseWalletIngestor implements MintIngestor {
async supportsUrl(resources: MintIngestorResources, url: string): Promise<boolean> {
const collectionDescriptor = url.split('/').pop();
if (!collectionDescriptor) {
return false;
}
const collectionDescriptorSplit = collectionDescriptor.split(':');
if (collectionDescriptorSplit.length !== 4) return false;
if (collectionDescriptorSplit[1] !== '8453') return false;

return new URL(url).hostname === 'www.wallet.coinbase.com' || new URL(url).hostname === 'wallet.coinbase.com';
}

async supportsContract(resources: MintIngestorResources, contractOptions: MintContractOptions): Promise<boolean> {
if (contractOptions.chainId !== 8453) {
return false;
}
const collection = await getCoinbaseWalletMetadata(8453, contractOptions.contractAddress, resources.alchemy);
if (!collection) {
return false;
}
return true;
}

async createMintForContract(
resources: MintIngestorResources,
contractOptions: MintContractOptions,
): Promise<MintTemplate> {
const mintBuilder = new MintTemplateBuilder()
.setMintInstructionType(MintInstructionType.EVM_MINT)
.setPartnerName('CoinbaseWallet');

if (contractOptions.url) {
mintBuilder.setMarketingUrl(contractOptions.url);
}

const collectionMetadata = await getCoinbaseWalletMetadata(
8453,
contractOptions.contractAddress as string,
resources.alchemy,
);

if (!collectionMetadata) {
throw new MintIngestorError(MintIngestionErrorName.CouldNotResolveMint, 'No such collection');
}

const contractAddress = contractOptions.contractAddress;
const description = collectionMetadata.description;
const formattedImage = `https://ipfs.io/ipfs/${collectionMetadata.image.split('//').pop()}`;

mintBuilder.setName(collectionMetadata.name).setDescription(description).setFeaturedImageUrl(formattedImage);
mintBuilder.setMintOutputContract({ chainId: 8453, address: contractAddress });

// Some collections creators do not have valid metadata, only address
const creator = await getCoinbaseWalletCreator(resources, collectionMetadata.creator);

mintBuilder.setCreator({
name: creator?.name ?? '',
walletAddress: collectionMetadata.creator,
imageUrl: creator?.avatar,
});

const totalPriceWei = await getCoinbaseWalletPriceInWei(8453, contractAddress, resources.alchemy);

if (!totalPriceWei) {
throw new MintIngestorError(MintIngestionErrorName.MissingRequiredData, 'Price not available');
}

mintBuilder.setMintInstructions({
chainId: 8453,
contractAddress,
contractMethod: 'mintWithComment',
contractParams: `[address, 1, ""]`,
abi: MINT_CONTRACT_ABI,
priceWei: totalPriceWei,
});

const liveDate =
+new Date() > collectionMetadata.startTime * 1000 ? new Date() : new Date(collectionMetadata.startTime * 1000);
mintBuilder
.setAvailableForPurchaseStart(new Date(+collectionMetadata.startTime * 1000 || Date.now()))
.setAvailableForPurchaseEnd(new Date(+collectionMetadata.endTime * 1000 || '2030-01-01'))
.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');
}

const collectionDescriptor = url.split('/').pop();
if (!collectionDescriptor) {
throw new MintIngestorError(MintIngestionErrorName.CouldNotResolveMint, 'Url error');
}

const collectionDescriptorSplit = collectionDescriptor.split(':');
const contractAddress = collectionDescriptorSplit.pop() as string;
collectionDescriptorSplit.pop();
const chainId = collectionDescriptorSplit.pop() as string;

const collectionMetadata = await getCoinbaseWalletMetadata(+chainId, contractAddress as string, resources.alchemy);

if (!collectionMetadata) {
throw new MintIngestorError(MintIngestionErrorName.CouldNotResolveMint, 'No such collection');
}

return this.createMintForContract(resources, {
chainId: 8453,
contractAddress,
url,
});
}
}
12 changes: 12 additions & 0 deletions src/ingestors/coinbase-wallet/offchain-metadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { MintIngestorResources } from 'src/lib';
import { Profile } from './types';

export const getCoinbaseWalletCreator = async (resources: MintIngestorResources, address: string): Promise<Profile | undefined> => {
const url = `https://api.wallet.coinbase.com/rpc/v2/getBasicPublicProfiles?addresses=${address}`;

try {
const resp = await resources.fetcher.get(url);
const profile = resp.data.result.profiles[address];
return profile;
} catch (error) {}
};
46 changes: 46 additions & 0 deletions src/ingestors/coinbase-wallet/onchain-metadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Alchemy, Contract } from 'alchemy-sdk';
import { MINT_CONTRACT_ABI } from './abi';
import { CollectionMetadata } from './types';

const getContract = async (chainId: number, contractAddress: string, alchemy: Alchemy): Promise<Contract> => {
const ethersProvider = await alchemy.config.getProvider();
const contract = new Contract(contractAddress, MINT_CONTRACT_ABI, ethersProvider);
return contract;
};

export const getCoinbaseWalletMetadata = async (
chainId: number,
contractAddress: string,
alchemy: Alchemy,
): Promise<CollectionMetadata | undefined> => {
try {
const contract = await getContract(chainId, contractAddress, alchemy);
const metadata = await contract.functions.metadata();

return {
...metadata,
cost: parseInt(String(metadata.cost)),
startTime: parseInt(String(metadata.startTime)),
endTime: parseInt(String(metadata.endTime))
};
} catch (error) {
console.log(error)
}
};

export const getCoinbaseWalletPriceInWei = async (
chainId: number,
contractAddress: string,
alchemy: Alchemy,
): Promise<string | undefined> => {
try {
const contract = await getContract(chainId, contractAddress, alchemy);
const price = await contract.functions.cost(1);

return `${parseInt(String(price))}`;

} catch (error) {
console.log(error)
}
};

16 changes: 16 additions & 0 deletions src/ingestors/coinbase-wallet/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export type CollectionMetadata = {
creator: string,
name: string,
description: string,
image: string,
mintType: 'OPEN_EDITION_721',
cost: number,
startTime: number,
endTime: number,
}

export type Profile = {
address: string;
name: string;
avatar: string;
}
2 changes: 2 additions & 0 deletions src/ingestors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { FxHashIngestor } from './fxhash';
import { HighlightIngestor } from './highlight';
import { TransientIngestor } from './transient-base';
import { FoundationIngestor } from './foundation';
import { CoinbaseWalletIngestor } from './coinbase-wallet';
import { ZoraInternalIngestor } from './zora-internal';
import { RodeoIngestor } from './rodeo';

Expand All @@ -19,6 +20,7 @@ export const ALL_MINT_INGESTORS: MintIngestionMap = {
highlight: new HighlightIngestor(),
transient: new TransientIngestor(),
foundation: new FoundationIngestor(),
'coinbase-wallet': new CoinbaseWalletIngestor(),
'zora-internal': new ZoraInternalIngestor(),
rodeo: new RodeoIngestor(),
};
Expand Down
120 changes: 120 additions & 0 deletions test/ingestors/coinbase-wallet.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { expect } from 'chai';
import { CoinbaseWalletIngestor } from '../../src/ingestors/coinbase-wallet';
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('CoinbaseWallet', function () {
basicIngestorTests(
new CoinbaseWalletIngestor(),
resources,
{
successUrls: [
'https://wallet.coinbase.com/nft/mint/eip155:8453:erc721:0xb5408b7126142C61f509046868B1273F96191b6d',
'https://wallet.coinbase.com/nft/mint/eip155:8453:erc721:0xEE8128c612ABE57070dEac0E299282ef0a71a347',
'https://wallet.coinbase.com/nft/mint/eip155:8453:erc721:0x3aDa53708b167Fbd2907b5a1bA19b58a856E2200'
],
failureUrls: [
'https://wallet.coinbase.com/nft/gallery/ethereum-etf',
'https://foundation.app/mint/base/the-billows',
],
successContracts: [
{ chainId: 8453, contractAddress: '0x13F294BF5e26843C33d0ae739eDb8d6B178740B0' },
{ chainId: 8453, contractAddress: '0xEE8128c612ABE57070dEac0E299282ef0a71a347' },
],
failureContracts: [{ chainId: 5000, contractAddress: '0x62F8C536De24ED32611f128f64F6eAbd9b82176c' }],
},
{
'8453': '0x1175BA0',
},
);
it('supportsUrl: Returns false for an unsupported URL', async function () {
const ingestor = new CoinbaseWalletIngestor();
const url = 'https://wallet.coinbase.com/nft/gallery/ethereum-etf';
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 CoinbaseWalletIngestor();
const url = 'https://wallet.coinbase.com/nft/mint/eip155:8453:erc721:0xb5408b7126142C61f509046868B1273F96191b6d';
const resources = mintIngestorResources();
const result = await ingestor.supportsUrl(resources, url);
expect(result).to.be.true;

const url2 = 'https://wallet.coinbase.com/nft/mint/eip155:8453:erc721:0x17ca8424F3c42aA840eB1B54a2400eC80330Eea8';
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 CoinbaseWalletIngestor();
const url = 'https://wallet.coinbase.com/nft/mint/eip155:8453:erc721:0x17ca8424F3c42aA840eB1B54a2400eC80330Eea8';
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('Italy Is Based');
expect(template.description).to.contain('Join the Italian Base community and fully enjoy the Onchain Summer');
const mintInstructions = template.mintInstructions as EVMMintInstructions;

expect(mintInstructions.contractAddress).to.equal('0x17ca8424F3c42aA840eB1B54a2400eC80330Eea8');
expect(mintInstructions.contractMethod).to.equal('mintWithComment');
expect(mintInstructions.contractParams).to.equal('[address, 1, ""]');
expect(mintInstructions.priceWei).to.equal('100000000000000');

expect(template.featuredImageUrl).to.equal(
'https://ipfs.io/ipfs/Qme3w2B1W9zHsASbV7KRgdfrkHLFCWfhtQiDgeEYbzSHnb/nft-gallery-1gif',
);

if (template.creator) {
expect(template.creator.walletAddress?.toLowerCase()).to.equal('0x30bec89100f144aad632153de93b58a32772cf58');
}

expect(template.marketingUrl).to.equal(url);
expect(template.availableForPurchaseStart?.getTime()).to.equal(+new Date('2024-07-24T22:02:16.000Z'));
expect(template.availableForPurchaseEnd?.getTime()).to.greaterThan(+new Date('2030-01-01T00:00:00.000Z'));
});

it('createMintTemplateForUrl: Returns a mint template for a supported URL with creator metadata', async function () {
const ingestor = new CoinbaseWalletIngestor();
const url = 'https://wallet.coinbase.com/nft/mint/eip155:8453:erc721:0xf9aDb505EaadacCF170e48eE46Ee4d5623f777d7';
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('Onchain Summit 2024 San Francisco');
expect(template.description).to.contain(
'Base community is bringing the Onchain Summit Billboard to life in San Francisco',
);
const mintInstructions = template.mintInstructions as EVMMintInstructions;

expect(mintInstructions.contractAddress).to.equal('0xf9aDb505EaadacCF170e48eE46Ee4d5623f777d7');
expect(mintInstructions.contractMethod).to.equal('mintWithComment');
expect(mintInstructions.contractParams).to.equal('[address, 1, ""]');
expect(mintInstructions.priceWei).to.equal('800000000000000');

expect(template.featuredImageUrl).to.equal(
'https://ipfs.io/ipfs/QmYuxDK8zkCF6taNTZuMSVAPbDDATZzdtVbHBSfPxCmT9J/nft-gallery-1png',
);

if (template.creator) {
expect(template.creator.name).to.equal('onchainsummit.base.eth');
expect(template.creator.walletAddress?.toLowerCase()).to.equal('0x03489e02bf56b43a8e91287e8cfef76a7a6a9aa3');
}

expect(template.marketingUrl).to.equal(url);
expect(template.availableForPurchaseStart?.getTime()).to.equal(+new Date('2024-07-26T23:20:15.000Z'));
expect(template.availableForPurchaseEnd?.getTime()).to.equal(+new Date('2024-08-31T15:20:19.000Z'));
});
});

0 comments on commit 63dc472

Please sign in to comment.