diff --git a/package.json b/package.json index 901fa635..0be17764 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "canvas": "^2.9.0", "compression": "^1.7.4", "cors": "^2.8.5", + "data-urls": "^3.0.2", "dotenv": "^16.0.0", "eslint": "^6.7.2", "ethers": "^5.5.4", diff --git a/src/api.ts b/src/api.ts index 88c42531..e96c8ab7 100644 --- a/src/api.ts +++ b/src/api.ts @@ -21,14 +21,15 @@ router.get('/clear/:type/:id', async (req, res) => { } }); -router.get('/:type/:id', async (req, res) => { - const { type, id } = req.params; +router.get('/:type/:id/:subId?', async (req, res) => { + const { type, id, subId } = req.params; const { address, network, w, h, fallback } = await parseQuery(id, type, req.query); const key1 = getCacheKey({ type, network, address, + subId, w: constants.max, h: constants.max, fallback @@ -37,6 +38,7 @@ router.get('/:type/:id', async (req, res) => { let currentResolvers = constants.resolvers.avatar; if (type === 'token') currentResolvers = constants.resolvers.token; if (type === 'space') currentResolvers = constants.resolvers.space; + if (type === 'nft') currentResolvers = constants.resolvers.nft; currentResolvers = [fallback, ...currentResolvers]; // Check resized cache @@ -55,7 +57,11 @@ router.get('/:type/:id', async (req, res) => { // console.log('Got base cache'); } else { console.log('No cache for', key1, base); - const p = currentResolvers.map(r => resolvers[r](address, network)); + + const extraArgs: any[] = []; + if (type === 'nft') extraArgs.push(subId); + + const p = currentResolvers.map(r => resolvers[r](address, ...extraArgs, network)); const files = await Promise.all(p); files.forEach(file => { if (file) file1 = file; diff --git a/src/constants.json b/src/constants.json index 798dd6b7..3fd967b5 100644 --- a/src/constants.json +++ b/src/constants.json @@ -4,6 +4,7 @@ "resolvers": { "avatar": ["selfid", "lens", "ens", "snapshot"], "token": ["trustwallet", "zapper"], - "space": ["space"] + "space": ["space"], + "nft": ["nft"] } } diff --git a/src/resolvers/index.ts b/src/resolvers/index.ts index 895d5b6f..5cf54f56 100644 --- a/src/resolvers/index.ts +++ b/src/resolvers/index.ts @@ -7,6 +7,7 @@ import space from './space'; import selfid from './selfid'; import lens from './lens'; import zapper from './zapper'; +import nft from './nft'; export default { blockie, @@ -17,5 +18,6 @@ export default { space, selfid, lens, - zapper + zapper, + nft }; diff --git a/src/resolvers/nft.ts b/src/resolvers/nft.ts new file mode 100644 index 00000000..4c5d9272 --- /dev/null +++ b/src/resolvers/nft.ts @@ -0,0 +1,91 @@ +import axios from 'axios'; +import parseDataURL from 'data-urls'; +import { getAddress } from '@ethersproject/address'; +import { Contract } from '@ethersproject/contracts'; +import { StaticJsonRpcProvider } from '@ethersproject/providers'; +import { getUrl, resize } from '../utils'; +import { max } from '../constants.json'; + +const provider = new StaticJsonRpcProvider('https://brovider.xyz/1'); + +const abis = { + erc721: ['function tokenURI(uint256 tokenId) view returns (string)'], + erc1155: ['function uri(uint256 id) view returns (string)'] +}; + +async function resolveErc721(address: string, tokenId: string) { + const contract = new Contract(getAddress(address), abis.erc721, provider); + const data = await contract.tokenURI(tokenId); + + const parsedMetadata = parseDataURL(data); + + let metadata; + if (parsedMetadata && parsedMetadata.mimeType.toString() === 'application/json') { + metadata = JSON.parse(Buffer.from(parsedMetadata.body).toString('utf-8')); + } else { + const url = getUrl(data); + metadata = (await axios.get(url)).data; + } + + if (!metadata.image) { + throw new Error('Image not found'); + } + + const parsedImage = parseDataURL(metadata.image); + if (parsedImage) { + return Buffer.from(parsedImage.body); + } + + const url = getUrl(metadata.image); + return (await axios({ url, responseType: 'arraybuffer' })).data as Buffer; +} + +async function resolveErc1155(address: string, tokenId: string) { + const contract = new Contract(getAddress(address), abis.erc1155, provider); + const data = await contract.uri(tokenId); + + const replacementId = + tokenId.length === 64 + ? tokenId + : BigInt(tokenId) + .toString(16) + .padStart(64, '0'); + + const parsedMetadata = parseDataURL(data); + + let metadataString; + if (parsedMetadata && parsedMetadata.mimeType.toString() === 'application/json') { + metadataString = Buffer.from(parsedMetadata.body).toString('utf-8'); + } else { + const uniqueData = data.replaceAll('{id}', replacementId); + const url = getUrl(uniqueData); + metadataString = JSON.stringify((await axios.get(url)).data); + } + + const metadata = JSON.parse(metadataString.replaceAll('{id}', replacementId)); + + if (!metadata.image) { + throw new Error('Image not found'); + } + + const parsedImage = parseDataURL(metadata.image); + if (parsedImage) { + return Buffer.from(parsedImage.body); + } + + const url = getUrl(metadata.image); + return (await axios({ url, responseType: 'arraybuffer' })).data as Buffer; +} + +export default async function resolve(address: string, tokenId: string) { + try { + const input = await Promise.any([ + resolveErc721(address, tokenId), + resolveErc1155(address, tokenId) + ]); + + return await resize(input, max, max, 'contain'); + } catch (e) { + return false; + } +} diff --git a/src/utils.ts b/src/utils.ts index 03df3528..c78dfd41 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -10,9 +10,11 @@ export function sha256(str) { .digest('hex'); } -export async function resize(input, w, h) { +export async function resize(input, w, h, fit = 'cover') { return sharp(input) - .resize(w, h) + .resize(w, h, { + fit + }) .webp({ lossless: true }) .toBuffer(); } @@ -82,6 +84,7 @@ export function getCacheKey({ type, network, address, + subId, w, h, fallback @@ -89,12 +92,16 @@ export function getCacheKey({ type: string; network: string; address: string; + subId?: string; w: number; h: number; fallback: string; }) { if (fallback === 'blockie') return sha256(JSON.stringify({ type, network, address, w, h })); - return sha256(JSON.stringify({ type, network, address, w, h, fallback })); + const blob: Record = { type, network, address, w, h, fallback }; + if (type === 'nft') blob.tokenId = subId; + + return sha256(JSON.stringify(blob)); } export function setHeader(res) { diff --git a/test/unit/resolvers/nft.test.ts b/test/unit/resolvers/nft.test.ts new file mode 100644 index 00000000..a75cb93a --- /dev/null +++ b/test/unit/resolvers/nft.test.ts @@ -0,0 +1,40 @@ +import resolvers from '../../../src/resolvers'; + +describe('resolvers', () => { + describe('nft', () => { + describe('erc721', () => { + it('should resolve on-chain metadata', async () => { + const result = await resolvers.nft('0x29b4ea6b1164c7cd8a3a0a1dc4ad88d1e0589124', '6364'); + + expect(result).toBeInstanceOf(Buffer); + expect((result as Buffer).length).toBeGreaterThan(500); + }, 15000); + + it('should resolve IPFS metadata', async () => { + const result = await resolvers.nft('0x7f8162f4ffe3db46cd3b0626dab699506c0ff63a', '6386'); + + expect(result).toBeInstanceOf(Buffer); + expect((result as Buffer).length).toBeGreaterThan(500); + }, 15000); + }); + + describe('erc1155', () => { + it('should resolve IPFS metadata', async () => { + const result = await resolvers.nft('0x3b1417c1f204607deda4767929497256e4ff540c', '1'); + + expect(result).toBeInstanceOf(Buffer); + expect((result as Buffer).length).toBeGreaterThan(500); + }, 15000); + + it('should resolve OpeanSea metadata', async () => { + const result = await resolvers.nft( + '0x495f947276749Ce646f68AC8c248420045cb7b5e', + '71349417930267003648058267821921373972951788320258492784107927381794011217921' + ); + + expect(result).toBeInstanceOf(Buffer); + expect((result as Buffer).length).toBeGreaterThan(500); + }, 15000); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 92b85849..2e57ef08 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3403,7 +3403,7 @@ cssstyle@^2.3.0: dependencies: cssom "~0.3.6" -data-urls@^3.0.1: +data-urls@^3.0.1, data-urls@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-3.0.2.tgz#9cf24a477ae22bcef5cd5f6f0bfbc1d2d3be9143" integrity sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==