Skip to content

Commit

Permalink
feat: add avatar resolver
Browse files Browse the repository at this point in the history
  • Loading branch information
zgayjjf committed Apr 29, 2022
1 parent 0bd2cdd commit d1818bc
Show file tree
Hide file tree
Showing 5 changed files with 238 additions and 3 deletions.
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,18 @@
},
"dependencies": {
"@bitgo/blake2b": "^3.0.1",
"@ethersproject/bignumber": "^5.6.0",
"@ethersproject/bytes": "^5.6.1",
"@ethersproject/web": "^5.6.0",
"@nestjs/common": "^7.6.13",
"@nestjs/core": "^7.6.13",
"@nestjs/platform-express": "^7.6.13",
"blueimp-md5": "^2.18.0",
"cache-manager": "^3.4.1",
"canvas": "2.6.1",
"das-sdk": "^1.7.1",
"das-ui-shared": "^0.0.9",
"ethers": "^5.6.4",
"node-canvas-webgl": "^0.2.6",
"qrcode": "^1.4.4",
"reflect-metadata": "^0.1.13",
Expand Down
8 changes: 7 additions & 1 deletion src/app.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Controller, Get, Header, Headers, Param, Query, Res } from '@nestjs/com
import { Response } from 'express'
import { AppService } from './app.service'
import { AvatarOptions, AvatarService } from './avatar.service'
import { TIME_30D } from './constants/index'
import { TIME_1H, TIME_30D } from './constants/index'

