├── .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 [](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 |
--------------------------------------------------------------------------------