UNPKG

viem

Version:

TypeScript Interface for Ethereum

299 lines (263 loc) • 8.67 kB
import type { Address } from 'abitype' import { type ReadContractErrorType, readContract, } from '../../../actions/public/readContract.js' import type { Client } from '../../../clients/createClient.js' import type { Transport } from '../../../clients/transports/createTransport.js' import { EnsAvatarInvalidMetadataError, type EnsAvatarInvalidMetadataErrorType, EnsAvatarInvalidNftUriError, type EnsAvatarInvalidNftUriErrorType, EnsAvatarUnsupportedNamespaceError, type EnsAvatarUnsupportedNamespaceErrorType, EnsAvatarUriResolutionError, type EnsAvatarUriResolutionErrorType, } from '../../../errors/ens.js' import type { ErrorType } from '../../../errors/utils.js' import type { Chain } from '../../../types/chain.js' import type { AssetGatewayUrls } from '../../../types/ens.js' type UriItem = { uri: string isOnChain: boolean isEncoded: boolean } const networkRegex = /(?<protocol>https?:\/\/[^\/]*|ipfs:\/|ipns:\/|ar:\/)?(?<root>\/)?(?<subpath>ipfs\/|ipns\/)?(?<target>[\w\-.]+)(?<subtarget>\/.*)?/ const ipfsHashRegex = /^(Qm[1-9A-HJ-NP-Za-km-z]{44,}|b[A-Za-z2-7]{58,}|B[A-Z2-7]{58,}|z[1-9A-HJ-NP-Za-km-z]{48,}|F[0-9A-F]{50,})(\/(?<target>[\w\-.]+))?(?<subtarget>\/.*)?$/ const base64Regex = /^data:([a-zA-Z\-/+]*);base64,([^"].*)/ const dataURIRegex = /^data:([a-zA-Z\-/+]*)?(;[a-zA-Z0-9].*?)?(,)/ type IsImageUriErrorType = ErrorType /** @internal */ export async function isImageUri(uri: string) { try { const res = await fetch(uri, { method: 'HEAD' }) // retrieve content type header to check if content is image if (res.status === 200) { const contentType = res.headers.get('content-type') return contentType?.startsWith('image/') } return false } catch (error: any) { // if error is not cors related then fail if (typeof error === 'object' && typeof error.response !== 'undefined') { return false } // fail in NodeJS, since the error is not cors but any other network issue // biome-ignore lint/suspicious/noPrototypeBuiltins: if (!globalThis.hasOwnProperty('Image')) return false // in case of cors, use image api to validate if given url is an actual image return new Promise((resolve) => { const img = new Image() img.onload = () => { resolve(true) } img.onerror = () => { resolve(false) } img.src = uri }) } } type GetGatewayErrorType = ErrorType /** @internal */ export function getGateway(custom: string | undefined, defaultGateway: string) { if (!custom) return defaultGateway if (custom.endsWith('/')) return custom.slice(0, -1) return custom } export type ResolveAvatarUriErrorType = | GetGatewayErrorType | EnsAvatarUriResolutionErrorType | ErrorType export function resolveAvatarUri({ uri, gatewayUrls, }: { uri: string gatewayUrls?: AssetGatewayUrls | undefined }): UriItem { const isEncoded = base64Regex.test(uri) if (isEncoded) return { uri, isOnChain: true, isEncoded } const ipfsGateway = getGateway(gatewayUrls?.ipfs, 'https://ipfs.io') const arweaveGateway = getGateway(gatewayUrls?.arweave, 'https://arweave.net') const networkRegexMatch = uri.match(networkRegex) const { protocol, subpath, target, subtarget = '', } = networkRegexMatch?.groups || {} const isIPNS = protocol === 'ipns:/' || subpath === 'ipns/' const isIPFS = protocol === 'ipfs:/' || subpath === 'ipfs/' || ipfsHashRegex.test(uri) if (uri.startsWith('http') && !isIPNS && !isIPFS) { let replacedUri = uri if (gatewayUrls?.arweave) replacedUri = uri.replace(/https:\/\/arweave.net/g, gatewayUrls?.arweave) return { uri: replacedUri, isOnChain: false, isEncoded: false } } if ((isIPNS || isIPFS) && target) { return { uri: `${ipfsGateway}/${isIPNS ? 'ipns' : 'ipfs'}/${target}${subtarget}`, isOnChain: false, isEncoded: false, } } if (protocol === 'ar:/' && target) { return { uri: `${arweaveGateway}/${target}${subtarget || ''}`, isOnChain: false, isEncoded: false, } } let parsedUri = uri.replace(dataURIRegex, '') if (parsedUri.startsWith('<svg')) { // if svg, base64 encode parsedUri = `data:image/svg+xml;base64,${btoa(parsedUri)}` } if (parsedUri.startsWith('data:') || parsedUri.startsWith('{')) { return { uri: parsedUri, isOnChain: true, isEncoded: false, } } throw new EnsAvatarUriResolutionError({ uri }) } export type GetJsonImageErrorType = | EnsAvatarInvalidMetadataErrorType | ErrorType export function getJsonImage(data: any) { // validation check for json data, must include one of theses properties if ( typeof data !== 'object' || (!('image' in data) && !('image_url' in data) && !('image_data' in data)) ) { throw new EnsAvatarInvalidMetadataError({ data }) } return data.image || data.image_url || data.image_data } export type GetMetadataAvatarUriErrorType = | EnsAvatarUriResolutionErrorType | ParseAvatarUriErrorType | GetJsonImageErrorType | ErrorType export async function getMetadataAvatarUri({ gatewayUrls, uri, }: { gatewayUrls?: AssetGatewayUrls | undefined uri: string }): Promise<string> { try { const res = await fetch(uri).then((res) => res.json()) const image = await parseAvatarUri({ gatewayUrls, uri: getJsonImage(res), }) return image } catch { throw new EnsAvatarUriResolutionError({ uri }) } } export type ParseAvatarUriErrorType = | ResolveAvatarUriErrorType | IsImageUriErrorType | EnsAvatarUriResolutionErrorType | ErrorType export async function parseAvatarUri({ gatewayUrls, uri, }: { gatewayUrls?: AssetGatewayUrls | undefined uri: string }): Promise<string> { const { uri: resolvedURI, isOnChain } = resolveAvatarUri({ uri, gatewayUrls }) if (isOnChain) return resolvedURI // check if resolvedURI is an image, if it is return the url const isImage = await isImageUri(resolvedURI) if (isImage) return resolvedURI throw new EnsAvatarUriResolutionError({ uri }) } type ParsedNft = { chainID: number namespace: string contractAddress: Address tokenID: string } export type ParseNftUriErrorType = EnsAvatarInvalidNftUriErrorType | ErrorType export function parseNftUri(uri_: string): ParsedNft { let uri = uri_ // parse valid nft spec (CAIP-22/CAIP-29) // @see: https://github.com/ChainAgnostic/CAIPs/tree/master/CAIPs if (uri.startsWith('did:nft:')) { // convert DID to CAIP uri = uri.replace('did:nft:', '').replace(/_/g, '/') } const [reference, asset_namespace, tokenID] = uri.split('/') const [eip_namespace, chainID] = reference.split(':') const [erc_namespace, contractAddress] = asset_namespace.split(':') if (!eip_namespace || eip_namespace.toLowerCase() !== 'eip155') throw new EnsAvatarInvalidNftUriError({ reason: 'Only EIP-155 supported' }) if (!chainID) throw new EnsAvatarInvalidNftUriError({ reason: 'Chain ID not found' }) if (!contractAddress) throw new EnsAvatarInvalidNftUriError({ reason: 'Contract address not found', }) if (!tokenID) throw new EnsAvatarInvalidNftUriError({ reason: 'Token ID not found' }) if (!erc_namespace) throw new EnsAvatarInvalidNftUriError({ reason: 'ERC namespace not found' }) return { chainID: Number.parseInt(chainID), namespace: erc_namespace.toLowerCase(), contractAddress: contractAddress as Address, tokenID, } } export type GetNftTokenUriErrorType = | ReadContractErrorType | EnsAvatarUnsupportedNamespaceErrorType | ErrorType export async function getNftTokenUri<chain extends Chain | undefined>( client: Client<Transport, chain>, { nft }: { nft: ParsedNft }, ) { if (nft.namespace === 'erc721') { return readContract(client, { address: nft.contractAddress, abi: [ { name: 'tokenURI', type: 'function', stateMutability: 'view', inputs: [{ name: 'tokenId', type: 'uint256' }], outputs: [{ name: '', type: 'string' }], }, ], functionName: 'tokenURI', args: [BigInt(nft.tokenID)], }) } if (nft.namespace === 'erc1155') { return readContract(client, { address: nft.contractAddress, abi: [ { name: 'uri', type: 'function', stateMutability: 'view', inputs: [{ name: '_id', type: 'uint256' }], outputs: [{ name: '', type: 'string' }], }, ], functionName: 'uri', args: [BigInt(nft.tokenID)], }) } throw new EnsAvatarUnsupportedNamespaceError({ namespace: nft.namespace }) }