@Controller()
export class AppController {
Expand Down Expand Up @@ -55,6 +55,12 @@ export class AppController {
res.send(avatar)
}

@Get('/resolve/:account')
@Header('Cache-Control', `public, max-age=${TIME_1H}`)
async resolve (@Param('account') account: string) {
return await this.avatarService.resolve(account)
}

@Get('/card/bestdas/:account')
@Header('content-type', 'image/png')
@Header('cache-control', `public, max-age=${TIME_30D}`)
Expand Down
225 changes: 224 additions & 1 deletion src/avatar.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,19 @@ import { Injectable } from '@nestjs/common'
import { CanvasRenderingContext2D, createCanvas, Image, loadImage } from 'canvas'
import { accountColor } from 'das-ui-shared'
import path from 'path'
import { LocalCache } from './decorators/cache.decorator'
import { Das } from 'das-sdk'
import { Cache, LocalCache } from './decorators/cache.decorator'
import { ethers } from 'ethers'
import { fetchJson } from '@ethersproject/web'
import { BigNumber } from '@ethersproject/bignumber'
import { hexConcat, hexDataSlice, hexZeroPad } from '@ethersproject/bytes'
import { toUtf8String } from '@ethersproject/strings'

const das = new Das({
url: 'https://indexer-not-use-in-production-env.did.id',
})

const provider = new ethers.providers.AnkrProvider()

function unitIndexes (length: number): string[] {
const maxLength = Math.max(length.toString().length, 2)
Expand Down Expand Up @@ -131,6 +143,48 @@ const AvatarSizeMap = {
[AvatarSize.xxl]: 1000,
}

const matcherIpfs = /^(ipfs):\/\/(.*)$/i

const matchers = [
/^(https):\/\/(.*)$/i,
/^(data):(.*)$/i,
matcherIpfs,
/^eip155:[0-9]+\/(erc[0-9]+):(.*)$/i,
]

// Trim off the ipfs:// prefix and return the default gateway URL
function getIpfsLink (link: string): string {
if (link.match(/^ipfs:\/\/ipfs\//i)) {
link = link.substring(12)
}
else if (link.match(/^ipfs:\/\//i)) {
link = link.substring(7)
}
else {
throw new Error(`unsupported IPFS format '${link}'`)
}

return `https://gateway.ipfs.io/ipfs/${link}`
}

function _parseBytes (result: string, start: number): null | string {
if (result === '0x') {
return null
}

const offset: number = BigNumber.from(hexDataSlice(result, start, start + 32)).toNumber()
const length: number = BigNumber.from(hexDataSlice(result, offset, offset + 32)).toNumber()

return hexDataSlice(result, offset + 32, offset + 32 + length)
}
function _parseString (result: string, start: number): null | string {
try {
return toUtf8String(_parseBytes(result, start))
}
catch (error) { }
return null
}

type Layer = typeof layers[0]

export interface AvatarOptions {
Expand Down Expand Up @@ -178,4 +232,173 @@ export class AvatarService {

return canvas.toBuffer('image/jpeg', { quality: 0.9 })
}

@Cache({ key: 'resolve', ttl: 3600 })
async resolve (accountName: string) {
const result = await this._resolve(accountName)

if (!result) {
return {
linkage: [{
type: 'account',
content: 'phone.bit',
}, {
type: 'fallback',
content: 'identicon',
}, {
type: 'url',
content: `https://identicons.did.id/identicon/${accountName}`
}],
// url: `https://identicons.did.id/avatar/${accountName}`,
url: `https://identicons.did.id/identicon/${accountName}`
}
}

return result
}

/**
* resolve avatar on-chain
* forked from ethers.js
* @param accountName
*/
async _resolve (accountName: string) {
const linkage: Array<{ type: string, content: string }> = [{ type: 'account', content: accountName }]
try {
const account = await das.account(accountName)
if (!account?.owner_key) {
return null
}
// test data for jeffx.bit
// const avatar = "eip155:1/erc721:0x265385c7f4132228A0d54EB1A9e7460b91c0cC68/29233";
const avatarRecord = await das.records(accountName, 'profile.avatar')
const avatar = avatarRecord[0]?.value
if (!avatar) {
return null
}

for (let i = 0; i < matchers.length; i++) {
const match = avatar.match(matchers[i])
if (match == null) {
continue
}

const scheme = match[1].toLowerCase()

switch (scheme) {
case 'https':
linkage.push({ type: 'url', content: avatar })
return { linkage, url: avatar }

case 'data':
linkage.push({ type: 'data', content: avatar })
return { linkage, url: avatar }

case 'ipfs':
linkage.push({ type: 'ipfs', content: avatar })
return { linkage, url: getIpfsLink(avatar) }

case 'erc721':
case 'erc1155': {
// Depending on the ERC type, use tokenURI(uint256) or url(uint256)
const selector = (scheme === 'erc721') ? '0xc87b56dd' : '0x0e89341c'
linkage.push({ type: scheme, content: avatar })

// The owner of this name
// todo: only use under eth
const owner = account.owner_key

const comps = (match[2] || '').split('/')
if (comps.length !== 2) {
return null
}

const addr = provider.formatter.address(comps[0])
const tokenId = hexZeroPad(BigNumber.from(comps[1]).toHexString(), 32)

// Check that this account owns the token
if (scheme === 'erc721') {
// ownerOf(uint256 tokenId)
const tokenOwner = provider.formatter.callAddress(await provider.call({
to: addr, data: hexConcat(['0x6352211e', tokenId])
}))
if (owner !== tokenOwner) {
return null
}
linkage.push({ type: 'owner', content: tokenOwner })
}
else if (scheme === 'erc1155') {
// balanceOf(address owner, uint256 tokenId)
const balance = BigNumber.from(await provider.call({
to: addr, data: hexConcat(['0x00fdd58e', hexZeroPad(owner, 32), tokenId])
}))
if (balance.isZero()) {
return null
}
linkage.push({ type: 'balance', content: balance.toString() })
}

// Call the token contract for the metadata URL
const tx = {
to: provider.formatter.address(comps[0]),
data: hexConcat([selector, tokenId])
}

let metadataUrl = _parseString(await provider.call(tx), 0)
if (metadataUrl == null) {
return null
}
linkage.push({ type: 'metadata-url-base', content: metadataUrl })

// ERC-1155 allows a generic {id} in the URL
if (scheme === 'erc1155') {
metadataUrl = metadataUrl.replace('{id}', tokenId.substring(2))
linkage.push({ type: 'metadata-url-expanded', content: metadataUrl })
}

// Transform IPFS metadata links
if (metadataUrl.match(/^ipfs:/i)) {
metadataUrl = getIpfsLink(metadataUrl)
}

linkage.push({ type: 'metadata-url', content: metadataUrl })

// Get the token metadata
const metadata = await fetchJson(metadataUrl)
if (!metadata) {
return null
}
linkage.push({ type: 'metadata', content: JSON.stringify(metadata) })

// Pull the image URL out
let imageUrl = metadata.image
if (typeof (imageUrl) !== 'string') {
return null
}

if (imageUrl.match(/^(https:\/\/|data:)/i)) {
// Allow
}
else {
// Transform IPFS link to gateway
const ipfs = imageUrl.match(matcherIpfs)
if (ipfs == null) {
return null
}

linkage.push({ type: 'url-ipfs', content: imageUrl })
imageUrl = getIpfsLink(imageUrl)
}

linkage.push({ type: 'url', content: imageUrl })

return { linkage, url: imageUrl }
}
}
}
}
catch (error) { }

return null
}
}
1 change: 1 addition & 0 deletions src/constants/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export const TIME_30D = 30 * 24 * 60 * 60
export const TIME_1H = 60 * 60
2 changes: 1 addition & 1 deletion src/decorators/cache.decorator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ interface LocalCacheConfig {
/**
* Memory cache
* @param key
* @param ttl
* @param ttl seconds
* @constructor
*/
export function Cache ({ key, ttl }: CacheConfig = { ttl: 10 }) {
Expand Down

0 comments on commit d1818bc

Please sign in to comment.