├── .editorconfig ├── .env.example ├── .eslintrc ├── .github ├── dependabot.yml └── workflows │ ├── create-sentry-release.yml │ ├── lint.yml │ └── test.yml ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── jest.config.ts ├── package.json ├── src ├── addressResolvers │ ├── cache.ts │ ├── ens.ts │ ├── index.ts │ ├── lens.ts │ ├── shibarium.ts │ ├── snapshot.ts │ ├── starknet.ts │ ├── unstoppableDomains.ts │ └── utils.ts ├── api.ts ├── aws.ts ├── chains.json ├── constants.json ├── getOwner │ ├── index.ts │ └── shibarium.ts ├── helpers │ ├── metrics.ts │ ├── redis.ts │ └── utils.ts ├── index.ts ├── lookupDomains │ ├── ens.ts │ ├── index.ts │ └── shibarium.ts ├── resolvers │ ├── blockie.ts │ ├── coingecko.ts │ ├── ens.ts │ ├── farcaster.ts │ ├── index.ts │ ├── jazzicon.ts │ ├── lens.ts │ ├── selfid.ts │ ├── snapshot.ts │ ├── space-sx.ts │ ├── starknet.ts │ ├── trustwallet.ts │ ├── utils.ts │ └── zapper.ts ├── scripts │ └── generate-chains.ts └── utils.ts ├── test ├── .env.test ├── e2e │ └── api.test.ts ├── fixtures │ └── addresses.ts ├── integration │ ├── addressResolvers │ │ ├── ens.test.ts │ │ ├── helper.ts │ │ ├── index.test.ts │ │ ├── lens.test.ts │ │ ├── shibarium.test.ts │ │ ├── snapshot.test.ts │ │ ├── starknet.test.ts │ │ ├── unstoppableDomains.test.ts │ │ └── utils.test.ts │ ├── getOwner.test.ts │ ├── lookupDomains.test.ts │ └── resolvers │ │ ├── blockie.test.ts │ │ ├── ens.test.ts │ │ ├── farcaster.test.ts │ │ ├── jazzicon.test.ts │ │ ├── lens.test.ts │ │ ├── selfid.test.ts │ │ ├── snapshot.test.ts │ │ ├── space-sx.test.ts │ │ ├── starknet.test.ts │ │ ├── trustwallet.test.ts │ │ └── zapper.test.ts └── setup-jest.ts ├── tsconfig.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = LF 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | AWS_ACCESS_KEY_ID= 2 | AWS_BUCKET_NAME= 3 | AWS_REGION= 4 | AWS_SECRET_ACCESS_KEY= 5 | HUB_URL=https://hub.snapshot.org 6 | HUB_API_KEY= 7 | BROVIDER_URL=https://rpc.snapshot.org 8 | IPFS_GATEWAY=cloudflare-ipfs.com 9 | REDIS_URL= 10 | SENTRY_DSN= 11 | SENTRY_TRACE_SAMPLE_RATE= 12 | NEYNAR_API_KEY= 13 | INFURA_API_KEY= 14 | D3_API_KEY_MAINNET= 15 | D3_API_KEY_TESTNET= 16 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "extends": [ 4 | "plugin:@typescript-eslint/recommended" 5 | ], 6 | "plugins": [ 7 | "prettier", 8 | "@typescript-eslint" 9 | ], 10 | "parserOptions": { 11 | "ecmaVersion": 2018, 12 | "sourceType": "module" 13 | }, 14 | "rules": { 15 | "no-console": "off", 16 | "prettier/prettier": "error", 17 | "@typescript-eslint/explicit-function-return-type": "off", 18 | "@typescript-eslint/ban-ts-ignore": "off", 19 | "@typescript-eslint/camelcase": "off", 20 | "@typescript-eslint/no-explicit-any": "off" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'npm' 4 | directory: '/' 5 | schedule: 6 | interval: 'daily' 7 | allow: 8 | - dependency-name: '@snapshot-labs/*' 9 | -------------------------------------------------------------------------------- /.github/workflows/create-sentry-release.yml: -------------------------------------------------------------------------------- 1 | name: Create a Sentry release 2 | on: 3 | push: 4 | branches: 5 | - 'master' 6 | jobs: 7 | create-sentry-release: 8 | uses: snapshot-labs/actions/.github/workflows/create-sentry-release.yml@main 9 | with: 10 | project: stamp 11 | secrets: inherit 12 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | on: [push] 3 | jobs: 4 | lint: 5 | uses: snapshot-labs/actions/.github/workflows/lint.yml@main 6 | secrets: inherit 7 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test: 7 | name: Test 8 | runs-on: ubuntu-latest 9 | timeout-minutes: 2 10 | env: 11 | D3_API_KEY_MAINNET: ${{ secrets.D3_API_KEY_MAINNET }} 12 | D3_API_KEY_TESTNET: ${{ secrets.D3_API_KEY_TESTNET }} 13 | 14 | steps: 15 | - uses: actions/checkout@v3 16 | - uses: actions/setup-node@v3 17 | with: 18 | node-version: '16' 19 | cache: 'yarn' 20 | 21 | - name: Setup Redis 22 | uses: supercharge/redis-github-action@1.7.0 23 | with: 24 | redis-version: '7' 25 | 26 | - name: Yarn install 27 | run: yarn install --frozen-lockfile 28 | 29 | - name: Test 30 | run: yarn test 31 | 32 | - name: Upload coverage reports to Codecov 33 | uses: codecov/codecov-action@v3 34 | with: 35 | token: ${{ secrets.CODECOV_TOKEN }} 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | dist 4 | build 5 | .env 6 | coverage 7 | 8 | # Remove some common IDE working directories 9 | .idea 10 | .vscode 11 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "printWidth": 100, 5 | "tabWidth": 2 6 | } 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Snapshot Labs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Stamp [![codecov](https://codecov.io/gh/snapshot-labs/stamp/branch/master/graph/badge.svg?token=N9IMKE41RA)](https://codecov.io/gh/snapshot-labs/stamp) 2 | 3 | Resolve and resize web3 avatar and token images. 4 | 5 | ### Usage 6 | 7 | Simply use a valid Stamp URL to display an avatar: 8 | 9 | https://cdn.stamp.fyi/avatar/0xeF8305E140ac520225DAf050e2f71d5fBcC543e7?s=200 10 | 11 | ``` 12 | 13 | ``` 14 | 15 | ### URL structure 16 | 17 | cdn.stamp.fyi/**{type}**/**{identifier}**?**{params}** 18 | cdn.stamp.fyi/**avatar**/**0xeF8305E140ac520225DAf050e2f71d5fBcC543e7**?**s=200** 19 | 20 | ### Type 21 | 22 | The type is either avatar or token. 23 | 24 | #### Examples 25 | 26 | cdn.stamp.fyi/**avatar**/0xeF8305E140ac520225DAf050e2f71d5fBcC543e7 27 | cdn.stamp.fyi/**token**/0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e 28 | 29 | ### Identifier 30 | 31 | The identifier can be an address (case insensitive), ENS name, CAIP-10, EIP-3770, DID or Starknet domain. 32 | 33 | #### Examples 34 | 35 | cdn.stamp.fyi/avatar/**0xeF8305E140ac520225DAf050e2f71d5fBcC543e7** 36 | cdn.stamp.fyi/avatar/**fabien.eth** 37 | cdn.stamp.fyi/token/**eth:0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e** 38 | cdn.stamp.fyi/token/**eip155:1:0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e** 39 | cdn.stamp.fyi/avatar/**checkpoint.stark** 40 | cdn.stamp.fyi/avatar/**0x07FF6B17f07c4d83236E3Fc5f94259A19D1Ed41bBCf1822397EA17882E9b038d** 41 | 42 | ### Params 43 | 44 | With the params you can define the size of the image returned. You can also refresh the image cache using **cb** param. 45 | 46 | #### Examples 47 | 48 | cdn.stamp.fyi/avatar/0xeF8305E140ac520225DAf050e2f71d5fBcC543e7?**s=160** 49 | cdn.stamp.fyi/avatar/0xeF8305E140ac520225DAf050e2f71d5fBcC543e7?**w=160&h=240** 50 | cdn.stamp.fyi/avatar/0xeF8305E140ac520225DAf050e2f71d5fBcC543e7?**cb=1** 51 | 52 | ### Resolvers 53 | 54 | #### [ENS avatar](/src/resolvers/ens.ts) 55 | 56 | #### [Lens](/src/resolvers/lens.ts) 57 | 58 | #### [Self.ID](/src/resolvers/selfid.ts) 59 | 60 | #### [Snapshot](/src/resolvers/snapshot.ts) 61 | 62 | #### [TrustWallet Assets Info](/src/resolvers/trustwallet.ts) 63 | 64 | #### [Blockie](/src/resolvers/blockie.ts) 65 | 66 | #### [Jazzicon](/src/resolvers/jazzicon.ts) 67 | 68 | #### [Starknet](/src/resolvers/starknet.ts) 69 | 70 | #### [Farcaster](/src/resolvers/farcaster.ts) 71 | 72 | ### Integrations 73 | 74 | #### [Snapshot](http://snapshot.org) 75 | 76 | #### [Parcel](https://parcel.money) 77 | 78 | #### [Hey](https://hey.xyz) 79 | 80 | #### [RSS3](https://rss3.io) 81 | 82 | #### [Cirip](https://cirip.io) 83 | 84 | #### [Tape](https://tape.xyz) 85 | 86 | #### [You?](https://github.com/snapshot-labs/stamp/edit/master/README.md) 87 | 88 | ### License 89 | 90 | [MIT](LICENSE). 91 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * For a detailed explanation regarding each configuration property and type check, visit: 3 | * https://jestjs.io/docs/configuration 4 | */ 5 | 6 | export default { 7 | clearMocks: true, 8 | collectCoverage: true, 9 | coverageDirectory: 'coverage', 10 | coverageProvider: 'v8', 11 | collectCoverageFrom: ['./src/**'], 12 | coveragePathIgnorePatterns: ['/node_modules/', '/dist/', '/test/fixtures/'], 13 | 14 | preset: 'ts-jest', 15 | testEnvironment: 'node', 16 | setupFiles: ['dotenv/config'], 17 | setupFilesAfterEnv: ['/test/setup-jest.ts'], 18 | testPathIgnorePatterns: ['/node_modules/', '/dist/', '/test/fixtures/'], 19 | moduleFileExtensions: ['js', 'ts'] 20 | }; 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stamp", 3 | "version": "0.1.0", 4 | "license": "MIT", 5 | "scripts": { 6 | "generate-chains": "node ./dist/src/scripts/generate-chains.js", 7 | "lint:fix": "yarn lint --fix", 8 | "lint": "eslint src/ test/ --ext .ts", 9 | "typecheck": "tsc --noEmit", 10 | "build": "tsc", 11 | "dev": "nodemon src/index.ts", 12 | "start": "node dist/src/index.js", 13 | "start:test": "dotenv -e test/.env.test yarn dev", 14 | "test": "PORT=3003 start-server-and-test 'yarn start:test' http://localhost:3003 'dotenv -e test/.env.test yarn jest'", 15 | "test:integration": "dotenv -e test/.env.test yarn jest --runInBand --collectCoverage=false test/integration", 16 | "test:e2e": "PORT=3003 start-server-and-test 'yarn start:test' http://localhost:3003 'dotenv -e test/.env.test jest --runInBand --collectCoverage=false test/e2e/'" 17 | }, 18 | "dependencies": { 19 | "@adraffy/ens-normalize": "^1.10.0", 20 | "@aws-sdk/client-s3": "^3.352.0", 21 | "@download/blockies": "^1.0.3", 22 | "@ethersproject/address": "^5.7.0", 23 | "@ethersproject/bignumber": "^5.7.0", 24 | "@ethersproject/contracts": "^5.7.0", 25 | "@ethersproject/providers": "^5.7.2", 26 | "@metamask/jazzicon": "^2.0.0", 27 | "@self.id/core": "^0.3.0", 28 | "@snapshot-labs/snapshot-metrics": "^1.4.1", 29 | "@snapshot-labs/snapshot-sentry": "^1.5.5", 30 | "@snapshot-labs/snapshot.js": "^0.14.3", 31 | "@unstoppabledomains/resolution": "^9.2.2", 32 | "@webinterop/dns-connect": "^0.3.1", 33 | "axios": "^0.25.0", 34 | "canvas": "^2.9.0", 35 | "compression": "^1.7.4", 36 | "cors": "^2.8.5", 37 | "dotenv": "^16.0.0", 38 | "dotenv-cli": "^7.3.0", 39 | "eslint": "^6.7.2", 40 | "express": "^4.17.1", 41 | "jsdom": "^19.0.0", 42 | "node-fetch": "v2.7.0", 43 | "nodemon": "^2.0.7", 44 | "redis": "^4.6.10", 45 | "sharp": "^0.30.1", 46 | "starknet": "^6.11.0", 47 | "ts-node": "^10.8.1", 48 | "typescript": "^4.7.3" 49 | }, 50 | "devDependencies": { 51 | "@types/express": "^4.17.11", 52 | "@types/jest": "^28.1.0", 53 | "@types/node": "^14.14.21", 54 | "@typescript-eslint/eslint-plugin": "^2.33.0", 55 | "@typescript-eslint/parser": "^2.33.0", 56 | "eslint-plugin-prettier": "^3.1.3", 57 | "jest": "^28.1.0", 58 | "prettier": "^1.19.1", 59 | "start-server-and-test": "^2.0.3", 60 | "ts-jest": "^28.0.4" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/addressResolvers/cache.ts: -------------------------------------------------------------------------------- 1 | import redis from '../helpers/redis'; 2 | import constants from '../constants.json'; 3 | import { addressResolversCacheHitCount } from '../helpers/metrics'; 4 | 5 | export const KEY_PREFIX = 'address-resolvers'; 6 | 7 | export async function getCache(keys: string[]): Promise> { 8 | if (!redis) return {}; 9 | 10 | const transaction = redis.multi(); 11 | keys.map(key => transaction.get(`${KEY_PREFIX}:${key}`)); 12 | const results = await transaction.exec(); 13 | 14 | return Object.fromEntries( 15 | keys.map((key, index) => [key, results[index]]).filter(([, value]) => value !== null) 16 | ); 17 | } 18 | 19 | export function setCache(payload: Record) { 20 | if (!redis) return; 21 | 22 | const transaction = redis.multi(); 23 | Object.entries(payload).map(([key, value]) => 24 | transaction.set(`${KEY_PREFIX}:${key}`, value || '', { EX: constants.ttl }) 25 | ); 26 | 27 | return transaction.exec(); 28 | } 29 | 30 | export default async function cache(input: string[], callback) { 31 | const cache = await getCache(input); 32 | const cachedKeys = Object.keys(cache); 33 | const uncachedInputs = input.filter(a => !cachedKeys.includes(a)); 34 | 35 | addressResolversCacheHitCount.inc({ status: 'MISS' }, uncachedInputs.length); 36 | addressResolversCacheHitCount.inc({ status: 'HIT' }, cachedKeys.length); 37 | 38 | if (uncachedInputs.length > 0) { 39 | const results = await callback(uncachedInputs); 40 | setCache(results); 41 | 42 | return { ...cache, ...results }; 43 | } 44 | 45 | return cache; 46 | } 47 | 48 | export async function clear(input: string): Promise { 49 | // TODO: When redis is not available, it should probably throw instead of returning false 50 | // causing the api the return "failed to clear cache" instead of "not found" 51 | if (!redis) return false; 52 | 53 | return (await redis?.del(`${KEY_PREFIX}:${input}`)) > 0; 54 | } 55 | -------------------------------------------------------------------------------- /src/addressResolvers/ens.ts: -------------------------------------------------------------------------------- 1 | import { getAddress } from '@ethersproject/address'; 2 | import snapshot from '@snapshot-labs/snapshot.js'; 3 | import { capture } from '@snapshot-labs/snapshot-sentry'; 4 | import { ens_normalize } from '@adraffy/ens-normalize'; 5 | import { provider as getProvider, isSilencedError, FetchError, isEvmAddress } from './utils'; 6 | import { Address, graphQlCall, Handle } from '../utils'; 7 | import constants from '../constants.json'; 8 | 9 | export const NAME = 'Ens'; 10 | const NETWORK = '1'; 11 | const provider = getProvider(NETWORK); 12 | 13 | function normalizeEns(names: Handle[]): Handle[] { 14 | return names.map(name => { 15 | try { 16 | return ens_normalize(name) === name ? name : ''; 17 | } catch (e) { 18 | return ''; 19 | } 20 | }); 21 | } 22 | 23 | function normalizeAddresses(addresses: Address[]): Address[] { 24 | return addresses.filter(isEvmAddress); 25 | } 26 | 27 | function normalizeHandles(names: Handle[]): Handle[] { 28 | return normalizeEns(names).filter(h => h); 29 | } 30 | 31 | export async function lookupAddresses(addresses: Address[]): Promise> { 32 | const abi = ['function getNames(address[] addresses) view returns (string[] r)']; 33 | const normalizedAddresses = normalizeAddresses(addresses); 34 | 35 | if (normalizedAddresses.length === 0) return {}; 36 | 37 | try { 38 | const reverseRecords = await snapshot.utils.call( 39 | provider, 40 | abi, 41 | ['0x3671aE578E63FdF66ad4F3E12CC0c0d71Ac7510C', 'getNames', [normalizedAddresses]], 42 | { blockTag: 'latest' } 43 | ); 44 | const validNames = normalizeEns(reverseRecords); 45 | 46 | return Object.fromEntries( 47 | normalizedAddresses 48 | .map((address, index) => [address, validNames[index]]) 49 | .filter((_, index) => !!validNames[index]) 50 | ); 51 | } catch (e) { 52 | if (!isSilencedError(e)) { 53 | capture(e, { input: { addresses: normalizedAddresses } }); 54 | } 55 | throw new FetchError(); 56 | } 57 | } 58 | 59 | export async function resolveNames(handles: Handle[]): Promise> { 60 | const normalizedHandles = normalizeHandles(handles); 61 | 62 | if (normalizedHandles.length === 0) return {}; 63 | 64 | const results = {}; 65 | 66 | try { 67 | const { 68 | data: { 69 | data: { domains: items } 70 | } 71 | } = await graphQlCall( 72 | constants.ensSubgraph[NETWORK], 73 | `query Domains($handles: [String!]!) { 74 | domains(where: {name_in: $handles}) { 75 | name 76 | resolvedAddress { 77 | id 78 | } 79 | } 80 | }`, 81 | { handles: normalizedHandles } 82 | ); 83 | 84 | for (const item of items) { 85 | results[item.name] = item.resolvedAddress ? getAddress(item.resolvedAddress.id) : ''; 86 | } 87 | } catch (e) { 88 | if (!isSilencedError(e)) { 89 | capture(e, { input: { handles: normalizedHandles } }); 90 | } 91 | } 92 | 93 | const unresolvedHandles = normalizedHandles.filter(handle => !results[handle]); 94 | 95 | if (unresolvedHandles.length === 0) return results; 96 | 97 | try { 98 | const providerResults = await Promise.allSettled( 99 | unresolvedHandles.map(handle => provider.resolveName(handle)) 100 | ); 101 | 102 | unresolvedHandles.forEach((handle, index) => { 103 | const result = providerResults[index]; 104 | if (result.status === 'fulfilled' && result.value) { 105 | results[handle] = getAddress(result.value); 106 | } 107 | }); 108 | } catch (e) { 109 | if (!isSilencedError(e)) { 110 | capture(e, { input: { handles: normalizedHandles } }); 111 | } 112 | } 113 | 114 | return results; 115 | } 116 | -------------------------------------------------------------------------------- /src/addressResolvers/index.ts: -------------------------------------------------------------------------------- 1 | import * as ensResolver from './ens'; 2 | import * as lensResolver from './lens'; 3 | import * as unstoppableDomainResolver from './unstoppableDomains'; 4 | import * as starknetResolver from './starknet'; 5 | import * as snapshotResolver from './snapshot'; 6 | import * as shibariumResolver from './shibarium'; 7 | import cache, { clear } from './cache'; 8 | import { 9 | normalizeAddresses, 10 | normalizeHandles, 11 | withoutEmptyValues, 12 | mapOriginalInput, 13 | withoutEmptyAddress 14 | } from './utils'; 15 | import { Address, Handle } from '../utils'; 16 | import { timeAddressResolverResponse as timeResponse } from '../helpers/metrics'; 17 | 18 | const RESOLVERS = [ 19 | snapshotResolver, 20 | ensResolver, 21 | unstoppableDomainResolver, 22 | lensResolver, 23 | starknetResolver, 24 | shibariumResolver 25 | ]; 26 | const MAX_LOOKUP_ADDRESSES = 50; 27 | const MAX_RESOLVE_NAMES = 5; 28 | 29 | async function _call(fnName: string, input: string[], maxInputLength: number) { 30 | if (input.length > maxInputLength) { 31 | return Promise.reject({ 32 | error: `params must contains less than ${maxInputLength} items`, 33 | code: 400 34 | }); 35 | } 36 | 37 | if (input.length === 0) return {}; 38 | 39 | return withoutEmptyAddress( 40 | withoutEmptyValues( 41 | await cache(input, async (_input: string[]) => { 42 | const results = await Promise.all( 43 | RESOLVERS.map(async r => { 44 | const end = timeResponse.startTimer({ 45 | provider: r.NAME, 46 | method: fnName 47 | }); 48 | let result = {}; 49 | let status = 0; 50 | 51 | try { 52 | result = await r[fnName](_input); 53 | status = 1; 54 | } catch (e) {} 55 | end({ status }); 56 | 57 | return result; 58 | }) 59 | ); 60 | 61 | return Object.fromEntries( 62 | _input.map(item => [item, results.map(r => r[item]).filter(i => !!i)[0] || '']) 63 | ); 64 | }) 65 | ) 66 | ); 67 | } 68 | 69 | export async function lookupAddresses(addresses: Address[]): Promise> { 70 | const result = await _call( 71 | 'lookupAddresses', 72 | Array.from(new Set(normalizeAddresses(addresses))), 73 | MAX_LOOKUP_ADDRESSES 74 | ); 75 | 76 | return mapOriginalInput(addresses, result); 77 | } 78 | 79 | export async function resolveNames(handles: Handle[]): Promise> { 80 | const result = await _call( 81 | 'resolveNames', 82 | Array.from(new Set(normalizeHandles(handles))), 83 | MAX_RESOLVE_NAMES 84 | ); 85 | 86 | return mapOriginalInput(handles, result); 87 | } 88 | 89 | export function clearCache(input: string): Promise { 90 | return clear(normalizeAddresses([input])[0]); 91 | } 92 | -------------------------------------------------------------------------------- /src/addressResolvers/lens.ts: -------------------------------------------------------------------------------- 1 | import { capture } from '@snapshot-labs/snapshot-sentry'; 2 | import { FetchError, isSilencedError, isEvmAddress } from './utils'; 3 | import { graphQlCall, Address, Handle } from '../utils'; 4 | 5 | export const NAME = 'Lens'; 6 | const API_URL = 'https://api.lens.xyz/graphql'; 7 | // mute not fixable errors, since it's a public API 8 | const MUTED_ERRORS = ['status code 503', 'status code 429']; 9 | 10 | async function apiCall(filterName: string, filters: string[]) { 11 | const filterValue = 12 | filterName === 'addresses' ? filters : filters.map(h => ({ localName: h.replace(/"/g, '') })); 13 | 14 | const query = `query AccountBulk($request: AccountsBulkRequest!) { 15 | accountsBulk(request: $request) { 16 | username { 17 | localName 18 | ownedBy 19 | } 20 | } 21 | }`; 22 | 23 | const { 24 | data: { 25 | data: { accountsBulk } 26 | } 27 | } = await graphQlCall(API_URL, query, { request: { [filterName]: filterValue } }); 28 | 29 | return accountsBulk; 30 | } 31 | 32 | function normalizeAddresses(addresses: Address[]): Address[] { 33 | return addresses.filter(isEvmAddress); 34 | } 35 | 36 | function normalizeHandles(handles: Handle[]): Handle[] { 37 | return handles 38 | .map(h => (/^[a-z0-9-_]{5,31}\.lens$/.test(h) ? h.replace(/\.lens$/, '') : '')) 39 | .filter(h => h); 40 | } 41 | 42 | export async function lookupAddresses(addresses: Address[]): Promise> { 43 | const normalizedAddresses = normalizeAddresses(addresses); 44 | 45 | if (normalizedAddresses.length === 0) return {}; 46 | 47 | try { 48 | const accounts = await apiCall('addresses', normalizedAddresses); 49 | 50 | return ( 51 | Object.fromEntries( 52 | accounts 53 | .filter(i => i.username) 54 | .map(i => [i.username.ownedBy, `${i.username.localName}.lens`]) 55 | ) || {} 56 | ); 57 | } catch (e) { 58 | if (!isSilencedError(e, MUTED_ERRORS)) { 59 | capture(e, { input: { addresses: normalizedAddresses } }); 60 | } 61 | 62 | throw new FetchError(); 63 | } 64 | } 65 | 66 | export async function resolveNames(handles: Handle[]): Promise> { 67 | const normalizedHandles = normalizeHandles(handles); 68 | 69 | if (normalizedHandles.length === 0) return {}; 70 | 71 | try { 72 | const accounts = await apiCall('usernames', normalizedHandles); 73 | 74 | return ( 75 | Object.fromEntries(accounts.map(i => [`${i.username.localName}.lens`, i.username.ownedBy])) || 76 | {} 77 | ); 78 | } catch (e) { 79 | if (!isSilencedError(e, MUTED_ERRORS)) { 80 | capture(e, { input: { handles: normalizedHandles } }); 81 | } 82 | throw new FetchError(); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/addressResolvers/shibarium.ts: -------------------------------------------------------------------------------- 1 | import { capture } from '@snapshot-labs/snapshot-sentry'; 2 | import { Address, Handle } from '../utils'; 3 | import { FetchError, isSilencedError, withoutEmptyValues } from './utils'; 4 | import { DNSConnect } from '@webinterop/dns-connect'; 5 | import constants from '../constants.json'; 6 | 7 | export const NAME = 'Shibarium'; 8 | const CHAIN_ID = '109'; 9 | const NETWORK = 'BONE'; 10 | const TLD = 'shib'; 11 | 12 | // TODO: Support unicode names, by converting to punycode 13 | // see https://docs.d3.app/resolve-d3-names#d3-connect-sdk 14 | function normalizeHandles(handles: Handle[]): Handle[] { 15 | return handles.filter(handle => handle.endsWith(`.${TLD}`)); 16 | } 17 | 18 | export async function lookupAddresses(addresses: Address[]): Promise> { 19 | try { 20 | const dnsConnect = new DNSConnect({ 21 | dns: { forwarderDomain: constants.d3[CHAIN_ID].forwarder } 22 | }); 23 | 24 | const results = await Promise.all( 25 | addresses.map(async address => dnsConnect.reverseResolve(address, NETWORK)) 26 | ); 27 | 28 | return withoutEmptyValues( 29 | Object.fromEntries(addresses.map((address, index) => [address, results[index]])) 30 | ); 31 | } catch (e) { 32 | if (!isSilencedError(e)) { 33 | capture(e, { input: { addresses } }); 34 | } 35 | throw new FetchError(); 36 | } 37 | } 38 | 39 | export async function resolveNames(handles: Handle[]): Promise> { 40 | try { 41 | const normalizedHandles = normalizeHandles(handles); 42 | 43 | if (normalizedHandles.length === 0) return {}; 44 | 45 | const dnsConnect = new DNSConnect({ 46 | dns: { forwarderDomain: constants.d3[CHAIN_ID].forwarder } 47 | }); 48 | 49 | const results = await Promise.all( 50 | normalizedHandles.map(async handle => dnsConnect.resolve(handle, NETWORK)) 51 | ); 52 | 53 | return withoutEmptyValues( 54 | Object.fromEntries(normalizedHandles.map((handle, index) => [handle, results[index]])) 55 | ); 56 | } catch (e) { 57 | if (!isSilencedError(e)) { 58 | capture(e, { input: { handles } }); 59 | } 60 | throw new FetchError(); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/addressResolvers/snapshot.ts: -------------------------------------------------------------------------------- 1 | import { capture } from '@snapshot-labs/snapshot-sentry'; 2 | import { Address, graphQlCall, Handle } from '../utils'; 3 | import { FetchError, isSilencedError } from './utils'; 4 | 5 | const HUB_URL = process.env.HUB_URL ?? 'https://hub.snapshot.org'; 6 | export const NAME = 'Snapshot'; 7 | 8 | export async function lookupAddresses(addresses: Address[]): Promise> { 9 | try { 10 | const { 11 | data: { 12 | data: { users } 13 | } 14 | } = await graphQlCall( 15 | `${HUB_URL}/graphql`, 16 | `query users($addresses: [String!]!) { 17 | users(where: {id_in: $addresses}) { 18 | id 19 | name 20 | } 21 | }`, 22 | { addresses }, 23 | { 24 | headers: { 'x-api-key': process.env.HUB_API_KEY } 25 | } 26 | ); 27 | 28 | return Object.fromEntries( 29 | users.filter((user: any) => user.name).map((user: any) => [user.id, user.name]) 30 | ); 31 | } catch (e) { 32 | if (!isSilencedError(e)) { 33 | capture(e, { input: { addresses } }); 34 | } 35 | throw new FetchError(); 36 | } 37 | } 38 | 39 | export async function resolveNames(): Promise> { 40 | return {}; 41 | } 42 | -------------------------------------------------------------------------------- /src/addressResolvers/starknet.ts: -------------------------------------------------------------------------------- 1 | import { capture } from '@snapshot-labs/snapshot-sentry'; 2 | import { withoutEmptyValues, isSilencedError, FetchError, isStarknetAddress } from './utils'; 3 | import { Address, Handle } from '../utils'; 4 | import axios from 'axios'; 5 | 6 | export const NAME = 'Starknet'; 7 | const BASE_URL = 'https://api.starknet.id'; 8 | 9 | type RESOLVE_TYPE = 'addr_to_domain' | 'domain_to_addr'; 10 | 11 | function buildApiUrl(resolve_type: RESOLVE_TYPE, needle: string): string { 12 | return `${BASE_URL}/${resolve_type}?${ 13 | resolve_type === 'addr_to_domain' ? 'addr' : 'domain' 14 | }=${needle}`; 15 | } 16 | 17 | async function apiCall( 18 | resolve_type: RESOLVE_TYPE, 19 | needles: string[] 20 | ): Promise> { 21 | const requests = needles.map(needle => 22 | axios.get(buildApiUrl(resolve_type, needle), { timeout: 5e3 }) 23 | ); 24 | const responses = await Promise.allSettled(requests); 25 | 26 | return withoutEmptyValues( 27 | Object.fromEntries( 28 | needles.map((needle, i) => { 29 | const response = responses[i]; 30 | let value: string | undefined; 31 | 32 | if (response.status === 'fulfilled') { 33 | value = response.value.data[resolve_type === 'addr_to_domain' ? 'domain' : 'addr']; 34 | } 35 | 36 | return [needle, value]; 37 | }) 38 | ) 39 | ); 40 | } 41 | 42 | function normalizeAddresses(addresses: Address[]): Address[] { 43 | return addresses.filter(isStarknetAddress); 44 | } 45 | 46 | function normalizeHandles(handles: Handle[]): Handle[] { 47 | return handles.filter(h => h.endsWith('.stark')); 48 | } 49 | 50 | export async function lookupAddresses(addresses: Address[]): Promise> { 51 | const normalizedAddresses = normalizeAddresses(addresses); 52 | 53 | if (normalizedAddresses.length === 0) return {}; 54 | 55 | try { 56 | return await apiCall('addr_to_domain', normalizedAddresses); 57 | } catch (e) { 58 | if (!isSilencedError(e)) { 59 | capture(e, { input: { addresses: normalizedAddresses } }); 60 | } 61 | throw new FetchError(); 62 | } 63 | } 64 | 65 | export async function resolveNames(handles: Handle[]): Promise> { 66 | const normalizedHandles = normalizeHandles(handles); 67 | 68 | if (normalizedHandles.length === 0) return {}; 69 | 70 | try { 71 | return await apiCall('domain_to_addr', normalizedHandles); 72 | } catch (e) { 73 | if (!isSilencedError(e)) { 74 | capture(e, { input: { handles: normalizedHandles } }); 75 | } 76 | throw new FetchError(); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/addressResolvers/unstoppableDomains.ts: -------------------------------------------------------------------------------- 1 | import snapshot from '@snapshot-labs/snapshot.js'; 2 | import Resolution, { NamingServiceName } from '@unstoppabledomains/resolution'; 3 | import { capture } from '@snapshot-labs/snapshot-sentry'; 4 | import { 5 | provider as getProvider, 6 | withoutEmptyValues, 7 | isSilencedError, 8 | FetchError, 9 | isEvmAddress 10 | } from './utils'; 11 | import { Address, Handle } from '../utils'; 12 | 13 | export const NAME = 'Unstoppable Domains'; 14 | const NETWORK = '137'; 15 | const provider = getProvider(NETWORK, { timeout: 5e3 }); 16 | const ABI = [ 17 | 'function reverseNameOf(address addr) view returns (string reverseUri)', 18 | 'function ownerOf(uint256 tokenId) external view returns (address address)' 19 | ]; 20 | const CONTRACT_ADDRESS = '0xa9a6A3626993D487d2Dbda3173cf58cA1a9D9e9f'; 21 | 22 | function normalizeAddresses(addresses: Address[]): Address[] { 23 | return addresses.filter(isEvmAddress); 24 | } 25 | 26 | function normalizeHandles(handles: Handle[]): Handle[] { 27 | return handles.map(h => (/^[.a-z0-9-]+$/.test(h) ? h : '')).filter(h => h); 28 | } 29 | 30 | export async function lookupAddresses(addresses: Address[]): Promise> { 31 | const normalizedAddresses = normalizeAddresses(addresses); 32 | 33 | if (normalizedAddresses.length === 0) return {}; 34 | 35 | try { 36 | const multi = new snapshot.utils.Multicaller(NETWORK, provider, ABI); 37 | normalizedAddresses.forEach(address => 38 | multi.call(address, CONTRACT_ADDRESS, 'reverseNameOf', [address]) 39 | ); 40 | 41 | const names = (await multi.execute()) as Record; 42 | 43 | return withoutEmptyValues(names); 44 | } catch (e) { 45 | if (!isSilencedError(e)) { 46 | capture(e, { input: { addresses, normalizedAddresses } }); 47 | } 48 | throw new FetchError(); 49 | } 50 | } 51 | 52 | export async function resolveNames(handles: Handle[]): Promise> { 53 | const normalizedHandles = normalizeHandles(handles); 54 | 55 | if (normalizedHandles.length === 0) return {}; 56 | 57 | try { 58 | const results = await Promise.all( 59 | normalizedHandles.map(async handle => { 60 | try { 61 | const tokenId = new Resolution().namehash(handle, NamingServiceName.UNS); 62 | return await snapshot.utils.call( 63 | provider, 64 | ABI, 65 | [CONTRACT_ADDRESS, 'ownerOf', [tokenId]], 66 | { blockTag: 'latest' } 67 | ); 68 | } catch (e) { 69 | if (!isSilencedError(e)) { 70 | capture(e, { input: { handles: normalizedHandles } }); 71 | } 72 | return; 73 | } 74 | }) 75 | ); 76 | 77 | return withoutEmptyValues( 78 | Object.fromEntries(normalizedHandles.map((handle, index) => [handle, results[index]])) 79 | ); 80 | } catch (e) { 81 | if (!isSilencedError(e)) { 82 | capture(e, { input: { handles: normalizedHandles } }); 83 | } 84 | throw new FetchError(); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/addressResolvers/utils.ts: -------------------------------------------------------------------------------- 1 | import snapshot from '@snapshot-labs/snapshot.js'; 2 | import { getAddress } from '@ethersproject/address'; 3 | import { Address, Handle, EMPTY_ADDRESS } from '../utils'; 4 | 5 | const broviderUrl = process.env.BROVIDER_URL || 'https://rpc.snapshot.org'; 6 | 7 | export class FetchError extends Error {} 8 | 9 | export function isEvmAddress(address: Address): boolean { 10 | return /^0x[a-fA-F0-9]{40}$/.test(address); 11 | } 12 | 13 | export function isStarknetAddress(address: Address): boolean { 14 | return /^0x[a-fA-F0-9]{64}$/.test(address); 15 | } 16 | 17 | export function provider( 18 | network: string, 19 | providerOptions: { broviderUrl?: string; timeout?: number } = { broviderUrl, timeout: 5e3 } 20 | ) { 21 | return snapshot.utils.getProvider(network, providerOptions); 22 | } 23 | 24 | export function withoutEmptyValues(obj: Record) { 25 | return Object.fromEntries(Object.entries(obj).filter(([, value]) => value)); 26 | } 27 | 28 | export function withoutEmptyAddress(obj: Record) { 29 | return Object.fromEntries(Object.entries(obj).filter(([key]) => key !== EMPTY_ADDRESS)); 30 | } 31 | 32 | export function normalizeAddresses(addresses: Address[]): Address[] { 33 | return addresses 34 | .map(a => { 35 | if (isStarknetAddress(a)) { 36 | return a.toLowerCase(); 37 | } 38 | try { 39 | return getAddress(a.toLowerCase()); 40 | } catch (e) {} 41 | }) 42 | .filter(a => a) as Address[]; 43 | } 44 | 45 | export function normalizeHandles(handles: Handle[]): Handle[] { 46 | return handles.filter(h => /^[^\s]*\.[^\s]*$/.test(h)).map(h => h.toLowerCase()); 47 | } 48 | 49 | export function isSilencedError(error: any, additionalMessages?: string[]): boolean { 50 | return ( 51 | [ 52 | 'invalid token ID', 53 | 'is not supported', 54 | 'execution reverted', 55 | 'status=504', 56 | ...(additionalMessages || []) 57 | ].some(m => error.message?.includes(m)) || 58 | ['TIMEOUT', 'ECONNABORTED', 'ETIMEDOUT', 'ECONNRESET', 504].some(c => 59 | (error.error?.code || error.error?.status || error.code)?.includes(c) 60 | ) 61 | ); 62 | } 63 | 64 | export function mapOriginalInput( 65 | input: string[], 66 | results: Record 67 | ): Record { 68 | const inputLc = input.map(i => i?.toLowerCase()); 69 | const resultLc = Object.fromEntries( 70 | Object.entries(results).map(([key, value]) => [key.toLowerCase(), value]) 71 | ); 72 | 73 | return withoutEmptyValues( 74 | Object.fromEntries( 75 | inputLc.map((key, index) => { 76 | return [input[index], resultLc[key]]; 77 | }) 78 | ) 79 | ); 80 | } 81 | -------------------------------------------------------------------------------- /src/api.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { capture } from '@snapshot-labs/snapshot-sentry'; 3 | import { parseQuery, resize, setHeader, getCacheKey, ResolverType } from './utils'; 4 | import { set, get, streamToBuffer, clear } from './aws'; 5 | import resolvers from './resolvers'; 6 | import constants from './constants.json'; 7 | import { rpcError, rpcSuccess } from './helpers/utils'; 8 | import { lookupAddresses, resolveNames, clearCache } from './addressResolvers'; 9 | import lookupDomains from './lookupDomains'; 10 | import getOwner from './getOwner'; 11 | 12 | const router = express.Router(); 13 | const TYPE_CONSTRAINTS = [...Object.keys(constants.resolvers), 'address', 'name'].join('|'); 14 | 15 | router.post('/', async (req, res) => { 16 | const { id = null, method, params } = req.body; 17 | if (!method) return rpcError(res, 400, 'missing method', id); 18 | try { 19 | let result: any = {}; 20 | 21 | if (method === 'lookup_domains') { 22 | result = await lookupDomains(params, req.body.network); 23 | } else if (method === 'get_owner') { 24 | result = await getOwner(params, req.body.network); 25 | } else if (['lookup_addresses', 'resolve_names'].includes(method)) { 26 | if (!Array.isArray(params)) 27 | return rpcError(res, 400, 'params must be an array of string', id); 28 | 29 | if (method === 'lookup_addresses') result = await lookupAddresses(params); 30 | else result = await resolveNames(params); 31 | } else return rpcError(res, 400, 'invalid method', id); 32 | 33 | if (result?.error) return rpcError(res, result.code || 500, result.error, id); 34 | return rpcSuccess(res, result, id); 35 | } catch (e) { 36 | const err = e as any; 37 | capture(err.error ? new Error(err.error) : err); 38 | return rpcError(res, 500, e, id); 39 | } 40 | }); 41 | 42 | router.get(`/clear/:type(${TYPE_CONSTRAINTS})/:id`, async (req, res) => { 43 | const { type, id } = req.params as { type: ResolverType; id: string }; 44 | 45 | try { 46 | let result = false; 47 | 48 | if (type === 'address' || type === 'name') { 49 | result = await clearCache(id); 50 | } else { 51 | const { address, network, w, h, fallback, cb, fit } = await parseQuery(id, type, { 52 | s: constants.max, 53 | fb: req.query.fb, 54 | cb: req.query.cb, 55 | fit: req.query.fit 56 | }); 57 | const key = getCacheKey({ type, network, address, w, h, fallback, cb, fit }); 58 | result = await clear(key); 59 | } 60 | res.status(result ? 200 : 404).json({ status: result ? 'ok' : 'not found' }); 61 | } catch (e) { 62 | capture(e); 63 | res.status(500).json({ status: 'error', error: 'failed to clear cache' }); 64 | } 65 | }); 66 | 67 | router.get(`/:type(${TYPE_CONSTRAINTS})/:id`, async (req, res) => { 68 | const { type, id } = req.params as { type: ResolverType; id: string }; 69 | let address, network, networkId, w, h, fallback, cb, resolver, fit; 70 | 71 | try { 72 | ({ address, network, networkId, w, h, fallback, cb, resolver, fit } = await parseQuery( 73 | id, 74 | type, 75 | req.query 76 | )); 77 | } catch (e) { 78 | return res.status(500).json({ status: 'error', error: 'failed to load content' }); 79 | } 80 | 81 | const disableCache = !!resolver; 82 | 83 | const key1 = getCacheKey({ 84 | type, 85 | network, 86 | address, 87 | w: constants.max, 88 | h: constants.max, 89 | fallback, 90 | cb, 91 | fit 92 | }); 93 | const key2 = getCacheKey({ type, network, address, w, h, fallback, cb, fit }); 94 | 95 | // Check resized cache 96 | const cache = await get(`${key1}/${key2}`); 97 | if (cache && !disableCache) { 98 | // console.log('Got cache', address); 99 | setHeader(res); 100 | return cache.pipe(res); 101 | } 102 | 103 | // Check base cache 104 | const base = await get(`${key1}/${key1}`); 105 | let baseImage; 106 | if (base) { 107 | baseImage = await streamToBuffer(base); 108 | // console.log('Got base cache'); 109 | } else { 110 | // console.log('No cache for', key1, base); 111 | 112 | let currentResolvers: string[] = constants.resolvers.avatar; 113 | if (type === 'token') currentResolvers = constants.resolvers.token; 114 | if (type === 'space') currentResolvers = constants.resolvers.space; 115 | if (type === 'space-cover') currentResolvers = constants.resolvers['space-cover']; 116 | if (type === 'space-logo') currentResolvers = constants.resolvers['space-logo']; 117 | if (type === 'space-sx') currentResolvers = constants.resolvers['space-sx']; 118 | if (type === 'space-cover-sx') currentResolvers = constants.resolvers['space-cover-sx']; 119 | if (type === 'user-cover') currentResolvers = constants.resolvers['user-cover']; 120 | 121 | if (resolver) { 122 | if (!currentResolvers.includes(resolver)) { 123 | return res.status(500).json({ status: 'error', error: 'invalid resolvers' }); 124 | } 125 | 126 | currentResolvers = [resolver]; 127 | } 128 | 129 | const files = await Promise.all( 130 | currentResolvers.map(r => resolvers[r](address, network, networkId)) 131 | ); 132 | baseImage = files.find(Boolean); 133 | 134 | if (!baseImage) { 135 | const fallbackImage = await resolvers[fallback](address, network, networkId); 136 | const resizedImage = await resize(fallbackImage, w, h, { fit }); 137 | 138 | setHeader(res, 'SHORT_CACHE'); 139 | return res.send(resizedImage); 140 | } 141 | } 142 | 143 | // Resize and return image 144 | const resizedImage = await resize(baseImage, w, h, { fit }); 145 | setHeader(res); 146 | res.send(resizedImage); 147 | 148 | if (disableCache) return; 149 | 150 | // Store cache 151 | try { 152 | if (!base) { 153 | await set(`${key1}/${key1}`, baseImage); 154 | console.log('Stored base cache', key1); 155 | } 156 | await set(`${key1}/${key2}`, resizedImage); 157 | console.log('Stored cache', address); 158 | } catch (e) { 159 | capture(e); 160 | console.log('Store cache failed', address, e); 161 | } 162 | }); 163 | 164 | export default router; 165 | -------------------------------------------------------------------------------- /src/aws.ts: -------------------------------------------------------------------------------- 1 | import * as AWS from '@aws-sdk/client-s3'; 2 | import { Readable } from 'stream'; 3 | 4 | let client; 5 | const bucket = process.env.AWS_BUCKET_NAME; 6 | const region = process.env.AWS_REGION; 7 | const endpoint = process.env.AWS_ENDPOINT || undefined; 8 | if (region) client = new AWS.S3({ region, endpoint }); 9 | const dir = 'stamp-7'; 10 | 11 | export async function streamToBuffer(stream: Readable) { 12 | return await new Promise((resolve, reject) => { 13 | const chunks: Uint8Array[] = []; 14 | stream.on('data', chunk => chunks.push(Buffer.from(chunk))); 15 | stream.on('error', reject); 16 | stream.on('end', () => resolve(Buffer.concat(chunks))); 17 | }); 18 | } 19 | 20 | export async function set(key, value) { 21 | if (!client) throw new Error('AWS cache not initialized'); 22 | 23 | try { 24 | const command = new AWS.PutObjectCommand({ 25 | Bucket: bucket, 26 | Key: `public/${dir}/${key}`, 27 | Body: value, 28 | ContentType: 'image/webp' 29 | }); 30 | 31 | await client.send(command); 32 | } catch (e) { 33 | console.log('Store cache failed', e); 34 | throw e; 35 | } 36 | } 37 | 38 | export async function clear(path) { 39 | if (!client) throw new Error('AWS cache not initialized'); 40 | 41 | try { 42 | const listedObjects = await client.listObjectsV2({ 43 | Bucket: bucket, 44 | Prefix: `public/${dir}/${path}` 45 | }); 46 | if (!listedObjects.Contents || listedObjects.Contents.length === 0) return false; 47 | const objs = listedObjects.Contents.map(obj => ({ Key: obj.Key })); 48 | await client.deleteObjects({ 49 | Bucket: bucket, 50 | Delete: { Objects: objs } 51 | }); 52 | if (listedObjects.IsTruncated) await clear(path); 53 | console.log('Cleared cache', path); 54 | return path; 55 | } catch (e) { 56 | console.log('Clear cache failed', e); 57 | throw e; 58 | } 59 | } 60 | 61 | export async function get(key) { 62 | try { 63 | const command = new AWS.GetObjectCommand({ 64 | Bucket: bucket, 65 | Key: `public/${dir}/${key}` 66 | }); 67 | 68 | const { Body } = await client.send(command); 69 | 70 | return Body; 71 | } catch (e) { 72 | return false; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/constants.json: -------------------------------------------------------------------------------- 1 | { 2 | "max": 500, 3 | "maxCover": 1500, 4 | "ttl": 43200, 5 | "shortTtl": 3600, 6 | "resolvers": { 7 | "avatar": ["snapshot", "ens", "lens", "farcaster", "starknet"], 8 | "user-cover": ["user-cover"], 9 | "token": ["trustwallet", "zapper", "coingecko"], 10 | "space": ["space"], 11 | "space-cover": ["space-cover"], 12 | "space-logo": ["space-logo"], 13 | "space-sx": ["space-sx"], 14 | "space-cover-sx": ["space-cover-sx"] 15 | }, 16 | "ensSubgraph": { 17 | "1": "https://subgrapher.snapshot.org/subgraph/arbitrum/5XqPmWe6gjyrJtFn9cLy237i4cWw2j9HcUJEXsP5qGtH", 18 | "11155111": "https://subgrapher.snapshot.org/subgraph/arbitrum/DmMXLtMZnGbQXASJ7p1jfzLUbBYnYUD9zNBTxpkjHYXV" 19 | }, 20 | "d3": { 21 | "109": { 22 | "apiUrl": "https://api-public.d3.app", 23 | "forwarder": "vana" 24 | }, 25 | "157": { 26 | "apiUrl": "https://api-public-stage.d3.app", 27 | "forwarder": "stage.d3test.vana" 28 | } 29 | }, 30 | "defaultOffchainNetwork": "s", 31 | "offchainNetworks": ["s", "s-tn"] 32 | } 33 | -------------------------------------------------------------------------------- /src/getOwner/index.ts: -------------------------------------------------------------------------------- 1 | import { Address, Handle } from '../utils'; 2 | import shibarium from './shibarium'; 3 | 4 | export default async function getOwner(handle: Handle, chainId = '1'): Promise
{ 5 | return shibarium(handle, chainId); 6 | } 7 | -------------------------------------------------------------------------------- /src/getOwner/shibarium.ts: -------------------------------------------------------------------------------- 1 | import { DNSConnect } from '@webinterop/dns-connect'; 2 | import { Address, EMPTY_ADDRESS, Handle } from '../utils'; 3 | import constants from '../constants.json'; 4 | 5 | const MAINNET = '109'; 6 | const TESTNET = '157'; 7 | const TLD = 'shib'; 8 | const NETWORK = 'BONE'; 9 | 10 | const API_KEYS = { 11 | [MAINNET]: process.env.D3_API_KEY_MAINNET, 12 | [TESTNET]: process.env.D3_API_KEY_TESTNET 13 | }; 14 | 15 | async function getClaimedOwner(handle: Handle, chainId: string): Promise
{ 16 | if (!handle.endsWith(`.${TLD}`) || !constants.d3[chainId]?.apiUrl || !API_KEYS[chainId]) 17 | return EMPTY_ADDRESS; 18 | 19 | const response = await fetch( 20 | `${constants.d3[chainId].apiUrl}/v1/partner/token/${handle.replace(/\.shib$/, '')}/${TLD}`, 21 | { 22 | method: 'GET', 23 | headers: { 24 | accept: 'application/json', 25 | 'Api-Key': API_KEYS[chainId] 26 | } 27 | } 28 | ); 29 | 30 | if (response.status === 404) return EMPTY_ADDRESS; 31 | if (!response.ok) throw new Error(`Error fetching owner: ${response.statusText}`); 32 | 33 | const data = await response.json(); 34 | 35 | if (data.status !== 'registered' || new Date(data.expirationDate) < new Date()) { 36 | return EMPTY_ADDRESS; 37 | } 38 | 39 | // owner field will be missing on unclaimed names 40 | return data.owner || false; 41 | } 42 | 43 | async function getResolvedAddress(handle: Handle, chainId: string): Promise
{ 44 | const dnsConnect = new DNSConnect({ dns: { forwarderDomain: constants.d3[chainId].forwarder } }); 45 | 46 | return (await dnsConnect.resolve(handle, NETWORK)) || EMPTY_ADDRESS; 47 | } 48 | 49 | /** 50 | * Returns the owner of a Shibarium handle. 51 | * In case the name has not been claimed (when bought with a credit card), 52 | * it will return the resolved address. 53 | **/ 54 | export default async function getOwner(handle: Handle, chainId = MAINNET): Promise
{ 55 | const address = await getClaimedOwner(handle, chainId); 56 | 57 | if (address) return address; 58 | 59 | return getResolvedAddress(handle, chainId); 60 | } 61 | -------------------------------------------------------------------------------- /src/helpers/metrics.ts: -------------------------------------------------------------------------------- 1 | import init, { client } from '@snapshot-labs/snapshot-metrics'; 2 | import { capture } from '@snapshot-labs/snapshot-sentry'; 3 | import { Express } from 'express'; 4 | import constants from '../constants.json'; 5 | 6 | const TYPE_CONSTRAINTS = Object.keys(constants.resolvers).join('|'); 7 | 8 | export default function initMetrics(app: Express) { 9 | init(app, { 10 | normalizedPath: [ 11 | [`^/clear/(${TYPE_CONSTRAINTS})/.+`, '/clear/$1/#id'], 12 | [`^/(${TYPE_CONSTRAINTS})/.+`, '/$1/#id'] 13 | ], 14 | whitelistedPath: [ 15 | /^\/$/, 16 | new RegExp(`^/clear/(${TYPE_CONSTRAINTS})/.+$`), 17 | new RegExp(`^/(${TYPE_CONSTRAINTS})/.+$`) 18 | ], 19 | errorHandler: (e: any) => capture(e) 20 | }); 21 | } 22 | 23 | export const timeAddressResolverResponse = new client.Histogram({ 24 | name: 'address_resolver_response_duration_seconds', 25 | help: "Duration in seconds of each address resolver's response.", 26 | labelNames: ['provider', 'method', 'status'] 27 | }); 28 | 29 | export const addressResolversCacheHitCount = new client.Counter({ 30 | name: 'address_resolvers_cache_hit_count', 31 | help: 'Number of hit/miss of the address resolvers cache layer', 32 | labelNames: ['status'] 33 | }); 34 | -------------------------------------------------------------------------------- /src/helpers/redis.ts: -------------------------------------------------------------------------------- 1 | import { createClient, RedisClientType } from 'redis'; 2 | 3 | let client: RedisClientType | undefined; 4 | 5 | (async () => { 6 | if (!process.env.REDIS_URL) return; 7 | 8 | console.log('[redis] Connecting to Redis'); 9 | client = createClient({ url: process.env.REDIS_URL }); 10 | client.on('connect', () => console.log('[redis] Redis connect')); 11 | client.on('ready', () => console.log('[redis] Redis ready')); 12 | client.on('reconnecting', err => console.log('[redis] Redis reconnecting', err)); 13 | client.on('error', err => console.log('[redis] Redis error', err)); 14 | client.on('end', () => console.log('[redis] Redis end')); 15 | await client.connect(); 16 | })(); 17 | 18 | export default client; 19 | -------------------------------------------------------------------------------- /src/helpers/utils.ts: -------------------------------------------------------------------------------- 1 | export function rpcSuccess(res, result, id) { 2 | res.json({ 3 | jsonrpc: '2.0', 4 | result, 5 | id 6 | }); 7 | } 8 | 9 | export function rpcError(res, code, e, id) { 10 | res.status(code).json({ 11 | jsonrpc: '2.0', 12 | error: { 13 | code, 14 | message: 'unauthorized', 15 | data: e 16 | }, 17 | id 18 | }); 19 | } 20 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | import express from 'express'; 3 | import compression from 'compression'; 4 | import cors from 'cors'; 5 | import { initLogger, fallbackLogger } from '@snapshot-labs/snapshot-sentry'; 6 | import initMetrics from './helpers/metrics'; 7 | import api from './api'; 8 | import { name, version } from '../package.json'; 9 | 10 | const app = express(); 11 | const PORT = process.env.PORT || 3008; 12 | 13 | initLogger(app); 14 | initMetrics(app); 15 | 16 | app.disable('x-powered-by'); 17 | app.use(express.json({ limit: '4mb' })); 18 | app.use(express.urlencoded({ limit: '4mb', extended: false })); 19 | app.use(cors({ maxAge: 86400 })); 20 | app.use(compression()); 21 | app.use('/', api); 22 | 23 | app.get('/', (req, res) => { 24 | const commit = process.env.COMMIT_HASH ?? undefined; 25 | res.json({ name, version, commit }); 26 | }); 27 | 28 | fallbackLogger(app); 29 | 30 | app.use((_, res) => { 31 | res.status(400).json({ message: 'Not found' }); 32 | }); 33 | 34 | app.listen(PORT, () => console.log(`Listening at http://localhost:${PORT}`)); 35 | -------------------------------------------------------------------------------- /src/lookupDomains/ens.ts: -------------------------------------------------------------------------------- 1 | import { capture } from '@snapshot-labs/snapshot-sentry'; 2 | import { FetchError, isSilencedError } from '../addressResolvers/utils'; 3 | import { Address, graphQlCall, Handle } from '../utils'; 4 | import constants from '../constants.json'; 5 | 6 | export const DEFAULT_CHAIN_ID = '1'; 7 | 8 | type Domain = { 9 | name: string; 10 | labelName?: string; 11 | expiryDate?: number; 12 | }; 13 | 14 | async function fetchDomainData(domain: Domain, chainId: string): Promise { 15 | const hash = domain.name.match(/\[(.*?)\]/)?.[1]; 16 | 17 | if (!hash) return domain; 18 | 19 | const { 20 | data: { data } 21 | } = await graphQlCall( 22 | constants.ensSubgraph[chainId], 23 | `query Registration($id: String!) { 24 | registration(id: $id) { 25 | domain { 26 | name 27 | labelName 28 | } 29 | } 30 | }`, 31 | { id: `0x${hash}` } 32 | ); 33 | const labelName = data?.registration?.domain?.labelName; 34 | 35 | return { 36 | ...domain, 37 | name: labelName ? domain.name.replace(`[${hash}]`, labelName) : domain.name 38 | }; 39 | } 40 | 41 | export default async function lookupDomains( 42 | address: Address, 43 | chainId = DEFAULT_CHAIN_ID 44 | ): Promise { 45 | if (!constants.ensSubgraph[chainId]) return []; 46 | 47 | try { 48 | const { 49 | data: { 50 | data: { account } 51 | } 52 | } = await graphQlCall( 53 | constants.ensSubgraph[chainId], 54 | `query Domain($id: String!) { 55 | account(id: $id) { 56 | domains { 57 | name 58 | expiryDate 59 | } 60 | wrappedDomains { 61 | name 62 | expiryDate 63 | } 64 | } 65 | }`, 66 | { id: address.toLowerCase() } 67 | ); 68 | 69 | const now = (Date.now() / 1000).toFixed(0); 70 | const domains: Domain[] = [ 71 | ...(account?.domains || []), 72 | ...(account?.wrappedDomains || []) 73 | ].filter( 74 | domain => 75 | (!domain.expiryDate || domain.expiryDate === '0' || domain.expiryDate > now) && 76 | !domain.name.endsWith('.addr.reverse') 77 | ); 78 | 79 | return ( 80 | (await Promise.all(domains.map(domain => fetchDomainData(domain, chainId)))).map( 81 | domain => domain.name 82 | ) || [] 83 | ); 84 | } catch (e) { 85 | console.log(e); 86 | if (!isSilencedError(e)) { 87 | capture(e, { input: { address } }); 88 | } 89 | throw new FetchError(); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/lookupDomains/index.ts: -------------------------------------------------------------------------------- 1 | import { isAddress } from '@ethersproject/address'; 2 | import { Address, Handle } from '../utils'; 3 | import ens, { DEFAULT_CHAIN_ID as ENS_DEFAULT_CHAIN_ID } from './ens'; 4 | import shibarium, { DEFAULT_CHAIN_ID as SHIBARIUM_DEFAULT_CHAIN_ID } from './shibarium'; 5 | 6 | const RESOLVERS = [ens, shibarium]; 7 | 8 | export default async function lookupDomains( 9 | address: Address, 10 | chains: string | string[] = [ENS_DEFAULT_CHAIN_ID, SHIBARIUM_DEFAULT_CHAIN_ID] 11 | ): Promise { 12 | const promises: Promise[] = []; 13 | let chainIds = Array.isArray(chains) ? chains : [chains]; 14 | chainIds = [...new Set(chainIds.map(String))]; 15 | 16 | if (!isAddress(address)) return []; 17 | 18 | RESOLVERS.forEach(resolver => { 19 | chainIds.forEach(chain => { 20 | promises.push(resolver(address, chain)); 21 | }); 22 | }); 23 | 24 | const domains = await Promise.all(promises); 25 | 26 | return domains.flat(); 27 | } 28 | -------------------------------------------------------------------------------- /src/lookupDomains/shibarium.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | import { capture } from '@snapshot-labs/snapshot-sentry'; 3 | import { Address, Handle } from '../utils'; 4 | import constants from '../constants.json'; 5 | 6 | const MAINNET = '109'; 7 | const TESTNET = '157'; 8 | const PAGE_SIZE = 25; 9 | const TIMEOUT = 10000; 10 | 11 | const API_KEYS = { 12 | [MAINNET]: process.env.D3_API_KEY_MAINNET, 13 | [TESTNET]: process.env.D3_API_KEY_TESTNET 14 | }; 15 | 16 | export const DEFAULT_CHAIN_ID = MAINNET; 17 | 18 | export default async function lookupDomains( 19 | address: Address, 20 | chainId = DEFAULT_CHAIN_ID 21 | ): Promise { 22 | if (!constants.d3[chainId]?.apiUrl || !API_KEYS[chainId]) return []; 23 | 24 | const allDomains: Handle[] = []; 25 | let skip = 0; 26 | let hasMore = true; 27 | 28 | try { 29 | while (hasMore) { 30 | const controller = new AbortController(); 31 | const timeoutId = setTimeout(() => controller.abort(), TIMEOUT); 32 | 33 | try { 34 | const response = await fetch( 35 | `${constants.d3[chainId].apiUrl}/v1/partner/tokens/EVM/${address}?limit=${PAGE_SIZE}&skip=${skip}`, 36 | { 37 | headers: { 'Content-Type': 'application/json', 'Api-Key': API_KEYS[chainId] }, 38 | signal: controller.signal 39 | } 40 | ); 41 | 42 | if (response.status === 404) { 43 | break; 44 | } 45 | 46 | if (!response.ok) { 47 | throw new Error(`HTTP ${response.status}: ${response.statusText}`); 48 | } 49 | 50 | let data: { pageItems?: Array<{ sld: string; tld: string }> }; 51 | try { 52 | data = await response.json(); 53 | } catch (e) { 54 | throw new Error(`Invalid JSON response: ${(e as any).message}`); 55 | } 56 | 57 | const domains = data.pageItems?.map(item => `${item.sld}.${item.tld}`) || []; 58 | allDomains.push(...domains); 59 | 60 | hasMore = domains.length === PAGE_SIZE; 61 | skip += PAGE_SIZE; 62 | } catch (e) { 63 | capture(e, { input: { address, chainId, skip } }); 64 | break; 65 | } finally { 66 | clearTimeout(timeoutId); 67 | } 68 | } 69 | 70 | return allDomains; 71 | } catch (e) { 72 | capture(e); 73 | return allDomains; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/resolvers/blockie.ts: -------------------------------------------------------------------------------- 1 | import { createCanvas } from 'canvas'; 2 | import { renderIcon } from '@download/blockies'; 3 | import { resize } from '../utils'; 4 | import { max } from '../constants.json'; 5 | 6 | export default async function resolve(address) { 7 | const canvas = createCanvas(64, 64); 8 | renderIcon({ seed: address, scale: 64 }, canvas); 9 | const input = canvas.toBuffer(); 10 | return await resize(input, max, max); 11 | } 12 | -------------------------------------------------------------------------------- /src/resolvers/coingecko.ts: -------------------------------------------------------------------------------- 1 | import { getAddress } from '@ethersproject/address'; 2 | import { resize } from '../utils'; 3 | import { max } from '../constants.json'; 4 | import { fetchHttpImage } from './utils'; 5 | 6 | const API_KEY = process.env.COINGECKO_API_KEY; 7 | 8 | const COINGECKO_ASSET_PLATFORMS = { 9 | 1: 'ethereum', 10 | 10: 'optimistic-ethereum', 11 | 137: 'polygon-pos', 12 | 8453: 'base', 13 | 33139: 'apechain', 14 | 42161: 'arbitrum-one' 15 | }; 16 | 17 | export default async function resolve(address: string, chainId: string) { 18 | if (!API_KEY) return false; 19 | 20 | try { 21 | const assetPlatformId = COINGECKO_ASSET_PLATFORMS[chainId]; 22 | 23 | if (!assetPlatformId) return false; 24 | 25 | const checksum = getAddress(address); 26 | const url = `https://pro-api.coingecko.com/api/v3/coins/${assetPlatformId}/contract/${checksum}`; 27 | 28 | const data = await fetch(url, { headers: { 'x-cg-pro-api-key': API_KEY } }).then(res => 29 | res.json() 30 | ); 31 | const input = await fetchHttpImage(data?.image?.large); 32 | return await resize(input, max, max); 33 | } catch (e) { 34 | return false; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/resolvers/ens.ts: -------------------------------------------------------------------------------- 1 | import { getProvider, resize } from '../utils'; 2 | import { max } from '../constants.json'; 3 | import { fetchHttpImage } from './utils'; 4 | import { isAddress } from '@ethersproject/address'; 5 | import { lookupAddresses } from '../addressResolvers'; 6 | 7 | async function castToEnsName(nameOrAddress: string): Promise { 8 | if (isAddress(nameOrAddress)) { 9 | return (await lookupAddresses([nameOrAddress]))[nameOrAddress]; 10 | } 11 | 12 | return nameOrAddress; 13 | } 14 | 15 | export default async function resolve(nameOrAddress: string) { 16 | try { 17 | const provider = getProvider(1); 18 | const ensName = await castToEnsName(nameOrAddress); 19 | 20 | if (!ensName) return false; 21 | 22 | const ensResolver = await provider.getResolver(ensName); 23 | 24 | if (!ensResolver) { 25 | return false; 26 | } 27 | 28 | let url = await ensResolver.getText('avatar'); 29 | url = url?.startsWith('http') ? url : `https://metadata.ens.domains/mainnet/avatar/${ensName}`; 30 | 31 | const input = await fetchHttpImage(url); 32 | 33 | return await resize(input, max, max); 34 | } catch (e) { 35 | return false; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/resolvers/farcaster.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | import { getAddress } from '@ethersproject/address'; 3 | import { Address, resize } from '../utils'; 4 | import { max } from '../constants.json'; 5 | import { fetchHttpImage } from './utils'; 6 | 7 | const NEYNAR_API_URL = 'https://api.neynar.com/v2/farcaster/user/bulk-by-address'; 8 | const API_KEY = process.env.NEYNAR_API_KEY ?? ''; 9 | 10 | interface UserDetails { 11 | pfp_url: string; 12 | } 13 | 14 | function withCache(url: string): string { 15 | return url.includes('imgur.com') 16 | ? `https://wrpcd.net/cdn-cgi/image/fit=contain,f=auto,w=${max}/${encodeURIComponent(url)}` 17 | : url; 18 | } 19 | 20 | function normalizeAddress(address: Address): Address | null { 21 | try { 22 | return getAddress(address); 23 | } catch (e) { 24 | return null; 25 | } 26 | } 27 | 28 | async function fetchAddressImageUrl(normalizedAddress: string): Promise { 29 | try { 30 | const response = await fetch(`${NEYNAR_API_URL}?addresses=${normalizedAddress}`, { 31 | headers: { Accept: 'application/json', api_key: API_KEY } 32 | }); 33 | 34 | if (!response.ok) { 35 | throw new Error(`Invalid network response (${response.url} ${response.status})`); 36 | } 37 | 38 | const data: Record = await response.json(); 39 | 40 | return data[normalizedAddress.toLowerCase()]?.[0].pfp_url; 41 | } catch (e) { 42 | return null; 43 | } 44 | } 45 | 46 | export default async function resolve(address: string): Promise { 47 | const normalizedAddress = normalizeAddress(address); 48 | if (!normalizedAddress) return false; 49 | 50 | const url = await fetchAddressImageUrl(normalizedAddress); 51 | if (!url) return false; 52 | 53 | try { 54 | const imageUrl = withCache(url); 55 | const input = await fetchHttpImage(imageUrl); 56 | return await resize(input, max, max); 57 | } catch (e) { 58 | return false; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/resolvers/index.ts: -------------------------------------------------------------------------------- 1 | import blockie from './blockie'; 2 | import jazzicon from './jazzicon'; 3 | import ens from './ens'; 4 | import trustwallet from './trustwallet'; 5 | import coingecko from './coingecko'; 6 | import { 7 | resolveUserAvatar as sResolveUserAvatar, 8 | resolveUserCover as sResolveUserCover, 9 | resolveSpaceAvatar as sResolveSpaceAvatar, 10 | resolveSpaceCover as sResolveSpaceCover, 11 | resolveSpaceLogo as sResolveSpaceLogo 12 | } from './snapshot'; 13 | import { resolveAvatar as sxResolveAvatar, resolveCover as sxResolveCover } from './space-sx'; 14 | import selfid from './selfid'; 15 | import lens from './lens'; 16 | import zapper from './zapper'; 17 | import starknet from './starknet'; 18 | import farcaster from './farcaster'; 19 | 20 | export default { 21 | blockie, 22 | jazzicon, 23 | ens, 24 | trustwallet, 25 | coingecko, 26 | snapshot: sResolveUserAvatar, 27 | 'user-cover': sResolveUserCover, 28 | space: sResolveSpaceAvatar, 29 | 'space-cover': sResolveSpaceCover, 30 | 'space-logo': sResolveSpaceLogo, 31 | 'space-sx': sxResolveAvatar, 32 | 'space-cover-sx': sxResolveCover, 33 | selfid, 34 | lens, 35 | zapper, 36 | starknet, 37 | farcaster 38 | }; 39 | -------------------------------------------------------------------------------- /src/resolvers/jazzicon.ts: -------------------------------------------------------------------------------- 1 | import { JSDOM } from 'jsdom'; 2 | import sharp from 'sharp'; 3 | import jazzicon from '@metamask/jazzicon'; 4 | import { resize } from '../utils'; 5 | import { max } from '../constants.json'; 6 | 7 | const dom = new JSDOM(''); 8 | global['document'] = dom.window.document; 9 | 10 | export default async function resolve(address) { 11 | const { innerHTML, style } = jazzicon(500, parseInt(address.slice(2, 10), 16)); 12 | 13 | const input = await sharp(Buffer.from(innerHTML, 'utf-8')) 14 | .flatten({ background: style.background }) 15 | .webp({ lossless: true }) 16 | .toBuffer(); 17 | 18 | return await resize(input, max, max); 19 | } 20 | -------------------------------------------------------------------------------- /src/resolvers/lens.ts: -------------------------------------------------------------------------------- 1 | import { getAddress, isAddress } from '@ethersproject/address'; 2 | import { graphQlCall, resize } from '../utils'; 3 | import { max } from '../constants.json'; 4 | import { fetchHttpImage } from './utils'; 5 | 6 | const API_URL = 'https://api.lens.xyz'; 7 | const LENS_IPFS_GATEWAY = 'https://gw.ipfs-lens.dev/ipfs/'; 8 | const LENS_EXTENSION = '.lens'; 9 | 10 | function normalizeImageUrl(url: string) { 11 | if (!url) return false; 12 | 13 | // Lens IPFS gateway is returning 403 when accessed directly 14 | if (url.startsWith(LENS_IPFS_GATEWAY)) { 15 | return `https://${process.env.IPFS_GATEWAY || 'cloudflare-ipfs.com'}/ipfs/${ 16 | url.split(LENS_IPFS_GATEWAY)[1] 17 | }`; 18 | } 19 | 20 | // Return the URL as-is if it's not an IPFS URL 21 | return url; 22 | } 23 | 24 | export default async function resolve(domainOrAddress: string) { 25 | let request: Record; 26 | 27 | if (isAddress(domainOrAddress)) { 28 | request = { address: getAddress(domainOrAddress) }; 29 | } else if (domainOrAddress.endsWith(LENS_EXTENSION)) { 30 | request = { username: { localName: domainOrAddress.split(LENS_EXTENSION)[0] } }; 31 | } else { 32 | return false; 33 | } 34 | 35 | try { 36 | const { 37 | data: { 38 | data: { account } 39 | } 40 | } = await graphQlCall( 41 | `${API_URL}/graphql`, 42 | `query Account($request: AccountRequest!) { 43 | account(request: $request) { 44 | metadata { 45 | picture 46 | } 47 | } 48 | }`, 49 | { request } 50 | ); 51 | 52 | const img_url = normalizeImageUrl(account?.metadata?.picture); 53 | if (!img_url) return false; 54 | 55 | const input = await fetchHttpImage(img_url); 56 | return await resize(input, max, max); 57 | } catch (e) { 58 | return false; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/resolvers/selfid.ts: -------------------------------------------------------------------------------- 1 | import { getAddress } from '@ethersproject/address'; 2 | import { Core } from '@self.id/core'; 3 | import { getUrl, resize } from '../utils'; 4 | import { max } from '../constants.json'; 5 | import { fetchHttpImage } from './utils'; 6 | 7 | const core = new Core({ ceramic: 'https://gateway.ceramic.network' }); 8 | 9 | export default async function resolve(address: string) { 10 | try { 11 | const did = await core.getAccountDID(`${getAddress(address)}@eip155:1`); 12 | const result = await core.get('basicProfile', did); 13 | 14 | const { src } = result?.image?.original || {}; 15 | if (!src) return false; 16 | 17 | const url = getUrl(src); 18 | const input = await fetchHttpImage(url); 19 | return await resize(input, max, max); 20 | } catch (e) { 21 | return false; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/resolvers/snapshot.ts: -------------------------------------------------------------------------------- 1 | import { getAddress } from '@ethersproject/address'; 2 | import { getUrl, graphQlCall, resize } from '../utils'; 3 | import { max, offchainNetworks, defaultOffchainNetwork } from '../constants.json'; 4 | import { fetchHttpImage } from './utils'; 5 | import { isStarknetAddress } from '../addressResolvers/utils'; 6 | 7 | const UNIFIED_API_URL = 'https://api.snapshot.box'; 8 | const UNIFIED_API_TESTNET_URL = 'https://testnet-api.snapshot.box'; 9 | 10 | const API_URLS = { 11 | s: `${process.env.HUB_URL ?? 'https://hub.snapshot.org'}/graphql`, 12 | 's-tn': `${process.env.HUB_URL_TN ?? 'https://testnet.hub.snapshot.org'}/graphql`, 13 | // SX mainnets 14 | eth: UNIFIED_API_URL, 15 | matic: UNIFIED_API_URL, 16 | arb1: UNIFIED_API_URL, 17 | oeth: UNIFIED_API_URL, 18 | base: UNIFIED_API_URL, 19 | mnt: UNIFIED_API_URL, 20 | ape: UNIFIED_API_URL, 21 | sn: UNIFIED_API_URL, 22 | // SX testnets 23 | sep: UNIFIED_API_TESTNET_URL, 24 | curtis: UNIFIED_API_TESTNET_URL, 25 | 'sn-sep': UNIFIED_API_TESTNET_URL 26 | }; 27 | 28 | type Entity = 'user' | 'space'; 29 | type Property = 'avatar' | 'cover' | 'logo'; 30 | 31 | const QUERIES = { 32 | avatar: { 33 | query: 'avatar', 34 | extract: (data: any) => data?.avatar 35 | }, 36 | cover: { 37 | query: 'cover', 38 | extract: (data: any) => data?.cover 39 | }, 40 | logo: { 41 | query: 'skinSettings { logo }', 42 | extract: (data: any) => data?.skinSettings?.logo 43 | } 44 | }; 45 | 46 | async function getOffchainProperty( 47 | networkId: string, 48 | id: string, 49 | entity: Entity, 50 | property: Property 51 | ) { 52 | const { 53 | data: { 54 | data: { entry } 55 | } 56 | } = await graphQlCall( 57 | API_URLS[networkId], 58 | `query GetEntry($id: String!) { 59 | entry: ${entity}(id: $id) { 60 | ${QUERIES[property].query} 61 | } 62 | }`, 63 | { id }, 64 | { 65 | headers: { 'x-api-key': process.env.HUB_API_KEY } 66 | } 67 | ); 68 | 69 | return QUERIES[property].extract(entry); 70 | } 71 | 72 | async function getOnchainProperty( 73 | networkId: string, 74 | id: string, 75 | entity: Entity, 76 | property: Property 77 | ) { 78 | const ids = [id]; 79 | if (!isStarknetAddress(id)) { 80 | ids.push(getAddress(id)); 81 | } 82 | 83 | const { 84 | data: { 85 | data: { spaces } 86 | } 87 | } = await graphQlCall( 88 | API_URLS[networkId], 89 | `query GetSpaces($ids: [String!]!) { 90 | spaces(where: { id_in: $ids }) { 91 | metadata { 92 | ${property} 93 | } 94 | } 95 | }`, 96 | { ids } 97 | ); 98 | 99 | return spaces?.map(space => space.metadata?.[property]).filter(Boolean)[0]; 100 | } 101 | 102 | function createPropertyResolver(entity: Entity, property: Property) { 103 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 104 | return async (address: string, chainId = 1, networkId = defaultOffchainNetwork) => { 105 | let value = null; 106 | 107 | if (!Object.keys(API_URLS).includes(networkId)) return false; 108 | 109 | try { 110 | if (offchainNetworks.includes(networkId) || entity === 'user') { 111 | value = await getOffchainProperty( 112 | offchainNetworks.includes(networkId) ? networkId : defaultOffchainNetwork, 113 | address, 114 | entity, 115 | property 116 | ); 117 | } else { 118 | value = await getOnchainProperty(networkId, address, entity, property); 119 | } 120 | 121 | if (!value) return false; 122 | 123 | const url = getUrl(value); 124 | const input = await fetchHttpImage(url); 125 | 126 | if (['cover', 'logo'].includes(property)) return input; 127 | 128 | return await resize(input, max, max); 129 | } catch (e) { 130 | return false; 131 | } 132 | }; 133 | } 134 | 135 | export const resolveUserAvatar = createPropertyResolver('user', 'avatar'); 136 | export const resolveUserCover = createPropertyResolver('user', 'cover'); 137 | export const resolveSpaceAvatar = createPropertyResolver('space', 'avatar'); 138 | export const resolveSpaceCover = createPropertyResolver('space', 'cover'); 139 | export const resolveSpaceLogo = createPropertyResolver('space', 'logo'); 140 | -------------------------------------------------------------------------------- /src/resolvers/space-sx.ts: -------------------------------------------------------------------------------- 1 | import { getAddress } from '@ethersproject/address'; 2 | import { getUrl, graphQlCall, resize } from '../utils'; 3 | import { max } from '../constants.json'; 4 | import { fetchHttpImage } from './utils'; 5 | import { isStarknetAddress } from '../addressResolvers/utils'; 6 | 7 | const SUBGRAPH_URLS = ['https://api.snapshot.box', 'https://testnet-api.snapshot.box']; 8 | 9 | async function getSpaceProperty(key: string, url: string, property: 'avatar' | 'cover') { 10 | const ids = [key]; 11 | if (!isStarknetAddress(key)) { 12 | ids.push(getAddress(key)); 13 | } 14 | 15 | const { 16 | data: { 17 | data: { spaces } 18 | } 19 | } = await graphQlCall( 20 | url, 21 | `query GetSpaces($ids: [String!]!) { 22 | spaces(where: { id_in: $ids }) { 23 | metadata { 24 | ${property} 25 | } 26 | } 27 | }`, 28 | { ids } 29 | ); 30 | 31 | const result = spaces?.map(space => space.metadata?.[property]).filter(Boolean)[0]; 32 | 33 | return result || Promise.reject(false); 34 | } 35 | 36 | function createPropertyResolver(property: 'avatar' | 'cover') { 37 | return async key => { 38 | try { 39 | const value = await Promise.any( 40 | SUBGRAPH_URLS.map(url => getSpaceProperty(key, url, property)) 41 | ); 42 | 43 | const url = getUrl(value); 44 | const input = await fetchHttpImage(url); 45 | 46 | if (property === 'cover') return input; 47 | return await resize(input, max, max); 48 | } catch (e) { 49 | return false; 50 | } 51 | }; 52 | } 53 | 54 | export const resolveAvatar = createPropertyResolver('avatar'); 55 | export const resolveCover = createPropertyResolver('cover'); 56 | -------------------------------------------------------------------------------- /src/resolvers/starknet.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | import { Provider, RpcProvider } from 'starknet'; 3 | import { getUrl, resize } from '../utils'; 4 | import { max } from '../constants.json'; 5 | import { fetchHttpImage } from './utils'; 6 | 7 | const DEFAULT_IMG_URL = 'https://starknet.id/api/identicons/0'; 8 | 9 | const provider = new Provider( 10 | new RpcProvider({ 11 | nodeUrl: process.env.INFURA_API_KEY 12 | ? `https://starknet-mainnet.infura.io/v3/${process.env.INFURA_API_KEY}` 13 | : 'https://starknet-mainnet.public.blastapi.io' 14 | }) 15 | ); 16 | 17 | function isStarknetDomain(domain: string): boolean { 18 | return domain.endsWith('.stark'); 19 | } 20 | 21 | function normalizeAddress(address: string): string { 22 | if (!address.match(/^(0x)?[0-9a-fA-F]{64}$/)) throw new Error('Invalid starknet address'); 23 | 24 | return address; 25 | } 26 | 27 | async function getStarknetAddress(domain: string): Promise { 28 | const address = await provider.getAddressFromStarkName(domain); 29 | 30 | return address === '0x0' ? null : address; 31 | } 32 | 33 | async function getImage(domainOrAddress: string): Promise { 34 | const address = isStarknetDomain(domainOrAddress) 35 | ? await getStarknetAddress(domainOrAddress) 36 | : normalizeAddress(domainOrAddress); 37 | 38 | if (!address) return null; 39 | 40 | return (await provider.getStarkProfile(address))?.profilePicture ?? null; 41 | } 42 | 43 | export default async function resolve(domainOrAddress: string) { 44 | try { 45 | let img_url = await getImage(domainOrAddress); 46 | 47 | if (img_url === DEFAULT_IMG_URL) return false; 48 | 49 | if (img_url?.startsWith('https://api.starkurabu.com')) { 50 | const response = await fetch(img_url); 51 | const data = await response.json(); 52 | 53 | img_url = data.image; 54 | } 55 | 56 | if (!img_url) return false; 57 | 58 | const input = await fetchHttpImage(getUrl(img_url)); 59 | 60 | return await resize(input, max, max); 61 | } catch (e) { 62 | return false; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/resolvers/trustwallet.ts: -------------------------------------------------------------------------------- 1 | import { getAddress } from '@ethersproject/address'; 2 | import { resize, chainIdToName, getBaseAssetIconUrl } from '../utils'; 3 | import { max } from '../constants.json'; 4 | import { fetchHttpImage } from './utils'; 5 | 6 | const ETH = [ 7 | '0x0000000000000000000000000000000000000000', 8 | '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE' 9 | ]; 10 | 11 | export default async function resolve(address, chainId) { 12 | try { 13 | const networkName = chainIdToName(chainId) || 'ethereum'; 14 | const checksum = getAddress(address); 15 | 16 | let url = `https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/${networkName}/assets/${checksum}/logo.png`; 17 | if (ETH.includes(checksum)) url = getBaseAssetIconUrl(chainId); 18 | 19 | const input = await fetchHttpImage(url); 20 | return await resize(input, max, max); 21 | } catch (e) { 22 | return false; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/resolvers/utils.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import http from 'http'; 3 | import https from 'https'; 4 | 5 | export const axiosDefaultParams = { 6 | httpAgent: new http.Agent({ keepAlive: true }), 7 | httpsAgent: new https.Agent({ keepAlive: true }), 8 | timeout: 5e3 9 | }; 10 | 11 | export async function fetchHttpImage(url: string): Promise { 12 | return ( 13 | await axios({ 14 | url, 15 | ...{ 16 | responseType: 'arraybuffer', 17 | ...axiosDefaultParams 18 | } 19 | }) 20 | ).data; 21 | } 22 | -------------------------------------------------------------------------------- /src/resolvers/zapper.ts: -------------------------------------------------------------------------------- 1 | import { getAddress } from '@ethersproject/address'; 2 | import { resize, chainIdToName, getBaseAssetIconUrl } from '../utils'; 3 | import { max } from '../constants.json'; 4 | import { fetchHttpImage } from './utils'; 5 | 6 | const ETH = [ 7 | '0x0000000000000000000000000000000000000000', 8 | '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE' 9 | ]; 10 | 11 | export default async function resolve(address, chainId) { 12 | try { 13 | const networkName = chainIdToName(chainId) || 'ethereum'; 14 | const checksum = getAddress(address); 15 | 16 | let url = `https://storage.googleapis.com/zapper-fi-assets/tokens/${networkName}/${checksum.toLocaleLowerCase()}.png`; 17 | if (ETH.includes(checksum)) url = getBaseAssetIconUrl(chainId); 18 | 19 | const input = await fetchHttpImage(url); 20 | return await resize(input, max, max); 21 | } catch (e) { 22 | return false; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/scripts/generate-chains.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs/promises'; 2 | 3 | type Chain = { 4 | chainId: number; 5 | shortName: string; 6 | }; 7 | 8 | const CUSTOM_CHAINS = [ 9 | // Polygon - Using old shortname for compatibility 10 | ['137', 'matic'], 11 | // SN_MAIN 12 | ['0x534e5f4d41494e', 'sn'], 13 | // SN_SEPOLIA 14 | ['0x534e5f5345504f4c4941', 'sn-sep'] 15 | ]; 16 | 17 | async function generateChains() { 18 | if (process.argv.length < 3) { 19 | console.log('Usage: yarn generate-chains '); 20 | process.exit(1); 21 | } 22 | 23 | console.log('Generating chains.json...'); 24 | 25 | const inputData = await fs.readFile(process.argv[2], 'utf-8'); 26 | const chains: Chain[] = JSON.parse(inputData); 27 | 28 | const CHAIN_ID_TO_SHORTNAME = [ 29 | ...chains.map(chain => [String(chain.chainId), chain.shortName.toLowerCase()]), 30 | ...CUSTOM_CHAINS 31 | ]; 32 | 33 | const output = { 34 | CHAIN_ID_TO_SHORTNAME: CHAIN_ID_TO_SHORTNAME.reduce((acc, chain) => { 35 | acc[chain[0]] = chain[1]; 36 | return acc; 37 | }, {}), 38 | SHORTNAME_TO_CHAIN_ID: CHAIN_ID_TO_SHORTNAME.reduce((acc, chain) => { 39 | acc[chain[1]] = chain[0]; 40 | return acc; 41 | }, {}) 42 | }; 43 | 44 | await fs.writeFile('src/chains.json', JSON.stringify(output, null, 2)); 45 | } 46 | 47 | generateChains(); 48 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import sharp from 'sharp'; 3 | import snapshot from '@snapshot-labs/snapshot.js'; 4 | import { createHash } from 'crypto'; 5 | import { Response } from 'express'; 6 | import { StaticJsonRpcProvider } from '@ethersproject/providers'; 7 | import chains from './chains.json'; 8 | import constants from './constants.json'; 9 | 10 | export type Address = string; 11 | export type Handle = string; 12 | export type ResolverType = 13 | | 'avatar' 14 | | 'user-cover' 15 | | 'token' 16 | | 'space' 17 | | 'space-cover' 18 | | 'space-logo' 19 | | 'space-sx' 20 | | 'space-cover-sx' 21 | | 'address' 22 | | 'name'; 23 | 24 | const providers: Record = {}; 25 | 26 | const RESIZE_FITS = ['cover', 'contain', 'fill', 'inside', 'outside']; 27 | 28 | export const EMPTY_ADDRESS = '0x0000000000000000000000000000000000000000'; 29 | 30 | export function getProvider(network: number): StaticJsonRpcProvider { 31 | if (!providers[`_${network}`]) 32 | providers[`_${network}`] = new StaticJsonRpcProvider( 33 | { 34 | url: `https://rpc.snapshot.org/${network}`, 35 | timeout: 20e3, 36 | allowGzip: true 37 | }, 38 | network 39 | ); 40 | 41 | return providers[`_${network}`]; 42 | } 43 | 44 | export function sha256(str) { 45 | return createHash('sha256') 46 | .update(str) 47 | .digest('hex'); 48 | } 49 | 50 | export async function resize(input, w, h, options?) { 51 | return sharp(input) 52 | .resize(w, h, options) 53 | .webp() 54 | .toBuffer(); 55 | } 56 | 57 | export function shortNameToChainId(shortName: string): string | null { 58 | return shortName in chains.SHORTNAME_TO_CHAIN_ID ? chains.SHORTNAME_TO_CHAIN_ID[shortName] : null; 59 | } 60 | 61 | export function chainIdToShortName(chainId: string): string | null { 62 | return chainId in chains.CHAIN_ID_TO_SHORTNAME ? chains.CHAIN_ID_TO_SHORTNAME[chainId] : null; 63 | } 64 | 65 | export function chainIdToName(chainId: string): string | null { 66 | if (chainId === '1') return 'ethereum'; 67 | if (chainId === '56') return 'binance'; 68 | if (chainId === '250') return 'fantom'; 69 | if (chainId === '137') return 'polygon'; 70 | if (chainId === '42161') return 'arbitrum'; 71 | 72 | return null; 73 | } 74 | 75 | export async function parseQuery(id: string, type: ResolverType, query) { 76 | let address = id; 77 | let network = '1'; 78 | let networkId: string | undefined = undefined; 79 | 80 | // Resolve format 81 | // let format; 82 | const chunks = id.split(':'); 83 | if (chunks.length === 2) { 84 | // format = 'eip3770'; 85 | address = chunks[1]; 86 | networkId = chunks[0]; 87 | network = shortNameToChainId(networkId) || '1'; 88 | } else if (chunks.length === 3) { 89 | // format = 'caip10'; 90 | address = chunks[2]; 91 | network = chunks[1]; 92 | networkId = chainIdToShortName(network) || 'eth'; 93 | } else if (id.startsWith('did:')) { 94 | // format = 'did'; 95 | address = id.slice(4); 96 | } 97 | // console.log('Format', format); 98 | 99 | address = address.toLowerCase(); 100 | const size = 64; 101 | const maxSize = type.includes('-cover') ? constants.maxCover : constants.max; 102 | let s = query.s ? parseInt(query.s) : size; 103 | if (s < 1 || s > maxSize || isNaN(s)) s = size; 104 | let w = query.w ? parseInt(query.w) : s; 105 | if (w < 1 || w > maxSize || isNaN(w)) w = size; 106 | let h = query.h ? parseInt(query.h) : s; 107 | if (h < 1 || h > maxSize || isNaN(h)) h = size; 108 | 109 | return { 110 | address, 111 | network, 112 | networkId, 113 | w, 114 | h, 115 | fallback: query.fb === 'jazzicon' ? 'jazzicon' : 'blockie', 116 | cb: query.cb, 117 | resolver: query.resolver, 118 | fit: RESIZE_FITS.includes(query.fit) ? query.fit : undefined 119 | }; 120 | } 121 | 122 | export function getUrl(url) { 123 | const gateway: string = process.env.IPFS_GATEWAY || 'cloudflare-ipfs.com'; 124 | return snapshot.utils.getUrl(url, gateway); 125 | } 126 | 127 | export function getCacheKey({ 128 | type, 129 | network, 130 | address, 131 | w, 132 | h, 133 | fallback, 134 | cb, 135 | fit 136 | }: { 137 | type: ResolverType; 138 | network: string; 139 | address: string; 140 | w: number; 141 | h: number; 142 | fallback: string; 143 | cb?: string; 144 | fit?: string; 145 | }) { 146 | const data = { type, network, address, w, h }; 147 | if (fallback !== 'blockie') data['fallback'] = fallback; 148 | if (cb) data['cb'] = cb; 149 | if (fit) data['fit'] = fit; 150 | 151 | return sha256(JSON.stringify(data)); 152 | } 153 | 154 | export function setHeader(res: Response, cacheType: 'SHORT_CACHE' | 'LONG_CACHE' = 'LONG_CACHE') { 155 | const ttl = cacheType === 'SHORT_CACHE' ? constants.shortTtl : constants.ttl; 156 | 157 | res.set({ 158 | 'Content-Type': 'image/webp', 159 | 'Cache-Control': `public, max-age=${ttl}`, 160 | Expires: new Date(Date.now() + ttl * 1e3).toUTCString() 161 | }); 162 | } 163 | 164 | export const getBaseAssetIconUrl = (chainId: string) => { 165 | if (chainId === '100') { 166 | return 'https://ipfs.snapshot.box/ipfs/bafkreie4u6cq3o6sarxti5r6riekkimr33fjnu4bw6vhnqcsijvzpxjesm'; 167 | } 168 | 169 | // Matic 170 | if (chainId === '137') { 171 | return 'https://github-production-user-asset-6210df.s3.amazonaws.com/1968722/269347324-fc34c3a3-01e8-424a-80f6-0910374ea6de.svg'; 172 | } 173 | 174 | if (chainId === '5000') { 175 | return 'https://ipfs.snapshot.box/ipfs/bafkreidkucwfn4mzo2gtydrt2wogk3je5xpugom67vhi4h4comaxxjzoz4'; 176 | } 177 | 178 | // Apechain & Curtis 179 | if (chainId === '33139' || chainId === '33111') { 180 | return 'https://ipfs.snapshot.box/ipfs/bafybeifjxd2q2znrqdsl5y2oplp6yothjfpzaosxs3kcvnxcacox6wfl5u'; 181 | } 182 | 183 | return 'https://static.cdnlogo.com/logos/e/81/ethereum-eth.svg'; 184 | }; 185 | 186 | export function graphQlCall( 187 | url: string, 188 | query: string, 189 | variables?: Record, 190 | options: any = { 191 | headers: {} 192 | } 193 | ) { 194 | const data: { query: string; variables?: Record } = { query }; 195 | if (variables) { 196 | data.variables = variables; 197 | } 198 | 199 | return axios({ 200 | url: url, 201 | method: 'post', 202 | headers: { 203 | 'Content-Type': 'application/json', 204 | ...Object.fromEntries( 205 | Object.entries(options.headers).filter(([, value]) => value !== undefined && value !== null) 206 | ) 207 | }, 208 | timeout: 5e3, 209 | data 210 | }); 211 | } 212 | -------------------------------------------------------------------------------- /test/.env.test: -------------------------------------------------------------------------------- 1 | HUB_URL=https://hub.snapshot.org 2 | BROVIDER_URL=https://rpc.snapshot.org 3 | REDIS_URL=redis://localhost:6379 4 | IPFS_GATEWAY=snapshot.4everland.link 5 | -------------------------------------------------------------------------------- /test/e2e/api.test.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import fetch from 'node-fetch'; 3 | import redis from '../../src/helpers/redis'; 4 | import { KEY_PREFIX } from '../../src/addressResolvers/cache'; 5 | 6 | const HOST = `http://localhost:${process.env.PORT || 3003}`; 7 | 8 | async function purge(): Promise { 9 | if (!redis) return; 10 | 11 | const keys = await redis.keys(`${KEY_PREFIX}:*`); 12 | const transaction = redis.multi(); 13 | 14 | keys.map((key: string) => transaction.del(key)); 15 | transaction.exec(); 16 | } 17 | 18 | async function imageToBase64(url: string) { 19 | const response = await fetch(url); 20 | const buffer = await response.buffer(); 21 | 22 | return buffer.toString('base64'); 23 | } 24 | 25 | describe('E2E api', () => { 26 | describe('GET type/TYPE/ID', () => { 27 | it.todo('returns a 500 status on invalid query'); 28 | 29 | describe('when the image is not cached', () => { 30 | it.todo('returns the image'); 31 | it.todo('caches the base image'); 32 | it.todo('caches the resized image'); 33 | 34 | it('returns same space avatar for snapshot legacy and non-legacy format', async () => { 35 | expect(await imageToBase64(`${HOST}/space/ens.eth`)).toEqual( 36 | await imageToBase64(`${HOST}/space/s:ens.eth`) 37 | ); 38 | expect( 39 | await imageToBase64( 40 | `${HOST}/space/sn:0x07c251045154318a2376a3bb65be47d3c90df1740d8e35c9b9d943aa3f240e50` 41 | ) 42 | ).toEqual( 43 | await imageToBase64( 44 | `${HOST}/space-sx/0x07c251045154318a2376a3bb65be47d3c90df1740d8e35c9b9d943aa3f240e50` 45 | ) 46 | ); 47 | }); 48 | 49 | it('returns different space avatar for different network', async () => { 50 | expect(await imageToBase64(`${HOST}/space/s:ens.eth`)).not.toEqual( 51 | await imageToBase64(`${HOST}/space/s-tn:ens.eth`) 52 | ); 53 | }); 54 | 55 | it('returns same space cover for snapshot legacy and non-legacy format', async () => { 56 | expect(await imageToBase64(`${HOST}/space-cover/test.wa0x6e.eth`)).toEqual( 57 | await imageToBase64(`${HOST}/space-cover/s:test.wa0x6e.eth`) 58 | ); 59 | expect( 60 | await imageToBase64( 61 | `${HOST}/space-cover/sn:0x07c251045154318a2376a3bb65be47d3c90df1740d8e35c9b9d943aa3f240e50` 62 | ) 63 | ).toEqual( 64 | await imageToBase64( 65 | `${HOST}/space-cover-sx/0x07c251045154318a2376a3bb65be47d3c90df1740d8e35c9b9d943aa3f240e50` 66 | ) 67 | ); 68 | }); 69 | 70 | it('returns different space cover for different network', async () => { 71 | expect(await imageToBase64(`${HOST}/space-cover/s:test.wa0x6e.eth`)).not.toEqual( 72 | await imageToBase64(`${HOST}/space-cover/s-tn:test.wa0x6e.eth`) 73 | ); 74 | }); 75 | }); 76 | 77 | describe('when the base image is cached, but not the requested size', () => { 78 | it.todo('resize the image from the cached base image'); 79 | it.todo('caches the resized image'); 80 | }); 81 | 82 | describe('when the resized image is cached', () => { 83 | it.todo('returns the cached resize image'); 84 | }); 85 | 86 | it.todo('clears the cache'); 87 | }); 88 | 89 | describe('POST /', () => { 90 | afterEach(async () => { 91 | await purge(); 92 | }); 93 | 94 | describe('when passing invalid method', () => { 95 | it.todo('returns an error'); 96 | }); 97 | 98 | describe('when method is missing', () => { 99 | it.todo('returns an error'); 100 | }); 101 | 102 | describe('on lookup_addresses', () => { 103 | function fetchLookupAddresses(params: any) { 104 | return axios({ 105 | url: HOST, 106 | method: 'POST', 107 | responseType: 'json', 108 | data: { method: 'lookup_addresses', params } 109 | }); 110 | } 111 | 112 | describe('when not passing an array as params', () => { 113 | const tests = [ 114 | ['a string', 'a simple string'], 115 | ['an object', { a: 'b' }], 116 | ['a number', 123], 117 | ['null', null], 118 | ['undefined', undefined], 119 | ['a boolean', true] 120 | ]; 121 | // @ts-ignore 122 | it.each(tests)('returns an error when passing %s', async (title: string, params: any) => { 123 | expect(fetchLookupAddresses(params)).rejects.toThrowError(/status code 400/); 124 | }); 125 | }); 126 | 127 | describe('when passing EVM addresses', () => { 128 | it('returns only addresses with associated domains', async () => { 129 | const response = await fetchLookupAddresses([ 130 | '0xE6D0Dd18C6C3a9Af8C2FaB57d6e6A38E29d513cC', 131 | '0xe6d0dd18c6c3a9af8c2fab57d6e6a38e29d513cc', 132 | '0x0C67A201b93cf58D4a5e8D4E970093f0FB4bb0D1' 133 | ]); 134 | 135 | expect(response.status).toBe(200); 136 | expect(response.data.result).toEqual({ 137 | '0xE6D0Dd18C6C3a9Af8C2FaB57d6e6A38E29d513cC': 'sdntestens.eth', 138 | '0xe6d0dd18c6c3a9af8c2fab57d6e6a38e29d513cc': 'sdntestens.eth' 139 | }); 140 | }); 141 | }); 142 | 143 | describe('when passing non-EVM addresses', () => { 144 | it('returns only addresses with associated domains', async () => { 145 | const response = await fetchLookupAddresses([ 146 | '0x07FF6B17F07C4D83236E3FC5F94259A19D1ED41BBCF1822397EA17882E9B038D', 147 | '0x07ff6b17f07c4d83236e3fc5f94259a19d1ed41bbcf1822397ea17882e9b038d', 148 | '0x040f81578c2ab498c1252fdebdf1ed5dc083906dc7b9e3552c362db1c7c23a02' 149 | ]); 150 | 151 | expect(response.status).toBe(200); 152 | expect(response.data.result).toEqual({ 153 | '0x07FF6B17F07C4D83236E3FC5F94259A19D1ED41BBCF1822397EA17882E9B038D': 'Checkpoint', 154 | '0x07ff6b17f07c4d83236e3fc5f94259a19d1ed41bbcf1822397ea17882e9b038d': 'Checkpoint' 155 | }); 156 | }); 157 | }); 158 | 159 | describe('when passing a mix of EVM and non-EVM addresses', () => { 160 | it('returns only addresses with associated domains', async () => { 161 | const response = await fetchLookupAddresses([ 162 | '0x07FF6B17F07C4D83236E3FC5F94259A19D1ED41BBCF1822397EA17882E9B038D', 163 | '0x07ff6b17f07c4d83236e3fc5f94259a19d1ed41bbcf1822397ea17882e9b038d', 164 | '0x040f81578c2ab498c1252fdebdf1ed5dc083906dc7b9e3552c362db1c7c23a02', 165 | '0xE6D0Dd18C6C3a9Af8C2FaB57d6e6A38E29d513cC', 166 | '0xe6d0dd18c6c3a9af8c2fab57d6e6a38e29d513cc' 167 | ]); 168 | 169 | expect(response.status).toBe(200); 170 | expect(response.data.result).toEqual({ 171 | '0x07FF6B17F07C4D83236E3FC5F94259A19D1ED41BBCF1822397EA17882E9B038D': 'Checkpoint', 172 | '0x07ff6b17f07c4d83236e3fc5f94259a19d1ed41bbcf1822397ea17882e9b038d': 'Checkpoint', 173 | '0xE6D0Dd18C6C3a9Af8C2FaB57d6e6A38E29d513cC': 'sdntestens.eth', 174 | '0xe6d0dd18c6c3a9af8c2fab57d6e6a38e29d513cc': 'sdntestens.eth' 175 | }); 176 | }); 177 | }); 178 | }); 179 | }); 180 | }); 181 | -------------------------------------------------------------------------------- /test/fixtures/addresses.ts: -------------------------------------------------------------------------------- 1 | const addresses = [ 2 | '0x89ceF96c58A85d9bE6DFa46D667e71f45f9Ad046', 3 | '0x035Bd9F5C8D7176E40b8b2460f9F827079eaC797', 4 | '0x5f2e6EB4b0A1FED489c884a31262DA561ac10269', 5 | '0x05182b873EB3ca6AF892eDf3cd991D59300E10d7', 6 | '0xc3eE2126EF3Dcd72a2c4F02Ae773EEA134d3A544', 7 | '0x709FdA400a524d2C6A431cb34F8626b646597103', 8 | '0x4dBE4c4683Fb93Ad684578031Cac5a046874DA3C', 9 | '0x19F2481479CA1859D220797F7c3ED6540F7431Fe', 10 | '0x3dC7BcC70452dF24DC8fBe54dea7E8e63022e86F', 11 | '0x3a5C11Dc9c24860aF664F9B5E550EB5657382aFe', 12 | '0xd793ae6Df21Aa927AC903Ff9B30CCB249F7F2c5d', 13 | '0xbb5F2888a02fCF3C499d4CCefce009552CB70785', 14 | '0xe4291AE3244C9b46B9181e475998A2eA3328D6C1', 15 | '0x436c6C2f1B2BCd68436234EBCc2915dFBc21C4ee', 16 | '0x8E5B6aC928E8e156a515dfb5807903b2DA9DE8e9', 17 | '0x1A99e13396Aa4156aA9002837E55f9F5846A7d31', 18 | '0xa44847cfA38CCC02C29D6540f24AFa3cE06D1cB5', 19 | '0xC06Bdf209E794270EF7d66d5FA6F40A5d381a184', 20 | '0xbddc79D216794F92D3B1dD48A36D36Ead96e8555', 21 | '0x79E69F0647d6f0c29340220f952E6ff9728Ad688', 22 | '0x7f29A38eeB8B1A55D0619600dFa73d8797270d6a', 23 | '0xfBdf1e93DC58cf8eAA6e66C93a66Ca33CDCB155F', 24 | '0x6A97e50f1e919541Eb51fbA1B721229684239A8c', 25 | '0xF1a2C05A02BcD1930c47287EfD4834DaF6d29999', 26 | '0x25f41C4c9ad12ED49c29080f70078e3D7D2Ca915', 27 | '0x4D6164B15C4241F030852d38dB1b4f78b3b49F9A', 28 | '0xeB36c7c1f8B0c151e6C300E40C5692c8168de5e8', 29 | '0x55f04Ce239Ab47720eEcb3B9762e8b9091B83657', 30 | '0xCeA4582Be80d33f2635d704a69eB788F16d8a1Ce', 31 | '0x51985E014ccb3F11F47fF43f13F14925ca8D28aF', 32 | '0x6EB7FA723b5a6F79026006C82027671D56F08CE8', 33 | '0x0e93Edf7d84e436cAe53E9f48C2AE5500872626B', 34 | '0xa1546c4974B2d7017A41728704E53bA61a8c3E85', 35 | '0x2BC747680647D4fd618866dBCbb050658215bf0D', 36 | '0x3c49172019d6dF541853bD949139372F4c95f8C2', 37 | '0x275E91da0a0b4997Fadbe8298775A026d28e50a4', 38 | '0x394730a1093Ad843AFACc6D361F57699d7109Fbc', 39 | '0xff106B2341977b26450b4bAed375341F6FAe371A', 40 | '0x60e36eE3796eE95EdC2A856a260A320075f2C8bc', 41 | '0xa805EEeA9f2d71FA82D14e599986E3fb3e580A9a', 42 | '0x0Cf08931F28b9a79C31836D58D5b738DD10A3948', 43 | '0x08C60C52C8962c487fB631bB02D6a0726914Ee07', 44 | '0x6Fb481b907a3d93c3433D2552cBF1C1c75F1E293', 45 | '0xd959eE29b0A488e2Ca1eb271F12edF9afcf4f463', 46 | '0xA17BfFCAf29C3284eE413aa944009Ad432Bea6EA', 47 | '0xD46Ff86297AEB92fde6e29d6001DB5F9df0e19E9', 48 | '0x9cc4bC504Ca45946a543d36fD7Ee437c9eAC0df3', 49 | '0x50cF41Ac392792478B0514E45b5040313FF17f67', 50 | '0x70cC42E73667A68Ad9F2eCa240daF92D08A6b6BD', 51 | '0x959DB71AFa258c289A39788885116cDf789C4078', 52 | '0xEf289dD4e59a065b2e1bAe1022f494E6E61fc800', 53 | '0xc426cEf69532a5dF20c2a822f4C10E0e45aC5A49', 54 | '0x0d88434ca4AF2DE066C6fb0328A773D10E9E2ad1', 55 | '0x507d416567438f9bfc6c7968ADF46Ee1aAaB6a08', 56 | '0xc2D2a2d19B4Ed10c558eAa1B7A06AB99FA3f7C18', 57 | '0xDbA9c49Fc4A94A52e2e56d6BcACFE5bF4cd56F19', 58 | '0x14c0FFF54e2E4fb808Ec32e56fB6d61777Ff8EF2', 59 | '0x640C3c7D0685ce8564a9ef52F0c7F99bADadb050', 60 | '0x548F592885D736A1758E8DA751421D49604aE1D2', 61 | '0xcE6eA038Bc8e874ebdFd561F88F64dc0440049dd', 62 | '0x5A57FC0F1dBBaBA1b01D24B37154F2E25aFafBB5', 63 | '0xdf062aD78A67b3C28bF21EAD2f242f1f7FB7d314', 64 | '0xA715814052c73a4BE0DADc291f0164ee6F23c199', 65 | '0x2492C15DA9F1BA6a39D38eC56eA7c8e2C4720d0b', 66 | '0x879d0Ab531e8FfDaC7749E9F591C946dc786efE3', 67 | '0x27DcCC6c93eA747DeE85e226F0c32C0a8F3cA0AC', 68 | '0x24449D799237C1A88F0ECf22edcd409D839Aa1E3', 69 | '0xBFA62C5e9578A8A4461B7C5Fca060AA7AC202c52', 70 | '0xB0ccB28336D942FdF18fA17F743285053DfADD71', 71 | '0xAcCb7f6244B6dF3E18AB9FbF0481d79cd90ADE63', 72 | '0xFaf12776f12eAD8c5927519C67A18F1Be8a1ce09', 73 | '0x39AD791Ddd06c6F9036c3Ee15Cb3366217A6666E', 74 | '0x84B862BebDA168A1cd33A39DA70636cB2f281Cc6', 75 | '0xB5Beda3A0aA726A0F4F132eB9964d433387a1B94', 76 | '0x3c1eF89c80E862cd568864Fb7afCB9491B44ec8D', 77 | '0xd9564C80AdaFAc8F03106F6a48FB7ccBA0943868', 78 | '0xA0f04734E9a41466292c6C2b6891CFCe9Fe9d4c2', 79 | '0x0E1c7cBD350aa7534719Dc105EFA9a233648Ce61', 80 | '0x6d6fD19c876164FA67D0E9023563738C12Cad9C3', 81 | '0x3C192BBEA50Db7A289D87c540b0af2911172DFd8', 82 | '0x70e0F11BF0693ba675e541cf1F7fbAEcE1EE834D', 83 | '0x02ae7b57C2e1A7370b96929f526E17b13Ed9Ae65', 84 | '0x9A409F3714ec1dED9A90E9c26263bA281aA1730a', 85 | '0x1A8247208a1b54f178ed1999E6b2B041ebCe2a27', 86 | '0xD2B78CBDE60Dd2BfE56fc34bBF5C009Fa20c4883', 87 | '0x0c77cA68DaA978eCc51547561E82bEe16C61c5AD', 88 | '0x1871490893546A7541335103128095e9C5374c30', 89 | '0x2fa08Bb5D49E946A2130668F1D9746C89A39BD6a', 90 | '0x90fcF2D5840364Ad4322E0227Defbd408816C4b2', 91 | '0x8c80A337c282E36Be5185bCc70Fb33fc289b63DC', 92 | '0x4Ce399edB2727701A91631Ff35f103E3aDAD9e34', 93 | '0xBa430D51A69a3E352F498cD400092363d9519bfd', 94 | '0xEC7576ed2635304B8E9E5941cBC45e373D40F0B4', 95 | '0x9D2Fbf43EC79d51F6087150b9dAE65dCC067E0Be', 96 | '0xc57a25Bdc4e36dc99e4e52bCe1f99592DbF4AEB9', 97 | '0x79510A1DE26b657FCC87483bd9A387565114Fa94', 98 | '0xa88648AB3B5059573175F8D3732112536ef00f44', 99 | '0xBdC1F27A57F514c9cCc5e2A7cd5202CFa2639593', 100 | '0x06b7cF60B1f03681A7Dc5ae00493e2186eCB15A0', 101 | '0xae30268C81F9b4c391Adff0877EF7FAa36F38eb1' 102 | ]; 103 | 104 | export default addresses; 105 | -------------------------------------------------------------------------------- /test/integration/addressResolvers/ens.test.ts: -------------------------------------------------------------------------------- 1 | import { lookupAddresses, resolveNames } from '../../../src/addressResolvers/ens'; 2 | import testAddressResolver from './helper'; 3 | 4 | testAddressResolver({ 5 | name: 'ENS', 6 | lookupAddresses, 7 | resolveNames, 8 | validAddress: '0xE6D0Dd18C6C3a9Af8C2FaB57d6e6A38E29d513cC', 9 | validDomain: 'sdntestens.eth', 10 | blankAddress: '0x0C67A201b93cf58D4a5e8D4E970093f0FB4bb0D1', 11 | invalidDomains: ['domain.crypto', 'domain.lens', 'domain.com'] 12 | }); 13 | -------------------------------------------------------------------------------- /test/integration/addressResolvers/helper.ts: -------------------------------------------------------------------------------- 1 | export default function testAddressResolver({ 2 | name, 3 | lookupAddresses, 4 | resolveNames, 5 | validAddress, 6 | validDomain, 7 | blankAddress, 8 | invalidDomains 9 | }) { 10 | describe(`${name} address resolver`, () => { 11 | describe('lookupAddresses()', () => { 12 | describe('when the address is associated to a domain', () => { 13 | it('returns the domain associated to the address', async () => { 14 | return expect(lookupAddresses([validAddress])).resolves.toEqual({ 15 | [validAddress]: validDomain 16 | }); 17 | }, 10e3); 18 | }); 19 | 20 | describe('when the address is not associated to a domain', () => { 21 | it('returns an empty object', () => { 22 | return expect(lookupAddresses([blankAddress])).resolves.toEqual({}); 23 | }, 10e3); 24 | }); 25 | 26 | describe('when mix of addresses with and without associated domains', () => { 27 | it('returns an object with only addresses associated to a domain', () => { 28 | return expect(lookupAddresses([validAddress, blankAddress])).resolves.toEqual({ 29 | [validAddress]: validDomain 30 | }); 31 | }, 10e3); 32 | }); 33 | }); 34 | 35 | describe('resolveNames()', () => { 36 | if (!resolveNames) { 37 | it.todo('missing tests for resolveNames()'); 38 | } else { 39 | describe('when the domain is associated to an address', () => { 40 | it('returns an address', () => { 41 | return expect(resolveNames([validDomain])).resolves.toEqual({ 42 | [validDomain]: validAddress 43 | }); 44 | }, 10e3); 45 | }); 46 | 47 | describe('when the domain is not associated to an address', () => { 48 | it('returns undefined', () => { 49 | return expect(resolveNames(['test.snapshotdomain'])).resolves.toEqual({}); 50 | }, 10e3); 51 | }); 52 | 53 | describe('when mix of domains with and without associated address', () => { 54 | it('returns an object with only handles associated to an address', () => { 55 | return expect(resolveNames([...invalidDomains, validDomain])).resolves.toEqual({ 56 | [validDomain]: validAddress 57 | }); 58 | }, 10e3); 59 | }); 60 | } 61 | }); 62 | }); 63 | } 64 | -------------------------------------------------------------------------------- /test/integration/addressResolvers/index.test.ts: -------------------------------------------------------------------------------- 1 | import { lookupAddresses, resolveNames } from '../../../src/addressResolvers'; 2 | import { getCache, setCache } from '../../../src/addressResolvers/cache'; 3 | import redis from '../../../src/helpers/redis'; 4 | import randomAddresses from '../../fixtures/addresses'; 5 | 6 | function purge() { 7 | if (!redis) return; 8 | 9 | return redis.flushDb(); 10 | } 11 | 12 | describe('addressResolvers', () => { 13 | describe('lookupAddresses()', () => { 14 | describe('when passing more than 50 addresses', () => { 15 | it('rejects with an error', async () => { 16 | return expect(lookupAddresses(randomAddresses)).rejects.toEqual({ 17 | error: 'params must contains less than 50 items', 18 | code: 400 19 | }); 20 | }); 21 | }); 22 | 23 | describe('when the params contains invalid address', () => { 24 | it('should ignore the invalid address', () => { 25 | expect( 26 | lookupAddresses(['test', '0xeF8305E140ac520225DAf050e2f71d5fBcC543e7']) 27 | ).resolves.toEqual({ '0xeF8305E140ac520225DAf050e2f71d5fBcC543e7': 'less' }); 28 | }); 29 | }); 30 | 31 | describe('when not cached', () => { 32 | beforeEach(async () => { 33 | await purge(); 34 | }); 35 | 36 | it('should return the ENS handle first if associated to multiple resolvers', () => { 37 | return expect( 38 | lookupAddresses(['0xeF8305E140ac520225DAf050e2f71d5fBcC543e7']) 39 | ).resolves.toEqual({ 40 | '0xeF8305E140ac520225DAf050e2f71d5fBcC543e7': 'less' 41 | }); 42 | }, 10e3); 43 | 44 | it('does not return addresses without domain', () => { 45 | return expect( 46 | lookupAddresses([ 47 | '0xeF8305E140ac520225DAf050e2f71d5fBcC543e7', 48 | '0x0C67A201b93cf58D4a5e8D4E970093f0FB4bb0D1' 49 | ]) 50 | ).resolves.toEqual({ 51 | '0xeF8305E140ac520225DAf050e2f71d5fBcC543e7': 'less' 52 | }); 53 | }, 10e3); 54 | 55 | it('keeps the original input case formatting', () => { 56 | return expect( 57 | lookupAddresses([ 58 | '0xeF8305E140ac520225DAf050e2f71d5fBcC543e7', 59 | '0xEF8305E140AC520225DAF050E2F71D5FBCC543E7', 60 | '0xef8305e140ac520225daf050e2f71d5fbcc543e7' 61 | ]) 62 | ).resolves.toEqual({ 63 | '0xeF8305E140ac520225DAf050e2f71d5fBcC543e7': 'less', 64 | '0xEF8305E140AC520225DAF050E2F71D5FBCC543E7': 'less', 65 | '0xef8305e140ac520225daf050e2f71d5fbcc543e7': 'less' 66 | }); 67 | }, 10e3); 68 | }); 69 | 70 | describe('when cached', () => { 71 | beforeEach(async () => { 72 | await purge(); 73 | }); 74 | 75 | it('should cache the results', async () => { 76 | await expect( 77 | lookupAddresses([ 78 | '0xeF8305E140ac520225DAf050e2f71d5fBcC543e7', 79 | '0x0C67A201b93cf58D4a5e8D4E970093f0FB4bb0D1' 80 | ]) 81 | ).resolves.toEqual({ 82 | '0xeF8305E140ac520225DAf050e2f71d5fBcC543e7': 'less' 83 | }); 84 | 85 | return expect( 86 | getCache([ 87 | '0xeF8305E140ac520225DAf050e2f71d5fBcC543e7', 88 | '0x0C67A201b93cf58D4a5e8D4E970093f0FB4bb0D1' 89 | ]) 90 | ).resolves.toEqual({ 91 | '0xeF8305E140ac520225DAf050e2f71d5fBcC543e7': 'less', 92 | '0x0C67A201b93cf58D4a5e8D4E970093f0FB4bb0D1': '' 93 | }); 94 | }); 95 | 96 | it('should return the cached results', async () => { 97 | await setCache({ 98 | '0xeF8305E140ac520225DAf050e2f71d5fBcC543e7': 'test.eth', 99 | '0xef8305e140ac520225daf050e2f71d5fbcc543e7': 'test1.eth' 100 | }); 101 | 102 | return expect( 103 | lookupAddresses([ 104 | '0xeF8305E140ac520225DAf050e2f71d5fBcC543e7', 105 | '0xef8305e140ac520225daf050e2f71d5fbcc543e7' 106 | ]) 107 | ).resolves.toEqual({ 108 | '0xeF8305E140ac520225DAf050e2f71d5fBcC543e7': 'test.eth', 109 | '0xef8305e140ac520225daf050e2f71d5fbcc543e7': 'test.eth' 110 | }); 111 | }); 112 | }); 113 | }); 114 | 115 | describe('resolveNames()', () => { 116 | describe('when passing more than 5 addresses', () => { 117 | it('rejects with an error', async () => { 118 | const params = ['1.com', '2.com', '3.com', '4.com', '5.com', '6.com']; 119 | 120 | return expect(resolveNames(params)).rejects.toEqual({ 121 | error: 'params must contains less than 5 items', 122 | code: 400 123 | }); 124 | }); 125 | }); 126 | 127 | describe('when not cached', () => { 128 | beforeEach(async () => { 129 | await purge(); 130 | }); 131 | 132 | it('should return the address associated to the handle', () => { 133 | return expect(resolveNames(['snapshot.crypto'])).resolves.toEqual({ 134 | 'snapshot.crypto': '0xeF8305E140ac520225DAf050e2f71d5fBcC543e7' 135 | }); 136 | }, 10e3); 137 | 138 | it('return null when the handle does not exist', () => { 139 | return expect(resolveNames(['test-snapshot.eth'])).resolves.toEqual({ 140 | 'test-snapshot.eth': undefined 141 | }); 142 | }, 10e3); 143 | 144 | it('keeps the original case formatting', () => { 145 | return expect( 146 | resolveNames(['snapshot.crypto', 'SNAPSHOT.CRYPTO', 'Snapshot.Crypto']) 147 | ).resolves.toEqual({ 148 | 'snapshot.crypto': '0xeF8305E140ac520225DAf050e2f71d5fBcC543e7', 149 | 'SNAPSHOT.CRYPTO': '0xeF8305E140ac520225DAf050e2f71d5fBcC543e7', 150 | 'Snapshot.Crypto': '0xeF8305E140ac520225DAf050e2f71d5fBcC543e7' 151 | }); 152 | }, 10e3); 153 | }); 154 | 155 | describe('when cached', () => { 156 | beforeEach(async () => { 157 | await purge(); 158 | }); 159 | 160 | it('should cache the results', async () => { 161 | await expect(resolveNames(['snapshot.crypto'])).resolves.toEqual({ 162 | 'snapshot.crypto': '0xeF8305E140ac520225DAf050e2f71d5fBcC543e7' 163 | }); 164 | 165 | return expect(getCache(['snapshot.crypto'])).resolves.toEqual({ 166 | 'snapshot.crypto': '0xeF8305E140ac520225DAf050e2f71d5fBcC543e7' 167 | }); 168 | }); 169 | 170 | it('should return the cached results', async () => { 171 | await setCache({ 'snapshot.crypto': '0x0', 'SNAPSHOT.CRYPTO': '0x1' }); 172 | 173 | return expect(resolveNames(['snapshot.crypto', 'SNAPSHOT.CRYPTO'])).resolves.toEqual({ 174 | 'snapshot.crypto': '0x0', 175 | 'SNAPSHOT.CRYPTO': '0x0' 176 | }); 177 | }); 178 | }); 179 | }); 180 | }); 181 | -------------------------------------------------------------------------------- /test/integration/addressResolvers/lens.test.ts: -------------------------------------------------------------------------------- 1 | import { lookupAddresses, resolveNames } from '../../../src/addressResolvers/lens'; 2 | import testAddressResolver from './helper'; 3 | 4 | testAddressResolver({ 5 | name: 'Lens', 6 | lookupAddresses, 7 | resolveNames, 8 | validAddress: '0x218F68106128E637fc942C2b1Ed1e3c326125344', 9 | validDomain: 'fabien.lens', 10 | blankAddress: '0x0C67A201b93cf58D4a5e8D4E970093f0FB4bb0D1', 11 | invalidDomains: ['domain.crypto', 'domain.eth', 'domain.com'] 12 | }); 13 | -------------------------------------------------------------------------------- /test/integration/addressResolvers/shibarium.test.ts: -------------------------------------------------------------------------------- 1 | import { lookupAddresses, resolveNames } from '../../../src/addressResolvers/shibarium'; 2 | import testAddressResolver from './helper'; 3 | 4 | testAddressResolver({ 5 | name: 'Shibarium', 6 | lookupAddresses, 7 | resolveNames, 8 | validAddress: '0x220bc93D88C0aF11f1159eA89a885d5ADd3A7Cf6', 9 | validDomain: 'boorger.shib', 10 | blankAddress: '0x91FD2c8d24767db4Ece7069AA27832ffaf8590f3', 11 | invalidDomains: ['domain.crypto', 'domain.eth', 'domain.com', 'inexistent-domain-for-test.shib'] 12 | }); 13 | -------------------------------------------------------------------------------- /test/integration/addressResolvers/snapshot.test.ts: -------------------------------------------------------------------------------- 1 | import { lookupAddresses } from '../../../src/addressResolvers/snapshot'; 2 | import testAddressResolver from './helper'; 3 | 4 | testAddressResolver({ 5 | name: 'Snapshot', 6 | lookupAddresses, 7 | resolveNames: null, 8 | validAddress: '0xeF8305E140ac520225DAf050e2f71d5fBcC543e7', 9 | validDomain: 'less', 10 | blankAddress: '0x0C67A201b93cf58D4a5e8D4E970093f0FB4bb0D1', 11 | invalidDomains: [''] 12 | }); 13 | -------------------------------------------------------------------------------- /test/integration/addressResolvers/starknet.test.ts: -------------------------------------------------------------------------------- 1 | import { lookupAddresses, resolveNames } from '../../../src/addressResolvers/starknet'; 2 | import testAddressResolver from './helper'; 3 | 4 | testAddressResolver({ 5 | name: 'Starknet', 6 | lookupAddresses, 7 | resolveNames, 8 | validAddress: '0x07ff6b17f07c4d83236e3fc5f94259a19d1ed41bbcf1822397ea17882e9b038d', 9 | validDomain: 'checkpoint.stark', 10 | blankAddress: '0x040f81578c2ab498c1252fdebdf1ed5dc083906dc7b9e3552c362db1c7c23a02', 11 | invalidDomains: ['domain.crypto', 'domain.eth', 'domain.com'] 12 | }); 13 | -------------------------------------------------------------------------------- /test/integration/addressResolvers/unstoppableDomains.test.ts: -------------------------------------------------------------------------------- 1 | import { lookupAddresses, resolveNames } from '../../../src/addressResolvers/unstoppableDomains'; 2 | import testAddressResolver from './helper'; 3 | 4 | testAddressResolver({ 5 | name: 'UnstoppableDomains', 6 | lookupAddresses, 7 | resolveNames, 8 | validAddress: '0xeF8305E140ac520225DAf050e2f71d5fBcC543e7', 9 | validDomain: 'snapshot.crypto', 10 | blankAddress: '0x0C67A201b93cf58D4a5e8D4E970093f0FB4bb0D1', 11 | invalidDomains: ['domain.eth', 'domain.lens', 'domain.com'] 12 | }); 13 | -------------------------------------------------------------------------------- /test/integration/addressResolvers/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { normalizeHandles, withoutEmptyAddress } from '../../../src/addressResolvers/utils'; 2 | 3 | describe('utils', () => { 4 | describe('normalizeHandles', () => { 5 | const VALID_DOMAINS = ['test.com', 'test.lens', 'test.ens']; 6 | const INVALID_DOMAINS = [1, '', false, 'hello world.com', 'hello']; 7 | 8 | it('should return only domain-like values', () => { 9 | // @ts-ignore 10 | expect(normalizeHandles([...INVALID_DOMAINS, ...VALID_DOMAINS])).toEqual([...VALID_DOMAINS]); 11 | }); 12 | }); 13 | 14 | describe('withoutEmptyAddress', () => { 15 | const EMPTY_ADDRESS = '0x0000000000000000000000000000000000000000'; 16 | 17 | it('should remove entry with EMPTY_ADDRESS key', () => { 18 | const input = { 19 | [EMPTY_ADDRESS]: 'some value' 20 | }; 21 | expect(withoutEmptyAddress(input)).toEqual({}); 22 | }); 23 | 24 | it('should keep normal entries', () => { 25 | const input = { 26 | '0x123': 'value1', 27 | '0x456': 'value2' 28 | }; 29 | expect(withoutEmptyAddress(input)).toEqual(input); 30 | }); 31 | 32 | it('should handle mixed entries', () => { 33 | const input = { 34 | [EMPTY_ADDRESS]: 'empty', 35 | '0x123': 'value1', 36 | '0x456': 'value2' 37 | }; 38 | expect(withoutEmptyAddress(input)).toEqual({ 39 | '0x123': 'value1', 40 | '0x456': 'value2' 41 | }); 42 | }); 43 | 44 | it('should handle empty object', () => { 45 | expect(withoutEmptyAddress({})).toEqual({}); 46 | }); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /test/integration/getOwner.test.ts: -------------------------------------------------------------------------------- 1 | import getOwner from '../../src/getOwner'; 2 | 3 | describe('getOwner', () => { 4 | describe('on claimed names', () => { 5 | it('should return an address for shibarium', async () => { 6 | const result = await getOwner('boorger.shib', '109'); 7 | expect(result).toContain('0x220bc93D88C0aF11f1159eA89a885d5ADd3A7Cf6'); 8 | }); 9 | 10 | it('should return an address for puppynet', async () => { 11 | const result = await getOwner('snapshot-test-1.shib', '157'); 12 | expect(result).toContain('0x91FD2c8d24767db4Ece7069AA27832ffaf8590f3'); 13 | }); 14 | }); 15 | 16 | describe('on unclaimed names', () => { 17 | // This may stop working since we don't own this domain. 18 | // In such case, go to https://www.shibariumscan.io/name-domains?only_active=true 19 | // and find a domain that is not claimed (owner = 0x1A039289Af80a806f562396569fBC6d4A862C25c), 20 | // but has a resolved address. 21 | it('should return an address for shibarium', async () => { 22 | const result = await getOwner('scoobysnacks.shib', '109'); 23 | expect(result).toContain('0xa226a85fF338f5015cd3Da6a987CD08D70619977'); 24 | }); 25 | 26 | it('should return an address for puppynet', async () => { 27 | const result = await getOwner('snapshot-test-unclaimed.shib', '157'); 28 | expect(result).toContain('0x91FD2c8d24767db4Ece7069AA27832ffaf8590f3'); 29 | }); 30 | 31 | it('should return an empty address when the name does not have a primary names', async () => { 32 | const result = await getOwner('snapshot-test-unclaimed-unresolved.shib', '157'); 33 | expect(result).toContain('0x0000000000000000000000000000000000000000'); 34 | }); 35 | }); 36 | 37 | it('should return an empty address for shibarium when domain does not exist', async () => { 38 | const result = await getOwner('invalid-domain-h.shib', '109'); 39 | expect(result).toContain('0x0000000000000000000000000000000000000000'); 40 | }); 41 | 42 | it('should return an empty address for puppynet when domain does not exist', async () => { 43 | const result = await getOwner('invalid-domain-h.shib', '157'); 44 | expect(result).toContain('0x0000000000000000000000000000000000000000'); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /test/integration/lookupDomains.test.ts: -------------------------------------------------------------------------------- 1 | import lookupDomains from '../../src/lookupDomains'; 2 | 3 | describe('lookupDomains', () => { 4 | it('should return an array of addresses on default network', async () => { 5 | const result = await lookupDomains('0x24F15402C6Bb870554489b2fd2049A85d75B982f'); 6 | 7 | expect(result).toBeInstanceOf(Array); 8 | expect(result.length).toBeGreaterThan(0); 9 | expect(result[0]).toContain('.eth'); 10 | }); 11 | 12 | it('should return an array of addresses on sepolia', async () => { 13 | const result = await lookupDomains('0x24F15402C6Bb870554489b2fd2049A85d75B982f', '11155111'); 14 | 15 | expect(result).toContain('testchaitu.eth'); 16 | }); 17 | 18 | it('should return an empty array if the address is not provided', async () => { 19 | const result = await lookupDomains(''); 20 | 21 | expect(result).toEqual([]); 22 | }); 23 | 24 | it('should return an empty array if the address does not own any domains', async () => { 25 | const result = await lookupDomains('0xf1f09AdC06aAB740AA16004D62Dbd89484d3Be90'); 26 | 27 | expect(result).toEqual([]); 28 | }); 29 | 30 | it('should return empty array on invalid network', async () => { 31 | const result = await lookupDomains('0x24F15402C6Bb870554489b2fd2049A85d75B982f', 'test'); 32 | 33 | expect(result).toEqual([]); 34 | }); 35 | 36 | it('should filter out expired domains', async () => { 37 | const result = await lookupDomains('0x76ece6825602294b87a40d783982d83bb8ebcaf7'); 38 | 39 | expect(result).not.toContain(['everaidao.eth', 'everark.eth', 'everaiark.eth']); 40 | }); 41 | 42 | it('should return an empty array if the address is not a valid address', async () => { 43 | const result = await lookupDomains('notAValidAddress'); 44 | expect(result).toEqual([]); 45 | }); 46 | 47 | it('should return an array of addresses for shibarium', async () => { 48 | const result = await lookupDomains('0x220bc93D88C0aF11f1159eA89a885d5ADd3A7Cf6', '109'); 49 | expect(result).toContain('boorger.shib'); 50 | }); 51 | 52 | it('should return an empty array if the address does not own any shibarium domains', async () => { 53 | const result = await lookupDomains('0x757a20E145435B5bDaf0E274987653aeCD47cf37', '109'); 54 | expect(result).toEqual([]); 55 | }); 56 | 57 | it('should return all the addresses from the given chain', async () => { 58 | const result = await lookupDomains('0x220bc93D88C0aF11f1159eA89a885d5ADd3A7Cf6', ['1', '109']); 59 | expect(result).toEqual(['boorger.eth', 'boorger.shib']); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /test/integration/resolvers/blockie.test.ts: -------------------------------------------------------------------------------- 1 | import resolvers from '../../../src/resolvers'; 2 | 3 | describe('resolvers', () => { 4 | describe('blockie', () => { 5 | it('should resolve', async () => { 6 | const result = await resolvers.blockie('0x556B14CbdA79A36dC33FcD461a04A5BCb5dC2A70'); 7 | 8 | expect(result).toBeInstanceOf(Buffer); 9 | expect(result.length).toBeGreaterThan(1000); 10 | }); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /test/integration/resolvers/ens.test.ts: -------------------------------------------------------------------------------- 1 | import resolvers from '../../../src/resolvers'; 2 | 3 | describe('resolvers', () => { 4 | jest.retryTimes(3); 5 | 6 | describe('ens', () => { 7 | it('should return false if avatar is not set', async () => { 8 | const result = await resolvers.ens('0x556B14CbdA79A36dC33FcD461a04A5BCb5dC2A70'); 9 | 10 | return expect(result).toBe(false); 11 | }); 12 | 13 | it('should return false on invalid ENS name', async () => { 14 | const result = await resolvers.ens('snapshot-test.eth'); 15 | 16 | return expect(result).toBe(false); 17 | }, 10e3); 18 | 19 | it('should resolve', async () => { 20 | const result = await resolvers.ens('fabien.eth'); 21 | 22 | expect(result).toBeInstanceOf(Buffer); 23 | return expect(result.length).toBeGreaterThan(1000); 24 | }, 30e3); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /test/integration/resolvers/farcaster.test.ts: -------------------------------------------------------------------------------- 1 | import resolvers from '../../../src/resolvers'; 2 | 3 | describe('resolvers', () => { 4 | if (!process.env.NEYNAR_API_KEY) { 5 | it.todo('is missing NEYNAR_API_KEY'); 6 | } else { 7 | describe('farcaster', () => { 8 | it('should return false for invalid address', async () => { 9 | const result = await resolvers.farcaster('0x556B14CbdA79A36dC33FcD461a04A5BCb5dC2A70!'); 10 | 11 | expect(result).toBe(false); 12 | }); 13 | 14 | it('should return false for address without farcaster account', async () => { 15 | const result = await resolvers.farcaster('0x2963fD170E12d748d0A80430DdC090e059f6013F'); 16 | 17 | expect(result).toBe(false); 18 | }); 19 | 20 | it('should resolve', async () => { 21 | const result = await resolvers.farcaster('0xd1a8Dd23e356B9fAE27dF5DeF9ea025A602EC81e'); 22 | 23 | expect(result).toBeInstanceOf(Buffer); 24 | expect((result as Buffer).length).toBeGreaterThan(1000); 25 | }); 26 | }); 27 | } 28 | }); 29 | -------------------------------------------------------------------------------- /test/integration/resolvers/jazzicon.test.ts: -------------------------------------------------------------------------------- 1 | import resolvers from '../../../src/resolvers'; 2 | 3 | describe('resolvers', () => { 4 | describe('jazzicon', () => { 5 | it('should resolve', async () => { 6 | const result = await resolvers.jazzicon('0x556B14CbdA79A36dC33FcD461a04A5BCb5dC2A70'); 7 | 8 | expect(result).toBeInstanceOf(Buffer); 9 | expect(result.length).toBeGreaterThan(1000); 10 | }); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /test/integration/resolvers/lens.test.ts: -------------------------------------------------------------------------------- 1 | import resolvers from '../../../src/resolvers'; 2 | 3 | describe('resolvers', () => { 4 | describe('lens', () => { 5 | it('should return false if missing', async () => { 6 | const result = await resolvers.lens('0x556B14CbdA79A36dC33FcD461a04A5BCb5dC2A70'); 7 | 8 | expect(result).toBe(false); 9 | }); 10 | 11 | it('should return false on invalid address', async () => { 12 | const result = await resolvers.lens('0x556B14CbdA79A36dC33FcD461a04A5BCb5dC2A70aaa'); 13 | 14 | expect(result).toBe(false); 15 | }); 16 | 17 | it('should return false on non-existent domain', async () => { 18 | const result = await resolvers.lens('non-existent-domain.lens'); 19 | 20 | expect(result).toBe(false); 21 | }); 22 | 23 | it('should resolve with handle', async () => { 24 | const result = await resolvers.lens('fabien.lens'); 25 | 26 | expect(result).toBeInstanceOf(Buffer); 27 | expect(result.length).toBeGreaterThan(1000); 28 | }); 29 | 30 | it('should resolve with address', async () => { 31 | const result = await resolvers.lens('0x218F68106128E637fc942C2b1Ed1e3c326125344'); 32 | 33 | expect(result).toBeInstanceOf(Buffer); 34 | expect(result.length).toBeGreaterThan(1000); 35 | }); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /test/integration/resolvers/selfid.test.ts: -------------------------------------------------------------------------------- 1 | import resolvers from '../../../src/resolvers'; 2 | 3 | describe.skip('resolvers', () => { 4 | describe('selfid', () => { 5 | it('should return false if missing DID', async () => { 6 | const result = await resolvers.selfid('0x290ADCcA6253aCe88b10A6bb34C07a5Ad10fC6B0'); 7 | 8 | expect(result).toBe(false); 9 | }); 10 | 11 | it('should return false if has no avatar', async () => { 12 | const result = await resolvers.selfid('0xd98420cFB1cd92828D192565A824B5728a566B11'); 13 | 14 | expect(result).toBe(false); 15 | }); 16 | 17 | it('should resolve', async () => { 18 | const result = await resolvers.selfid('0x556B14CbdA79A36dC33FcD461a04A5BCb5dC2A70'); 19 | 20 | expect(result).toBeInstanceOf(Buffer); 21 | expect(result.length).toBeGreaterThan(1000); 22 | }, 30000); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /test/integration/resolvers/snapshot.test.ts: -------------------------------------------------------------------------------- 1 | import resolvers from '../../../src/resolvers'; 2 | 3 | describe('resolvers', () => { 4 | describe('snapshot', () => { 5 | describe('on user avatar', () => { 6 | it('should return false if missing', async () => { 7 | const result = await resolvers.snapshot('0x556B14CbdA79A36dC33FcD461a04A5BCb5dC2A70'); 8 | 9 | expect(result).toBe(false); 10 | }); 11 | 12 | it('should resolve regardless of network', async () => { 13 | const result = await resolvers.snapshot( 14 | '0xeF8305E140ac520225DAf050e2f71d5fBcC543e7', 15 | 1, 16 | 'eth' 17 | ); 18 | 19 | expect(result).toBeInstanceOf(Buffer); 20 | expect(result.length).toBeGreaterThan(1000); 21 | }); 22 | 23 | it('should resolve', async () => { 24 | const result = await resolvers.snapshot('0xeF8305E140ac520225DAf050e2f71d5fBcC543e7'); 25 | 26 | expect(result).toBeInstanceOf(Buffer); 27 | expect(result.length).toBeGreaterThan(1000); 28 | }); 29 | }); 30 | }); 31 | 32 | describe('on user cover', () => { 33 | it('should return false if missing', async () => { 34 | const result = await resolvers['user-cover']('0x556B14CbdA79A36dC33FcD461a04A5BCb5dC2A70'); 35 | 36 | expect(result).toBe(false); 37 | }); 38 | 39 | it('should resolve regardless of network', async () => { 40 | const result = await resolvers.snapshot( 41 | '0xf1f09AdC06aAB740AA16004D62Dbd89484d3Be90', 42 | 1, 43 | 'eth' 44 | ); 45 | 46 | expect(result).toBeInstanceOf(Buffer); 47 | expect(result.length).toBeGreaterThan(1000); 48 | }); 49 | 50 | it('should resolve', async () => { 51 | const result = await resolvers['user-cover']('0xf1f09AdC06aAB740AA16004D62Dbd89484d3Be90'); 52 | 53 | expect(result).toBeInstanceOf(Buffer); 54 | expect(result.length).toBeGreaterThan(1000); 55 | }); 56 | }); 57 | 58 | describe('on space avatar', () => { 59 | it('should return false if missing', async () => { 60 | const result = await resolvers.space('idonthaveensdomain.eth'); 61 | 62 | expect(result).toBe(false); 63 | }); 64 | 65 | it('should return false on unsupported network', async () => { 66 | const result = await resolvers.space('ens.eth', 1, 'eth'); 67 | 68 | expect(result).toBe(false); 69 | }); 70 | 71 | it('should resolve', async () => { 72 | const result = await resolvers.space('ens.eth'); 73 | 74 | expect(result).toBeInstanceOf(Buffer); 75 | expect(result.length).toBeGreaterThan(1000); 76 | }); 77 | 78 | it('should return same result for both legacy and non-legacy format', async () => { 79 | const resultA = await resolvers.space('ens.eth'); 80 | const resultB = await resolvers.space('ens.eth', 1, 's'); 81 | 82 | expect(resultA).toBeInstanceOf(Buffer); 83 | expect(resultA.length).toBeGreaterThan(1000); 84 | expect(resultA).toEqual(resultB); 85 | }); 86 | }); 87 | 88 | describe('on space cover', () => { 89 | it('should return false if missing', async () => { 90 | const result = await resolvers['space-cover']('idonthaveensdomain.eth'); 91 | 92 | expect(result).toBe(false); 93 | }); 94 | 95 | it('should return false on unsupported network', async () => { 96 | const result = await resolvers['space-cover']('test.wa0x6e.eth', 1, 'eth'); 97 | 98 | expect(result).toBe(false); 99 | }); 100 | 101 | it('should resolve', async () => { 102 | const result = await resolvers['space-cover']('test.wa0x6e.eth'); 103 | 104 | expect(result).toBeInstanceOf(Buffer); 105 | expect(result.length).toBeGreaterThan(1000); 106 | }); 107 | 108 | it('should return same result for both legacy and non-legacy format', async () => { 109 | const resultA = await resolvers['space-cover']('test.wa0x6e.eth'); 110 | const resultB = await resolvers['space-cover']('test.wa0x6e.eth', 1, 's'); 111 | 112 | expect(resultA).toBeInstanceOf(Buffer); 113 | expect(resultA.length).toBeGreaterThan(1000); 114 | expect(resultA).toEqual(resultB); 115 | }); 116 | }); 117 | }); 118 | -------------------------------------------------------------------------------- /test/integration/resolvers/space-sx.test.ts: -------------------------------------------------------------------------------- 1 | import resolvers from '../../../src/resolvers'; 2 | 3 | describe('resolvers', () => { 4 | describe('space-sx', () => { 5 | describe('avatar', () => { 6 | it('should return false if missing', async () => { 7 | const result = await resolvers['space-sx']('0x06ba9855965EeEc09B5D43B113944c27F45aD3Ce'); 8 | 9 | expect(result).toBe(false); 10 | }); 11 | 12 | it('should return false if address is invalid', async () => { 13 | const result = await resolvers['space-sx']('0x00006ba9855965EeEc09B5D43B113944c27F45aD3Ce'); 14 | 15 | expect(result).toBe(false); 16 | }); 17 | 18 | it.todo('should resolve on eth'); 19 | 20 | it('should resolve on arbitrum', async () => { 21 | const result = await resolvers['space-sx']('0xFd36252770642Ac48FC3A06d7A1D00be8946dd18'); 22 | 23 | expect(result).toBeInstanceOf(Buffer); 24 | expect(result.length).toBeGreaterThan(100); 25 | }); 26 | 27 | it('should resolve on polygon', async () => { 28 | const result = await resolvers['space-sx']('0x80D0Ffd8739eABF16436074fF64DC081c60C833A'); 29 | 30 | expect(result).toBeInstanceOf(Buffer); 31 | expect(result.length).toBeGreaterThan(100); 32 | }); 33 | 34 | it('should resolve on optimism', async () => { 35 | const result = await resolvers['space-sx']('0x2EF7E7CF469f5296011664682D58b57D38a3c83f'); 36 | 37 | expect(result).toBeInstanceOf(Buffer); 38 | expect(result.length).toBeGreaterThan(100); 39 | }); 40 | 41 | it('should resolve on starknet', async () => { 42 | const result = await resolvers['space-sx']( 43 | '0x010841ba1d0c66602aa27837560823e631b19686ebbdcd591caa42a7c01611c0' 44 | ); 45 | 46 | expect(result).toBeInstanceOf(Buffer); 47 | expect(result.length).toBeGreaterThan(100); 48 | }); 49 | 50 | it('should resolve on starknet sepolia', async () => { 51 | const result = await resolvers['space-sx']( 52 | '0x00a330d13703f0af4f87e65d95c898297f8ce6e88ac7e9bff3b3bd270d2f6d5b' 53 | ); 54 | 55 | expect(result).toBeInstanceOf(Buffer); 56 | expect(result.length).toBeGreaterThan(100); 57 | }); 58 | 59 | it('should resolve on sepolia', async () => { 60 | const result = await resolvers['space-sx']('0xbFF55fd2A671288316956A0Cae8f1d24BA7E5C9B'); 61 | 62 | expect(result).toBeInstanceOf(Buffer); 63 | expect(result.length).toBeGreaterThan(100); 64 | }); 65 | }); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /test/integration/resolvers/starknet.test.ts: -------------------------------------------------------------------------------- 1 | import resolvers from '../../../src/resolvers'; 2 | 3 | describe('resolvers', () => { 4 | describe('starknet', () => { 5 | jest.retryTimes(3); 6 | 7 | it('should return false if missing', async () => { 8 | const result = await resolvers.starknet('test-not-existing.stark'); 9 | 10 | expect(result).toBe(false); 11 | }); 12 | 13 | describe('with a simple image', () => { 14 | it('should resolve with address', async () => { 15 | const result = await resolvers.starknet( 16 | '0x0779ba6e4e227947acbbdfb978a292c401339027eeb3d768f5d12cd2e818265a' 17 | ); 18 | 19 | expect(result).toBeInstanceOf(Buffer); 20 | expect(result.length).toBeGreaterThan(1000); 21 | }); 22 | }); 23 | 24 | describe('with the default image', () => { 25 | it('should return false', async () => { 26 | const result = await resolvers.starknet( 27 | '0x0047f2e8dbf39f6856fc2437dfc931e3b3a64bfe240218046f2a9fca80e768d4' 28 | ); 29 | 30 | expect(result).toBe(false); 31 | }); 32 | }); 33 | 34 | describe('with an NFT image', () => { 35 | it('should resolve with handle', async () => { 36 | const result = await resolvers.starknet('fricoben.stark'); 37 | 38 | expect(result).toBeInstanceOf(Buffer); 39 | expect(result.length).toBeGreaterThan(1000); 40 | }); 41 | 42 | it('should resolve with address', async () => { 43 | const result = await resolvers.starknet( 44 | '0x072d4f3fa4661228ed0c9872007fc7e12a581e000fad7b8f3e3e5bf9e6133207' 45 | ); 46 | 47 | expect(result).toBeInstanceOf(Buffer); 48 | expect(result.length).toBeGreaterThan(1000); 49 | }); 50 | }); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /test/integration/resolvers/trustwallet.test.ts: -------------------------------------------------------------------------------- 1 | import resolvers from '../../../src/resolvers'; 2 | 3 | describe('resolvers', () => { 4 | describe('trustwallet', () => { 5 | it('should return false if missing', async () => { 6 | const result = await resolvers.trustwallet('0x556B14CbdA79A36dC33FcD461a04A5BCb5dC2A70', ''); 7 | 8 | expect(result).toBe(false); 9 | }); 10 | 11 | it('should resolve', async () => { 12 | const result = await resolvers.trustwallet('0xcf0C122c6b73ff809C693DB761e7BaeBe62b6a2E', ''); 13 | 14 | expect(result).toBeInstanceOf(Buffer); 15 | expect(result.length).toBeGreaterThan(1000); 16 | }); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /test/integration/resolvers/zapper.test.ts: -------------------------------------------------------------------------------- 1 | import resolvers from '../../../src/resolvers'; 2 | 3 | describe('resolvers', () => { 4 | describe('zapper', () => { 5 | it('should return false if missing', async () => { 6 | const result = await resolvers.zapper('0x556B14CbdA79A36dC33FcD461a04A5BCb5dC2A70', ''); 7 | 8 | expect(result).toBe(false); 9 | }); 10 | 11 | it('should resolve', async () => { 12 | const result = await resolvers.zapper('0xc18360217d8f7ab5e7c516566761ea12ce7f9d72', ''); 13 | 14 | expect(result).toBeInstanceOf(Buffer); 15 | expect(result.length).toBeGreaterThan(1000); 16 | }); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /test/setup-jest.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-empty-function 2 | jest.spyOn(console, 'log').mockImplementation(() => {}); 3 | 4 | import client from '../src/helpers/redis'; 5 | 6 | afterAll(async () => { 7 | if (client) { 8 | await client.flushDb(); 9 | await client.quit(); 10 | } 11 | }); 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "commonjs", 5 | "rootDir": "./", 6 | "outDir": "./dist", 7 | "esModuleInterop": true, 8 | "strict": true, 9 | "noImplicitAny": false, 10 | "resolveJsonModule": true, 11 | "moduleResolution": "Node", 12 | "skipLibCheck": true, 13 | "sourceMap": true, 14 | "inlineSources": true, 15 | "sourceRoot": "/" 16 | } 17 | } 18 | --------------------------------------------------------------------------------