├── .eslintignore
├── .eslintrc.js
├── .gitignore
├── LICENSE
├── README.md
├── examples
├── node
│ ├── README.md
│ ├── index.js
│ ├── package-lock.json
│ └── package.json
└── react-app
│ ├── .gitignore
│ ├── README.md
│ ├── package-lock.json
│ ├── package.json
│ ├── public
│ ├── favicon.ico
│ ├── index.html
│ ├── logo192.png
│ ├── logo512.png
│ ├── manifest.json
│ └── robots.txt
│ └── src
│ ├── App.css
│ ├── App.js
│ ├── App.test.js
│ ├── index.css
│ ├── index.js
│ ├── logo.svg
│ ├── reportWebVitals.js
│ └── setupTests.js
├── package-lock.json
├── package.json
├── rollup.config.ts
├── src
├── eth
│ ├── helpers.ts
│ ├── opensea.ts
│ ├── provider.ts
│ └── types.ts
├── index.ts
├── sol
│ ├── helius.ts
│ ├── helpers.ts
│ ├── provider.ts
│ └── types.ts
└── utils
│ ├── allSettled.ts
│ ├── placeholderImage.ts
│ ├── typeUtils.ts
│ └── types.ts
├── tsconfig.json
└── tsconfig.test.json
/.eslintignore:
--------------------------------------------------------------------------------
1 | example/node_modules
2 | dist/
3 | build/
4 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: {
3 | browser: true,
4 | es6: true
5 | },
6 | extends: [
7 | 'standard',
8 | 'plugin:@typescript-eslint/recommended'
9 | ],
10 | globals: {
11 | Atomics: 'readonly',
12 | SharedArrayBuffer: 'readonly'
13 | },
14 | parser: '@typescript-eslint/parser',
15 | parserOptions: {
16 | ecmaFeatures: {
17 | jsx: true
18 | },
19 | ecmaVersion: 2018,
20 | sourceType: 'module'
21 | },
22 | plugins: ['@typescript-eslint', 'import'],
23 | settings: {
24 | 'import/resolver': {
25 | // NOTE: sk - These aliases are required for the import/order rule.
26 | // We are using the typescript baseUrl to do absolute import paths
27 | // relative to /src, which eslint can't tell apart from 3rd party deps
28 | alias: {
29 | map: [
30 | ['eth', './src/eth'],
31 | ['sol', './src/sol'],
32 | ['utils', './src/utils']
33 | ],
34 | extensions: ['.js', '.json', '.ts']
35 | }
36 | }
37 | },
38 | rules: {
39 | '@typescript-eslint/explicit-function-return-type': 'off',
40 | '@typescript-eslint/no-non-null-assertion': 'off',
41 | '@typescript-eslint/member-delimiter-style': 'off',
42 | '@typescript-eslint/no-empty-interface': 'off',
43 | '@typescript-eslint/camelcase': 'off',
44 | '@typescript-eslint/ban-ts-ignore': 'off',
45 | '@typescript-eslint/no-explicit-any': 'off',
46 | '@typescript-eslint/no-use-before-define': 'off',
47 | '@typescript-eslint/no-empty-function': 'off',
48 | '@typescript-eslint/no-var-requires': 'off',
49 | '@typescript-eslint/no-unused-vars': 'error',
50 | '@typescript-eslint/no-this-alias': 'off',
51 |
52 | 'no-use-before-define': 'off',
53 | camelcase: 'off',
54 | 'no-unused-vars': 'off',
55 | 'func-call-spacing': 'off',
56 | semi: ['error', 'never'],
57 | 'no-undef': 'off',
58 | 'no-empty': 'off',
59 | 'arrow-parens': 'off',
60 | 'padded-blocks': 'off',
61 |
62 | 'space-before-function-paren': 'off',
63 | 'generator-star-spacing': 'off',
64 |
65 | 'import/order': [
66 | 'error',
67 | {
68 | alphabetize: {
69 | order: 'asc'
70 | },
71 | groups: [
72 | 'builtin',
73 | 'external',
74 | 'internal',
75 | 'parent',
76 | 'sibling',
77 | 'index'
78 | ],
79 | 'newlines-between': 'always',
80 | pathGroups: [
81 | {
82 | pattern: 'react',
83 | group: 'builtin',
84 | position: 'before'
85 | }
86 | ],
87 | pathGroupsExcludedImportTypes: ['builtin']
88 | }
89 | ],
90 | 'import/no-default-export': 'error'
91 | },
92 | overrides: [
93 | {
94 | files: ['src/**/*.d.ts', 'rollup.config.ts'],
95 | rules: {
96 | 'import/no-default-export': 'off'
97 | }
98 | }
99 | ]
100 | }
101 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 |
2 | # dependencies
3 | node_modules
4 |
5 | # builds
6 | build
7 | dist
8 |
9 | # misc
10 | .DS_Store
11 | .env
12 | .env.local
13 | .env.development.local
14 | .env.test.local
15 | .env.production.local
16 | example
17 | notes.md
18 |
19 | npm-debug.log*
20 | yarn-debug.log*
21 | yarn-error.log*
22 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2021 Audius
2 |
3 | Licensed under the Apache License, Version 2.0 (the "License");
4 | you may not use this file except in compliance with the License.
5 | You may obtain a copy of the License at
6 |
7 | http://www.apache.org/licenses/LICENSE-2.0
8 |
9 | Unless required by applicable law or agreed to in writing, software
10 | distributed under the License is distributed on an "AS IS" BASIS,
11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | See the License for the specific language governing permissions and
13 | limitations under the License.
14 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | @audius/fetch-nft
4 |
5 |
6 | 🖼🎑🌠
7 |
8 |
9 | A utility to fetch and easily display Ethereum & Solana NFTs in a common format given any wallet.
10 |
11 |
12 | built with ❤️ from the team @Audius.
13 |
14 |
15 |
16 |
17 |
18 |
19 | # Installation
20 |
21 | ```bash
22 | # install peer dependencies if not already in your project
23 | npm install @solana/spl-token @solana/web3.js
24 |
25 | npm install @audius/fetch-nft
26 | ```
27 |
28 | # Basic Usage
29 |
30 | ```ts
31 | import { FetchNFTClient } from "@audius/fetch-nft";
32 |
33 | // Initialize fetch client
34 | const fetchClient = new FetchNFTClient();
35 |
36 | // Fetching all collectibles for the given wallets
37 | fetchClient
38 | .getCollectibles({
39 | ethWallets: ["0x5A8443f456f490dceeAD0922B0Cc89AFd598cec9"],
40 | solWallets: ["GrWNH9qfwrvoCEoTm65hmnSh4z3CD96SfhtfQY6ZKUfY"],
41 | })
42 | .then((res) => console.log(res));
43 | ```
44 |
45 | By default, fetch-nft uses the public Opensea API and the Solana mainnet RPC endpoint. To configure API keys and endpoints, see [Usage With Configs](#usage-with-configs).
46 |
47 | # Fetch Client
48 |
49 | FetchNFTClient is the primary interface for using the library. When initializing the client, you may optionally pass in configs for the OpenSea and Helius clients used internally.
50 |
51 | ```ts
52 | type OpenSeaConfig = {
53 | apiEndpoint?: string;
54 | apiKey?: string;
55 | assetLimit?: number;
56 | eventLimit?: number;
57 | };
58 |
59 | type HeliusConfig = {
60 | apiEndpoint?: string;
61 | apiKey?: string;
62 | limit?: number;
63 | };
64 |
65 | type FetchNFTClientProps = {
66 | openSeaConfig?: OpenSeaConfig;
67 | heliusConfig?: HeliusConfig;
68 | solanaConfig?: {
69 | rpcEndpoint?: string;
70 | metadataProgramId?: string;
71 | };
72 | };
73 | ```
74 |
75 | # Main Functions
76 |
77 | Getting Ethereum collectibles:
78 |
79 | ```ts
80 | FetchNFTClient::getEthereumCollectibles(wallets: string[]) => Promise
81 | ```
82 |
83 | Getting Solana collectibles:
84 |
85 | ```ts
86 | FetchNFTClient::getSolanaCollectibles(wallets: string[]) => Promise
87 | ```
88 |
89 | Getting all collectibles:
90 |
91 | ```ts
92 | FetchNFTClient::getCollectibles({
93 | ethWallets?: string[],
94 | solWallets?: string[]
95 | }) => Promise<{
96 | ethCollectibles: CollectibleState
97 | solCollectibles: CollectibleState
98 | }>
99 | ```
100 |
101 | # Output Types
102 |
103 | ### Collectible
104 |
105 | ```ts
106 | type Collectible = {
107 | id: string;
108 | tokenId: string;
109 | name: Nullable;
110 | description: Nullable;
111 | mediaType: CollectibleMediaType;
112 | frameUrl: Nullable;
113 | imageUrl: Nullable;
114 | gifUrl: Nullable;
115 | videoUrl: Nullable;
116 | threeDUrl: Nullable;
117 | animationUrl: Nullable;
118 | hasAudio: boolean;
119 | isOwned: boolean;
120 | dateCreated: Nullable;
121 | dateLastTransferred: Nullable;
122 | externalLink: Nullable;
123 | permaLink: Nullable;
124 | chain: Chain;
125 | wallet: string;
126 | duration?: number;
127 |
128 | // ethereum nfts
129 | assetContractAddress: Nullable;
130 | standard: Nullable;
131 | collectionSlug: Nullable;
132 | collectionName: Nullable;
133 | collectionImageUrl: Nullable;
134 |
135 | // solana nfts
136 | solanaChainMetadata?: Nullable;
137 | heliusCollection?: Nullable;
138 | };
139 | ```
140 |
141 | ### CollectibleState
142 |
143 | ```ts
144 | type CollectibleState = {
145 | [wallet: string]: Collectible[];
146 | };
147 | ```
148 |
149 | # Usage with Configs
150 |
151 | ```ts
152 | import { FetchNFTClient } from '@audius/fetch-nft'
153 |
154 | // OpenSea Config
155 | const openSeaConfig = {
156 | apiEndpoint: '...',
157 | apiKey: '...',
158 | assetLimit: 10,
159 | eventLimit: 10
160 | }
161 |
162 | // Helius Config
163 | const heliusConfig = {
164 | apiEndpoint: '...';
165 | apiKey: '...',
166 | limit: 10
167 | }
168 |
169 | const solanaConfig = {
170 | rpcEndpoint: '...',
171 | metadataProgramId: '...'
172 | };
173 |
174 | // Initialize fetch client with configs
175 | const fetchClient = new FetchNFTClient({ openSeaConfig, heliusConfig, solanaConfig })
176 |
177 | // Fetching Ethereum collectibles for the given wallets
178 | fetchClient.getEthereumCollectibles([...]).then(res => console.log(res))
179 |
180 | // Fetching Solana collectibles for the given wallets
181 | fetchClient.getSolanaCollectibles([...]).then(res => console.log(res))
182 | ```
183 |
184 | For more examples, see the [/examples](/examples) directory
185 |
--------------------------------------------------------------------------------
/examples/node/README.md:
--------------------------------------------------------------------------------
1 | # Node
2 |
3 | Fetches NFTs corresponding to two wallets, one Ethereum & one Solana.
4 |
5 | ```bash
6 | npm start
7 | ```
--------------------------------------------------------------------------------
/examples/node/index.js:
--------------------------------------------------------------------------------
1 | import { FetchNFTClient } from '@audius/fetch-nft'
2 |
3 | // Initialize fetch client
4 | const fetchClient = new FetchNFTClient()
5 |
6 | // Fetching all collectibles for the given wallets
7 | fetchClient.getCollectibles({
8 | ethWallets: ['0x5A8443f456f490dceeAD0922B0Cc89AFd598cec9'],
9 | solWallets: ['GrWNH9qfwrvoCEoTm65hmnSh4z3CD96SfhtfQY6ZKUfY']
10 | }).then(res => console.log(JSON.stringify(res, null, 2)))
11 |
--------------------------------------------------------------------------------
/examples/node/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "node",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "type": "module",
7 | "scripts": {
8 | "start": "node index.js"
9 | },
10 | "dependencies": {
11 | "@audius/fetch-nft": "file:../../",
12 | "@solana/spl-token": "^0.1.8",
13 | "@solana/web3.js": "^1.31.0"
14 | },
15 | "author": "",
16 | "license": "ISC"
17 | }
18 |
--------------------------------------------------------------------------------
/examples/react-app/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
--------------------------------------------------------------------------------
/examples/react-app/README.md:
--------------------------------------------------------------------------------
1 | # React App
2 |
3 | Fetches NFTs corresponding to two wallets, one Ethereum & one Solana.
4 |
5 | ```bash
6 | npm start
7 | ```
--------------------------------------------------------------------------------
/examples/react-app/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-app",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@audius/fetch-nft": "file:../../",
7 | "@solana/spl-token": "^0.1.8",
8 | "@solana/web3.js": "^1.31.0",
9 | "@testing-library/jest-dom": "^5.15.1",
10 | "@testing-library/react": "^11.2.7",
11 | "@testing-library/user-event": "^12.8.3",
12 | "react": "^17.0.2",
13 | "react-dom": "^17.0.2",
14 | "react-scripts": "4.0.3",
15 | "web-vitals": "^1.1.2"
16 | },
17 | "scripts": {
18 | "start": "react-scripts start",
19 | "build": "react-scripts build",
20 | "test": "react-scripts test",
21 | "eject": "react-scripts eject"
22 | },
23 | "eslintConfig": {
24 | "extends": [
25 | "react-app",
26 | "react-app/jest"
27 | ]
28 | },
29 | "browserslist": {
30 | "production": [
31 | ">0.2%",
32 | "not dead",
33 | "not op_mini all"
34 | ],
35 | "development": [
36 | "last 1 chrome version",
37 | "last 1 firefox version",
38 | "last 1 safari version"
39 | ]
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/examples/react-app/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AudiusProject/fetch-nft/c6bce5d32e2b6f13d4540263c381026274c70501/examples/react-app/public/favicon.ico
--------------------------------------------------------------------------------
/examples/react-app/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
17 |
18 |
27 | React App
28 |
29 |
30 |
31 |
32 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/examples/react-app/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AudiusProject/fetch-nft/c6bce5d32e2b6f13d4540263c381026274c70501/examples/react-app/public/logo192.png
--------------------------------------------------------------------------------
/examples/react-app/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AudiusProject/fetch-nft/c6bce5d32e2b6f13d4540263c381026274c70501/examples/react-app/public/logo512.png
--------------------------------------------------------------------------------
/examples/react-app/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "logo192.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "logo512.png",
17 | "type": "image/png",
18 | "sizes": "512x512"
19 | }
20 | ],
21 | "start_url": ".",
22 | "display": "standalone",
23 | "theme_color": "#000000",
24 | "background_color": "#ffffff"
25 | }
26 |
--------------------------------------------------------------------------------
/examples/react-app/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/examples/react-app/src/App.css:
--------------------------------------------------------------------------------
1 | .App {
2 | text-align: center;
3 | }
4 |
5 | .Header {
6 | margin-top: 20px;
7 | font-size: 20px;
8 | font-weight: 900;
9 | }
10 |
11 | .Name {
12 | font-size: 14px;
13 | }
14 |
15 | .Image {
16 | width: 100px;
17 | height: 100px;
18 | }
19 |
--------------------------------------------------------------------------------
/examples/react-app/src/App.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import './App.css';
3 |
4 | import { FetchNFTClient } from '@audius/fetch-nft'
5 |
6 | // Initialize fetch client
7 | const fetchClient = new FetchNFTClient()
8 |
9 | const App = () => {
10 | const [collectibles, setCollectibles] = useState(null)
11 | useEffect(() => {
12 | // Fetching all collectibles for the given wallets
13 | fetchClient.getCollectibles({
14 | ethWallets: ['0x5A8443f456f490dceeAD0922B0Cc89AFd598cec9'],
15 | solWallets: ['GrWNH9qfwrvoCEoTm65hmnSh4z3CD96SfhtfQY6ZKUfY']
16 | }).then(res => setCollectibles(res))
17 | }, [])
18 |
19 | return (
20 |
21 |
Eth Collectibles
22 | {
23 | collectibles?.ethCollectibles['0x5A8443f456f490dceeAD0922B0Cc89AFd598cec9']
24 | .map(collectible => (
25 |
26 |
{collectible.name}
27 |

28 |
29 | ))
30 | }
31 |
Solana Collectibles
32 | {
33 | collectibles?.solCollectibles['GrWNH9qfwrvoCEoTm65hmnSh4z3CD96SfhtfQY6ZKUfY']
34 | .map(collectible => (
35 |
36 |
{collectible.name}
37 |

38 |
39 | ))
40 | }
41 |
42 | );
43 | }
44 |
45 | export default App;
46 |
--------------------------------------------------------------------------------
/examples/react-app/src/App.test.js:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import App from './App';
3 |
4 | test('renders learn react link', () => {
5 | render();
6 | const linkElement = screen.getByText(/learn react/i);
7 | expect(linkElement).toBeInTheDocument();
8 | });
9 |
--------------------------------------------------------------------------------
/examples/react-app/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
5 | sans-serif;
6 | -webkit-font-smoothing: antialiased;
7 | -moz-osx-font-smoothing: grayscale;
8 | }
9 |
10 | code {
11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
12 | monospace;
13 | }
14 |
--------------------------------------------------------------------------------
/examples/react-app/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import './index.css';
4 | import App from './App';
5 | import reportWebVitals from './reportWebVitals';
6 |
7 | ReactDOM.render(
8 |
9 |
10 | ,
11 | document.getElementById('root')
12 | );
13 |
14 | // If you want to start measuring performance in your app, pass a function
15 | // to log results (for example: reportWebVitals(console.log))
16 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
17 | reportWebVitals();
18 |
--------------------------------------------------------------------------------
/examples/react-app/src/logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/examples/react-app/src/reportWebVitals.js:
--------------------------------------------------------------------------------
1 | const reportWebVitals = onPerfEntry => {
2 | if (onPerfEntry && onPerfEntry instanceof Function) {
3 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
4 | getCLS(onPerfEntry);
5 | getFID(onPerfEntry);
6 | getFCP(onPerfEntry);
7 | getLCP(onPerfEntry);
8 | getTTFB(onPerfEntry);
9 | });
10 | }
11 | };
12 |
13 | export default reportWebVitals;
14 |
--------------------------------------------------------------------------------
/examples/react-app/src/setupTests.js:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import '@testing-library/jest-dom';
6 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@audius/fetch-nft",
3 | "version": "0.2.8",
4 | "author": "Audius",
5 | "description": "A utility to fetch Ethereum & Solana NFTs",
6 | "files": [
7 | "dist"
8 | ],
9 | "main": "dist/index.js",
10 | "module": "dist/index.es.js",
11 | "types": "dist/index.d.ts",
12 | "publishConfig": {
13 | "access": "public"
14 | },
15 | "scripts": {
16 | "test": "echo \"Error: no test specified\" && exit 1",
17 | "build": "rollup -c --configPlugin typescript",
18 | "start": "rollup -c -w --configPlugin typescript",
19 | "prepare": "npm run build"
20 | },
21 | "keywords": [],
22 | "license": "ISC",
23 | "devDependencies": {
24 | "@rollup/plugin-commonjs": "25.0.8",
25 | "@rollup/plugin-json": "6.1.0",
26 | "@rollup/plugin-node-resolve": "15.2.3",
27 | "@rollup/plugin-typescript": "11.1.6",
28 | "@typescript-eslint/eslint-plugin": "5.1.0",
29 | "@typescript-eslint/parser": "5.1.0",
30 | "eslint": "7.32.0",
31 | "eslint-config-standard": "16.0.3",
32 | "eslint-import-resolver-alias": "1.1.2",
33 | "eslint-plugin-import": "2.25.2",
34 | "eslint-plugin-node": "11.1.0",
35 | "eslint-plugin-promise": "5.1.0",
36 | "rollup": "4.3.0",
37 | "rollup-plugin-dts": "6.1.1",
38 | "rollup-plugin-peer-deps-external": "2.2.4",
39 | "tslib": "2.3.1",
40 | "typescript": "5.0.4"
41 | },
42 | "peerDependencies": {
43 | "@solana/spl-token": "^0.3.8",
44 | "@solana/web3.js": "^1.95.8"
45 | },
46 | "dependencies": {
47 | "@metaplex-foundation/mpl-token-metadata": "3.2.1",
48 | "@metaplex-foundation/umi": "0.9.1",
49 | "cross-fetch": "3.1.4",
50 | "dayjs": "1.11.10"
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/rollup.config.ts:
--------------------------------------------------------------------------------
1 | import commonjs from '@rollup/plugin-commonjs'
2 | import json from '@rollup/plugin-json'
3 | import resolve from '@rollup/plugin-node-resolve'
4 | import typescript from '@rollup/plugin-typescript'
5 | import dts from 'rollup-plugin-dts'
6 | import external from 'rollup-plugin-peer-deps-external'
7 |
8 | import pkg from './package.json' assert { type: 'json' }
9 | import tsconfig from './tsconfig.json' assert { type: 'json' }
10 |
11 | const extensions = ['.js', '.ts']
12 |
13 | const config = [
14 | {
15 | input: 'src/index.ts',
16 | output: [
17 | {
18 | file: pkg.main,
19 | format: 'cjs',
20 | exports: 'named',
21 | sourcemap: true,
22 | inlineDynamicImports: true
23 | },
24 | {
25 | file: pkg.module,
26 | format: 'es',
27 | exports: 'named',
28 | sourcemap: true,
29 | inlineDynamicImports: true
30 | }
31 | ],
32 | plugins: [
33 | commonjs({ extensions }),
34 | external({
35 | includeDependencies: true
36 | }),
37 | json(),
38 | resolve({ extensions, preferBuiltins: true }),
39 | typescript({ tsconfig: './tsconfig.json' })
40 | ]
41 | },
42 | {
43 | input: './dist/index.d.ts',
44 | output: [{ file: 'dist/index.d.ts', format: 'es' }],
45 | plugins: [
46 | dts({
47 | compilerOptions: {
48 | baseUrl: tsconfig.compilerOptions.baseUrl
49 | }
50 | })
51 | ]
52 | }
53 | ]
54 |
55 | export default config
56 |
--------------------------------------------------------------------------------
/src/eth/helpers.ts:
--------------------------------------------------------------------------------
1 | import dayjs from 'dayjs'
2 |
3 | import { Nullable } from 'utils/typeUtils'
4 | import { Collectible, CollectibleMediaType } from 'utils/types'
5 |
6 | import { EthTokenStandard, OpenSeaEvent, OpenSeaEventExtended, OpenSeaNftExtended } from './types'
7 | import { placeholderImage } from 'utils/placeholderImage'
8 |
9 | export const fetchWithTimeout = async (
10 | resource: RequestInfo,
11 | options: { timeout?: number } & RequestInit = {}
12 | ) => {
13 | const { timeout = 4000 } = options
14 |
15 | const controller = new AbortController()
16 | const id = setTimeout(() => controller.abort(), timeout)
17 | const response = await fetch(resource, {
18 | ...options,
19 | signal: controller.signal
20 | })
21 | clearTimeout(id)
22 | return response
23 | }
24 |
25 | const isWebpAnimated = (arrayBuffer: ArrayBuffer) => {
26 | const decoder = new TextDecoder('utf-8')
27 | const text = decoder.decode(arrayBuffer)
28 | return text.indexOf('ANMF') !== -1
29 | }
30 |
31 | /**
32 | * extensions based on OpenSea metadata standards
33 | * https://docs.opensea.io/docs/metadata-standards
34 | */
35 | const OPENSEA_AUDIO_EXTENSIONS = ['mp3', 'wav', 'oga']
36 | const OPENSEA_VIDEO_EXTENSIONS = [
37 | 'gltf',
38 | 'glb',
39 | 'webm',
40 | 'mp4',
41 | 'm4v',
42 | 'ogv',
43 | 'ogg',
44 | 'mov'
45 | ]
46 |
47 | const SUPPORTED_VIDEO_EXTENSIONS = ['webm', 'mp4', 'ogv', 'ogg', 'mov']
48 | const SUPPORTED_3D_EXTENSIONS = ['gltf', 'glb']
49 |
50 | const NON_IMAGE_EXTENSIONS = [
51 | ...OPENSEA_VIDEO_EXTENSIONS,
52 | ...OPENSEA_AUDIO_EXTENSIONS
53 | ]
54 |
55 | const NULL_ADDRESS = '0x0000000000000000000000000000000000000000'
56 |
57 | const isAssetImage = (asset: OpenSeaNftExtended) => {
58 | return [
59 | asset.image,
60 | asset.image_url,
61 | asset.image_original_url,
62 | asset.image_preview_url,
63 | asset.image_thumbnail_url
64 | ].some(
65 | (url) => url && NON_IMAGE_EXTENSIONS.every((ext) => !url.endsWith(ext))
66 | )
67 | }
68 |
69 | const areUrlExtensionsSupportedForType = (
70 | asset: OpenSeaNftExtended,
71 | extensions: string[]
72 | ) => {
73 | const {
74 | animation_url,
75 | animation_original_url,
76 | image_url,
77 | image,
78 | image_original_url,
79 | image_preview_url,
80 | image_thumbnail_url
81 | } = asset
82 | return [
83 | animation_url || '',
84 | animation_original_url || '',
85 | image_url,
86 | image,
87 | image_original_url,
88 | image_preview_url,
89 | image_thumbnail_url
90 | ].some((url) => url && extensions.some((ext) => url.endsWith(ext)))
91 | }
92 |
93 | const isAssetVideo = (asset: OpenSeaNftExtended) => {
94 | return areUrlExtensionsSupportedForType(asset, SUPPORTED_VIDEO_EXTENSIONS)
95 | }
96 |
97 | const isAssetThreeDAndIncludesImage = (asset: OpenSeaNftExtended) => {
98 | return (
99 | areUrlExtensionsSupportedForType(asset, SUPPORTED_3D_EXTENSIONS) &&
100 | isAssetImage(asset)
101 | )
102 | }
103 |
104 | const isAssetGif = (asset: OpenSeaNftExtended) => {
105 | return !!(
106 | asset.image?.endsWith('.gif') ||
107 | asset.image_url?.endsWith('.gif') ||
108 | asset.image_original_url?.endsWith('.gif') ||
109 | asset.image_preview_url?.endsWith('.gif') ||
110 | asset.image_thumbnail_url?.endsWith('.gif')
111 | )
112 | }
113 |
114 | export const isAssetValid = (asset: OpenSeaNftExtended) => {
115 | return (
116 | isAssetGif(asset) ||
117 | isAssetThreeDAndIncludesImage(asset) ||
118 | isAssetVideo(asset) ||
119 | isAssetImage(asset)
120 | )
121 | }
122 |
123 | const ipfsProtocolPrefix = 'ipfs://'
124 | const getIpfsProtocolUrl = (asset: OpenSeaNftExtended) => {
125 | return [
126 | asset.image,
127 | asset.image_url,
128 | asset.image_original_url,
129 | asset.image_preview_url,
130 | asset.image_thumbnail_url,
131 | asset.animation_url,
132 | asset.animation_original_url
133 | ].find((url) => url?.startsWith(ipfsProtocolPrefix))
134 | }
135 | const getIpfsMetadataUrl = (ipfsProtocolUrl: string) => {
136 | const url = ipfsProtocolUrl
137 | .substring(ipfsProtocolPrefix.length)
138 | .replace('ipfs/', '')
139 | return `https://ipfs.io/ipfs/${url}`
140 | }
141 | const arweavePrefix = 'ar://'
142 | const getArweaveProtocolUrl = (asset: OpenSeaNftExtended) => {
143 | return [
144 | asset.image,
145 | asset.image_url,
146 | asset.image_original_url,
147 | asset.image_preview_url,
148 | asset.image_thumbnail_url,
149 | asset.animation_url,
150 | asset.animation_original_url
151 | ].find((url) => url?.startsWith(arweavePrefix))
152 | }
153 | const getArweaveMetadataUrl = (arweaveProtocolUrl: string) => {
154 | return `https://arweave.net/${arweaveProtocolUrl.substring(
155 | arweavePrefix.length
156 | )}`
157 | }
158 |
159 | export const getAssetIdentifier = (asset: OpenSeaNftExtended) => {
160 | return `${asset.identifier}:::${asset.contract ?? ''}`
161 | }
162 |
163 | /**
164 | * Returns a collectible given an asset object from the OpenSea API
165 | *
166 | * A lot of the work here is to determine whether a collectible is a gif, a video, or an image
167 | *
168 | * If the collectible is a gif, we set the gifUrl, and we process a frame from the gifUrl which we set as its frameUrl
169 | *
170 | * If the collectible is a video, we set the videoUrl, and we check whether the asset has an image
171 | * - if it has an image, we check whether the image url is an actual image or a video (sometimes OpenSea returns
172 | * videos in the image url properties of the asset)
173 | * - if it's an image, we set it as the frameUrl
174 | * - otherwise, we unset the frameUrl
175 | * - if not, we do not set the frameUrl
176 | * Video collectibles that do not have a frameUrl will use the video paused at the first frame as the thumbnail
177 | * in the collectibles tab
178 | *
179 | * Otherwise, we consider the collectible to be an image, we get the image url and make sure that it is not
180 | * a gif or a video
181 | * - if it's a gif, we follow the above gif logic
182 | * - if it's a video, we unset the frameUrl and follow the above video logic
183 | * - otherwise, we set the frameUrl and the imageUrl
184 | *
185 | * @param asset
186 | */
187 | export const assetToCollectible = async (
188 | asset: OpenSeaNftExtended
189 | ): Promise => {
190 | let mediaType: CollectibleMediaType
191 | let frameUrl: Nullable = null
192 | let imageUrl: Nullable = null
193 | let videoUrl: Nullable = null
194 | let threeDUrl: Nullable = null
195 | let gifUrl: Nullable = null
196 |
197 | let hasAudio = false
198 | let animationUrlOverride: Nullable = null
199 |
200 | const {
201 | animation_url,
202 | animation_original_url,
203 | image,
204 | image_url,
205 | image_original_url,
206 | image_preview_url,
207 | image_thumbnail_url
208 | } = asset
209 | const imageUrls = [
210 | image,
211 | image_url,
212 | image_original_url,
213 | image_preview_url,
214 | image_thumbnail_url
215 | ]
216 |
217 | const ipfsProtocolUrl = getIpfsProtocolUrl(asset)
218 | const arweaveProtocolUrl = getArweaveProtocolUrl(asset)
219 |
220 | try {
221 | if (isAssetGif(asset)) {
222 | mediaType = CollectibleMediaType.GIF
223 | // frame url for the gif is computed later in the collectibles page
224 | frameUrl = null
225 | gifUrl = imageUrls.find((url) => url?.endsWith('.gif'))!
226 | if (ipfsProtocolUrl) {
227 | gifUrl = getIpfsMetadataUrl(gifUrl)
228 | }
229 | } else if (isAssetThreeDAndIncludesImage(asset)) {
230 | mediaType = CollectibleMediaType.THREE_D
231 | threeDUrl = [animation_url, animation_original_url, ...imageUrls].find(
232 | (url) => url && SUPPORTED_3D_EXTENSIONS.some((ext) => url.endsWith(ext))
233 | )!
234 | frameUrl = imageUrls.find(
235 | (url) => url && NON_IMAGE_EXTENSIONS.every((ext) => !url.endsWith(ext))
236 | )!
237 | // image urls may not end in known extensions
238 | // just because the don't end with the NON_IMAGE_EXTENSIONS above does not mean they are images
239 | // they may be gifs
240 | // example: https://lh3.googleusercontent.com/rOopRU-wH9mqMurfvJ2INLIGBKTtF8BN_XC7KZxTh8PPHt5STSNJ-i8EQit8ZTwE3Mi8LK4on_4YazdC3Cl-HdaxbnKJ23P8kocvJHQ
241 | const res = await fetchWithTimeout(frameUrl, { method: 'HEAD' })
242 | const hasGifFrame = res.headers.get('Content-Type')?.includes('gif')
243 | if (hasGifFrame) {
244 | gifUrl = frameUrl
245 | // frame url for the gif is computed later in the collectibles page
246 | frameUrl = null
247 | }
248 | } else if (isAssetVideo(asset)) {
249 | mediaType = CollectibleMediaType.VIDEO
250 | frameUrl =
251 | imageUrls.find(
252 | (url) =>
253 | url && NON_IMAGE_EXTENSIONS.every((ext) => !url.endsWith(ext))
254 | ) ?? null
255 |
256 | /**
257 | * make sure frame url is not a video or a gif
258 | * if it is, unset frame url so that component will use a video url frame instead
259 | */
260 | if (frameUrl) {
261 | const res = await fetchWithTimeout(frameUrl, { method: 'HEAD' })
262 | const isVideo = res.headers.get('Content-Type')?.includes('video')
263 | const isGif = res.headers.get('Content-Type')?.includes('gif')
264 | if (isVideo || isGif) {
265 | frameUrl = null
266 | }
267 | }
268 |
269 | videoUrl = [animation_url, animation_original_url, ...imageUrls].find(
270 | (url) =>
271 | url && SUPPORTED_VIDEO_EXTENSIONS.some((ext) => url.endsWith(ext))
272 | )!
273 | } else if (ipfsProtocolUrl) {
274 | try {
275 | const metadataUrl = getIpfsMetadataUrl(ipfsProtocolUrl)
276 | const res = await fetchWithTimeout(metadataUrl, { method: 'HEAD' })
277 | const isGif = res.headers.get('Content-Type')?.includes('gif')
278 | const isVideo = res.headers.get('Content-Type')?.includes('video')
279 | const isAudio = res.headers.get('Content-Type')?.includes('audio')
280 | const isWebp = res.headers.get('Content-Type')?.includes('webp')
281 | let isAnimatedWebp = false
282 | if (isWebp) {
283 | const ab = await res.arrayBuffer()
284 | isAnimatedWebp = isWebpAnimated(ab)
285 | }
286 | if (res.status >= 300) {
287 | mediaType = CollectibleMediaType.IMAGE
288 | imageUrl = placeholderImage
289 | frameUrl = placeholderImage
290 | } else if (isAnimatedWebp) {
291 | mediaType = CollectibleMediaType.ANIMATED_WEBP
292 | gifUrl = frameUrl
293 | // frame url for the animated webp is computed later in the collectibles page
294 | frameUrl = null
295 | } else if (isGif) {
296 | mediaType = CollectibleMediaType.GIF
297 | frameUrl = null
298 | gifUrl = metadataUrl
299 | } else if (isVideo) {
300 | mediaType = CollectibleMediaType.VIDEO
301 | frameUrl = null
302 | videoUrl = metadataUrl
303 | } else {
304 | mediaType = CollectibleMediaType.IMAGE
305 | imageUrl = imageUrls.find((url) => !!url)!
306 | if (imageUrl.startsWith(ipfsProtocolPrefix)) {
307 | imageUrl = getIpfsMetadataUrl(imageUrl)
308 | }
309 | frameUrl = imageUrl
310 | if (isAudio) {
311 | hasAudio = true
312 | animationUrlOverride = metadataUrl
313 | }
314 | }
315 | } catch (e) {
316 | console.error(
317 | `Could not fetch url metadata at ${ipfsProtocolUrl} for asset contract address ${asset.contract} and asset token id ${asset.token_id}`
318 | )
319 | mediaType = CollectibleMediaType.IMAGE
320 | frameUrl = imageUrls.find((url) => !!url)!
321 | if (frameUrl.startsWith(ipfsProtocolPrefix)) {
322 | frameUrl = getIpfsMetadataUrl(frameUrl)
323 | }
324 | imageUrl = frameUrl
325 | }
326 | } else if (arweaveProtocolUrl) {
327 | try {
328 | const metadataUrl = getArweaveMetadataUrl(arweaveProtocolUrl)
329 | const res = await fetchWithTimeout(metadataUrl, { method: 'HEAD' })
330 | const isGif = res.headers.get('Content-Type')?.includes('gif')
331 | const isVideo = res.headers.get('Content-Type')?.includes('video')
332 | const isAudio = res.headers.get('Content-Type')?.includes('audio')
333 | const isWebp = res.headers.get('Content-Type')?.includes('webp')
334 | let isAnimatedWebp = false
335 | if (isWebp) {
336 | const ab = await res.arrayBuffer()
337 | isAnimatedWebp = isWebpAnimated(ab)
338 | }
339 | if (res.status >= 300) {
340 | mediaType = CollectibleMediaType.IMAGE
341 | imageUrl = placeholderImage
342 | frameUrl = placeholderImage
343 | } else if (isAnimatedWebp) {
344 | mediaType = CollectibleMediaType.ANIMATED_WEBP
345 | gifUrl = frameUrl
346 | // frame url for the animated webp is computed later in the collectibles page
347 | frameUrl = null
348 | } else if (isGif) {
349 | mediaType = CollectibleMediaType.GIF
350 | frameUrl = null
351 | gifUrl = metadataUrl
352 | } else if (isVideo) {
353 | mediaType = CollectibleMediaType.VIDEO
354 | frameUrl = null
355 | videoUrl = metadataUrl
356 | } else {
357 | mediaType = CollectibleMediaType.IMAGE
358 | imageUrl = imageUrls.find((url) => !!url)!
359 | if (imageUrl.startsWith(arweavePrefix)) {
360 | imageUrl = getArweaveMetadataUrl(imageUrl)
361 | }
362 | frameUrl = imageUrl
363 | if (isAudio) {
364 | hasAudio = true
365 | animationUrlOverride = metadataUrl
366 | }
367 | }
368 | } catch (e) {
369 | console.error(
370 | `Could not fetch url metadata at ${arweaveProtocolUrl} for asset contract address ${asset.contract} and asset token id ${asset.token_id}`
371 | )
372 | mediaType = CollectibleMediaType.IMAGE
373 | frameUrl = imageUrls.find((url) => !!url)!
374 | imageUrl = frameUrl
375 | }
376 | } else {
377 | frameUrl = imageUrls.find((url) => !!url)!
378 | const res = await fetchWithTimeout(frameUrl, {
379 | headers: { Range: 'bytes=0-100' }
380 | })
381 | const isGif = res.headers.get('Content-Type')?.includes('gif')
382 | const isVideo = res.headers.get('Content-Type')?.includes('video')
383 | const isWebp = res.headers.get('Content-Type')?.includes('webp')
384 | let isAnimatedWebp = false
385 | if (isWebp) {
386 | const ab = await res.arrayBuffer()
387 | isAnimatedWebp = isWebpAnimated(ab)
388 | }
389 | if (res.status >= 300) {
390 | mediaType = CollectibleMediaType.IMAGE
391 | imageUrl = placeholderImage
392 | frameUrl = placeholderImage
393 | } else if (isAnimatedWebp) {
394 | mediaType = CollectibleMediaType.ANIMATED_WEBP
395 | gifUrl = frameUrl
396 | // frame url for the animated webp is computed later in the collectibles page
397 | frameUrl = null
398 | } else if (isGif) {
399 | mediaType = CollectibleMediaType.GIF
400 | gifUrl = frameUrl
401 | // frame url for the gif is computed later in the collectibles page
402 | frameUrl = null
403 | } else if (isVideo) {
404 | mediaType = CollectibleMediaType.VIDEO
405 | frameUrl = null
406 | videoUrl = imageUrls.find((url) => !!url)!
407 | } else {
408 | mediaType = CollectibleMediaType.IMAGE
409 | imageUrl = imageUrls.find((url) => !!url)!
410 | frameUrl = imageUrls.find((url) => !!url)!
411 | }
412 | }
413 | } catch (e) {
414 | console.error('Error processing collectible', e)
415 | mediaType = CollectibleMediaType.IMAGE
416 | imageUrl = placeholderImage
417 | frameUrl = placeholderImage
418 | }
419 |
420 | const collectionSlug =
421 | typeof asset.collection === 'object'
422 | ? (asset.collection as unknown as any).name ?? null
423 | : asset.collection
424 |
425 | const collectible: Collectible = {
426 | id: getAssetIdentifier(asset),
427 | tokenId: asset.identifier,
428 | name: (asset.name || asset?.asset_contract?.name) ?? '',
429 | description: asset.description,
430 | mediaType,
431 | frameUrl,
432 | imageUrl,
433 | videoUrl,
434 | threeDUrl,
435 | gifUrl,
436 | animationUrl: animationUrlOverride ?? animation_url ?? null,
437 | hasAudio,
438 | isOwned: true,
439 | dateCreated: null,
440 | dateLastTransferred: null,
441 | externalLink: asset.external_url ?? null,
442 | permaLink: asset.opensea_url,
443 | assetContractAddress: asset.contract,
444 | standard: (asset.token_standard?.toUpperCase() as EthTokenStandard) ?? null,
445 | collectionSlug: collectionSlug ?? null,
446 | collectionName: asset.collectionMetadata?.name ?? null,
447 | collectionImageUrl: asset.collectionMetadata?.image_url ?? null,
448 | chain: 'eth',
449 | wallet: asset.wallet
450 | }
451 | return collectible
452 | }
453 |
454 | export const transferEventToCollectible = async (
455 | event: OpenSeaEventExtended,
456 | isOwned = true
457 | ): Promise => {
458 | const { nft, event_timestamp } = event
459 |
460 | const collectible = await assetToCollectible(nft)
461 |
462 | return {
463 | ...collectible,
464 | isOwned,
465 | dateLastTransferred: dayjs(event_timestamp * 1000).toString()
466 | }
467 | }
468 |
469 | export const isNotFromNullAddress = (event: OpenSeaEvent) => {
470 | return event.from_address !== NULL_ADDRESS
471 | }
472 |
--------------------------------------------------------------------------------
/src/eth/opensea.ts:
--------------------------------------------------------------------------------
1 | import { OpenSeaCollection, OpenSeaEvent, OpenSeaNft } from './types'
2 |
3 | const OPENSEA_API_URL = 'https://api.opensea.io'
4 | const OPENSEA_NUM_ASSETS_LIMIT = 200
5 | const OPENSEA_NUM_EVENTS_LIMIT = 50
6 |
7 | export type OpenSeaConfig = {
8 | apiEndpoint?: string
9 | apiKey?: string
10 | assetLimit?: number
11 | eventLimit?: number
12 | }
13 |
14 | export class OpenSeaClient {
15 | private readonly apiUrl: string = OPENSEA_API_URL
16 | private readonly apiKey: string = ''
17 | private readonly assetLimit: number = OPENSEA_NUM_ASSETS_LIMIT
18 | private readonly eventLimit: number = OPENSEA_NUM_EVENTS_LIMIT
19 |
20 | private requestOptions
21 |
22 | constructor(props?: OpenSeaConfig) {
23 | this.apiUrl = props?.apiEndpoint ?? this.apiUrl
24 | this.apiKey = props?.apiKey ?? this.apiKey
25 | this.assetLimit = props?.assetLimit ?? this.assetLimit
26 | this.eventLimit = props?.eventLimit ?? this.eventLimit
27 |
28 | if (this.apiKey) {
29 | this.requestOptions = {
30 | headers: new Headers({ 'X-API-KEY': this.apiKey })
31 | }
32 | }
33 | }
34 |
35 | async getCollectionMetadata(
36 | collection: string
37 | ): Promise {
38 | const url = `${this.apiUrl}/api/v2/collections/${collection}`
39 | try {
40 | const res = await fetch(
41 | url,
42 | this.requestOptions
43 | )
44 | return res.json()
45 | } catch (e) {
46 | console.error(`OpenSeaClient | getCollectionMetadata | error for collection ${collection} at url ${url}: ${e}`)
47 | throw e
48 | }
49 | }
50 |
51 | async getNftTransferEventsForWallet(
52 | wallet: string,
53 | limit = this.eventLimit
54 | ): Promise {
55 | try {
56 | let res: Response
57 | let json: { next: string | undefined; asset_events: OpenSeaEvent[] }
58 | let events: OpenSeaEvent[]
59 | let next: string | undefined
60 | res = await fetch(
61 | `${this.apiUrl}/api/v2/events/accounts/${wallet}?limit=${limit}&event_type=transfer&chain=ethereum`,
62 | this.requestOptions
63 | )
64 | json = await res.json()
65 | next = json.next
66 | events = json.asset_events
67 | while (next) {
68 | res = await fetch(
69 | `${this.apiUrl}/api/v2/events/accounts/${wallet}?limit=${limit}&event_type=transfer&chain=ethereum&next=${next}`,
70 | this.requestOptions
71 | )
72 | json = await res.json()
73 | next = json.next
74 | events = [...events, ...json.asset_events]
75 | }
76 | if (events) {
77 | return events.map((event) => ({ ...event, wallet }))
78 | } else {
79 | console.warn(`OpenSeaClient | getNftTransferEventsForWallet | could not get transfer events for wallet ${wallet}: ${{ events }}`)
80 | return []
81 | }
82 | } catch (e) {
83 | console.error(`OpenSeaClient | getNftTransferEventsForWallet | error for wallet ${wallet}: ${e}`)
84 | throw e
85 | }
86 | }
87 |
88 | async getNftsForWallet(
89 | wallet: string,
90 | limit = this.assetLimit
91 | ): Promise {
92 | try {
93 | let res: Response
94 | let json: { next: string | undefined; nfts: OpenSeaNft[] }
95 | let nfts: OpenSeaNft[]
96 | let next: string | undefined
97 | res = await fetch(
98 | `${this.apiUrl}/api/v2/chain/ethereum/account/${wallet}/nfts?limit=${limit}`,
99 | this.requestOptions
100 | )
101 | json = await res.json()
102 | next = json.next
103 | nfts = json.nfts
104 | while (next) {
105 | res = await fetch(
106 | `${this.apiUrl}/api/v2/chain/ethereum/account/${wallet}/nfts?limit=${limit}&next=${next}`,
107 | this.requestOptions
108 | )
109 | json = await res.json()
110 | next = json.next
111 | nfts = [...nfts, ...json.nfts]
112 | }
113 | if (nfts) {
114 | return nfts.map((nft) => ({ ...nft, wallet }))
115 | } else {
116 | console.warn(`OpenSeaClient | getNftsForWallet | could not get nfts for wallet ${wallet}: ${{ nfts }}`)
117 | return []
118 | }
119 | } catch (e) {
120 | console.error(`OpenSeaClient | getNftsForWallet | error for wallet ${wallet}: ${e}`)
121 | throw e
122 | }
123 | }
124 | }
125 |
--------------------------------------------------------------------------------
/src/eth/provider.ts:
--------------------------------------------------------------------------------
1 | // import dayjs from 'dayjs'
2 |
3 | import { allSettled } from 'utils/allSettled'
4 | import { Collectible, CollectibleState } from 'utils/types'
5 |
6 | import {
7 | assetToCollectible,
8 | getAssetIdentifier,
9 | isAssetValid
10 | // isNotFromNullAddress,
11 | // transferEventToCollectible
12 | } from './helpers'
13 | import { OpenSeaClient } from './opensea'
14 | import {
15 | OpenSeaCollection,
16 | // OpenSeaEvent,
17 | // OpenSeaEventExtended,
18 | OpenSeaNft,
19 | OpenSeaNftExtended,
20 | OpenSeaNftMetadata
21 | } from './types'
22 |
23 | export class EthereumCollectiblesProvider {
24 | private readonly openSeaClient: OpenSeaClient
25 |
26 | constructor(openSeaClient: OpenSeaClient) {
27 | this.openSeaClient = openSeaClient
28 | }
29 |
30 | private async getNftsForMultipleWallets(wallets: string[]): Promise {
31 | return allSettled(
32 | wallets.map((wallet) => this.openSeaClient.getNftsForWallet(wallet))
33 | ).then((results: PromiseSettledResult[]) =>
34 | results
35 | .map((result, i) => ({ result, wallet: wallets[i] }))
36 | .filter(({ result }) => result.status === 'fulfilled')
37 | .map(
38 | ({ result, wallet }) =>
39 | (result as PromiseFulfilledResult).value?.map(
40 | (nft) => ({ ...nft, wallet })
41 | ) || []
42 | )
43 | .flat()
44 | )
45 | }
46 |
47 | private async addNftMetadata(nft: OpenSeaNft): Promise {
48 | let metadata: OpenSeaNftMetadata = {}
49 | try {
50 | const res = await fetch(nft.metadata_url)
51 | metadata = await res.json()
52 | } catch (e) {
53 | console.error(`EthereumCollectiblesProvider | addNftMetadata | error: ${e}`)
54 | }
55 | return { ...nft, ...metadata }
56 | }
57 |
58 | async getCollectionMetadatas(
59 | addresses: string[]
60 | ): Promise<{ [address: string]: OpenSeaCollection }> {
61 | const collections = await Promise.all(
62 | addresses.map((address) => {
63 | try {
64 | return this.openSeaClient.getCollectionMetadata(address)
65 | } catch (e) {
66 | console.error(`EthereumCollectiblesProvider | getCollectionMetadatas | error for address ${address}: ${e}`)
67 | return null
68 | }
69 | })
70 | )
71 | return collections.reduce((acc, curr, i) => {
72 | acc[addresses[i]] = curr
73 | return acc
74 | }, {})
75 | }
76 |
77 | async getCollectionMetadatasForCollectibles(
78 | collectibles: Collectible[]
79 | ): Promise {
80 | // Build a set of collections to fetch metadata for
81 | // and fetch them all at once, making sure to not fetch
82 | // the same collection metadata multiple times.
83 | const collectionSet = new Set()
84 | const idToCollectionMap = collectibles.reduce((acc, curr) => {
85 | // Believe it or not, sometimes, rarely, the type of collection is an object
86 | // that looks like { name: string, family: string }
87 | // and sometimes it's a string. I don't know why.
88 | // Wonder if worth changing the 'collection' type and chasing down all the
89 | // type errors that would cause just for this irregularity. Probably not for now.
90 | const collection = curr.collectionSlug
91 | if (collection) {
92 | collectionSet.add(collection)
93 | acc[curr.id] = collection
94 | }
95 | return acc
96 | }, {})
97 | const collectionMetadatasMap = await this.getCollectionMetadatas(
98 | Array.from(collectionSet)
99 | )
100 | return collectibles.map((collectible) => {
101 | const collection = idToCollectionMap[collectible.id]
102 | const collectionMetadata = collection
103 | ? collectionMetadatasMap[collection]
104 | : null
105 | if (collectionMetadata) {
106 | return {
107 | ...collectible,
108 | collectionName: collectionMetadata?.name ?? null,
109 | collectionImageUrl: collectionMetadata?.image_url ?? null
110 | }
111 | }
112 | return collectible
113 | })
114 | }
115 |
116 | async getCollectibles(wallets: string[]): Promise {
117 | return this.getNftsForMultipleWallets(wallets)
118 | .then(async (nfts) => {
119 | const assets = await Promise.all(
120 | nfts.map(async (nft) => this.addNftMetadata(nft))
121 | )
122 | const validAssets = assets.filter((asset) => asset && isAssetValid(asset))
123 |
124 | // For assets, build a set of collections to fetch metadata for
125 | // and fetch them all at once, making sure to not fetch
126 | // the same collection metadata multiple times.
127 | const assetCollectionSet = new Set()
128 | const idToAssetCollectionMap = validAssets.reduce((acc, curr) => {
129 | // Believe it or not, sometimes, rarely, the type of collection is an object
130 | // that looks like { name: string, family: string }
131 | // and sometimes it's a string. I don't know why.
132 | // Wonder if worth changing the 'collection' type and chasing down all the
133 | // type errors that would cause just for this irregularity. Probably not for now.
134 | const collection =
135 | typeof curr.collection === 'object'
136 | ? (curr.collection as unknown as any).name ?? ''
137 | : curr.collection
138 | assetCollectionSet.add(collection)
139 | const id = getAssetIdentifier(curr)
140 | acc[id] = collection
141 | return acc
142 | }, {})
143 | const assetCollectionMetadatasMap = await this.getCollectionMetadatas(
144 | Array.from(assetCollectionSet)
145 | )
146 | validAssets.forEach((asset) => {
147 | const id = getAssetIdentifier(asset)
148 | const collection = idToAssetCollectionMap[id]
149 | const collectionMetadata = assetCollectionMetadatasMap[collection]
150 | if (collectionMetadata) {
151 | asset.collectionMetadata = collectionMetadata
152 | }
153 | })
154 |
155 | const collectibles = await Promise.all(
156 | validAssets.map(async (asset) => await assetToCollectible(asset))
157 | )
158 |
159 | const result = collectibles.reduce(
160 | (result, collectible) => {
161 | if (result[collectible.wallet]) {
162 | result[collectible.wallet].push(collectible)
163 | } else {
164 | result[collectible.wallet] = [collectible]
165 | }
166 | return result
167 | },
168 | {}
169 | )
170 | return result
171 | })
172 | }
173 | }
174 |
--------------------------------------------------------------------------------
/src/eth/types.ts:
--------------------------------------------------------------------------------
1 | import { Nullable } from 'utils/typeUtils'
2 |
3 | export type EthTokenStandard = 'ERC721' | 'ERC1155'
4 |
5 | type OpenSeaAssetContract = {
6 | address: Nullable
7 | asset_contract_type: string
8 | created_date: string
9 | name: string
10 | nft_version: string
11 | opensea_version: Nullable
12 | owner: Nullable
13 | schema_name: EthTokenStandard
14 | symbol: string
15 | description: Nullable
16 | external_link: Nullable
17 | image_url: Nullable
18 | }
19 |
20 | type OpenSeaAssetPerson = {
21 | user: {
22 | username: string
23 | }
24 | address: string
25 | } | null
26 | type OpenSeaAssetOwner = OpenSeaAssetPerson
27 | type OpenSeaAssetCreator = OpenSeaAssetPerson
28 |
29 | export type OpenSeaCollection = {
30 | collection: string
31 | name: string
32 | description: string
33 | image_url: string
34 | }
35 |
36 | // This metadata object is absurd. It is the combination of
37 | // some real standards and some just random fields we get back.
38 | // Use it to try to display whatever we can. Yay NFTs.
39 | export type OpenSeaNftMetadata = {
40 | token_id?: string
41 | name?: string
42 | description?: string
43 | external_url?: string
44 | permalink?: string
45 | image?: string
46 | image_url?: string
47 | image_preview_url?: string
48 | image_thumbnail_url?: string
49 | image_original_url?: string
50 | animation_url?: string
51 | animation_original_url?: string
52 | youtube_url?: string
53 | background_color?: string
54 | owner?: OpenSeaAssetOwner
55 | creator?: OpenSeaAssetCreator
56 | asset_contract?: OpenSeaAssetContract
57 | }
58 |
59 | export type OpenSeaNft = {
60 | identifier: string
61 | collection: string
62 | contract: string
63 | token_standard: EthTokenStandard
64 | name: string
65 | description: string
66 | image_url: string
67 | metadata_url: string
68 | opensea_url: string
69 | // Audius added fields
70 | wallet: string
71 | }
72 |
73 | export type OpenSeaNftExtended = OpenSeaNft &
74 | OpenSeaNftMetadata & { collectionMetadata?: OpenSeaCollection }
75 |
76 | export type OpenSeaEvent = {
77 | id: number
78 | event_timestamp: number
79 | from_address: string
80 | to_address: string
81 | nft: OpenSeaNft
82 | wallet: string
83 | }
84 |
85 | export type OpenSeaEventExtended = Omit & {
86 | nft: OpenSeaNftExtended
87 | }
88 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import { Metadata } from '@metaplex-foundation/mpl-token-metadata'
2 |
3 | import { OpenSeaClient, OpenSeaConfig } from 'eth/opensea'
4 | import { EthereumCollectiblesProvider } from 'eth/provider'
5 | import { OpenSeaCollection } from 'eth/types'
6 | import { HeliusClient, HeliusConfig } from 'sol/helius'
7 | import { SolanaCollectiblesProvider } from 'sol/provider'
8 | import { Nullable } from 'utils/typeUtils'
9 | import { Collectible, CollectibleState } from 'utils/types'
10 |
11 | import 'cross-fetch/polyfill'
12 |
13 | type FetchNFTClientProps = {
14 | openSeaConfig?: OpenSeaConfig
15 | heliusConfig? : HeliusConfig
16 | solanaConfig?: {
17 | rpcEndpoint?: string
18 | metadataProgramId?: string
19 | }
20 | }
21 |
22 | export class FetchNFTClient {
23 | private readonly ethCollectiblesProvider: EthereumCollectiblesProvider
24 | private readonly solCollectiblesProvider: SolanaCollectiblesProvider
25 |
26 | constructor(props?: FetchNFTClientProps) {
27 | const openseaClient = new OpenSeaClient(props?.openSeaConfig)
28 | this.ethCollectiblesProvider = new EthereumCollectiblesProvider(openseaClient)
29 | const heliusClient = new HeliusClient(props?.heliusConfig)
30 | this.solCollectiblesProvider = new SolanaCollectiblesProvider({
31 | heliusClient,
32 | rpcEndpoint: props?.solanaConfig?.rpcEndpoint,
33 | metadataProgramId: props?.solanaConfig?.metadataProgramId
34 | })
35 | }
36 |
37 | public getEthereumCollectionMetadatas = async (addresses: string[]): Promise<{ [address: string]: OpenSeaCollection }> => {
38 | return this.ethCollectiblesProvider.getCollectionMetadatas(addresses)
39 | }
40 |
41 | public getEthereumCollectionMetadatasForCollectibles = async (collectibles: Collectible[]): Promise => {
42 | return this.ethCollectiblesProvider.getCollectionMetadatasForCollectibles(collectibles)
43 | }
44 |
45 | public getEthereumCollectibles = async (
46 | wallets: string[]
47 | ): Promise => {
48 | return wallets.length
49 | ? this.ethCollectiblesProvider.getCollectibles(wallets)
50 | : {}
51 | }
52 |
53 | public getSolanaMetadataFromChain = async (mintAddress: string): Promise> => {
54 | return this.solCollectiblesProvider.getMetadataFromChain(mintAddress)
55 | }
56 |
57 | public getSolanaCollectibles = async (
58 | wallets: string[]
59 | ): Promise => {
60 | return wallets.length
61 | ? this.solCollectiblesProvider.getCollectibles(wallets)
62 | : {}
63 | }
64 |
65 | public getCollectibles = async (args: {
66 | ethWallets?: string[]
67 | solWallets?: string[]
68 | }): Promise<{
69 | ethCollectibles: CollectibleState
70 | solCollectibles: CollectibleState
71 | }> => {
72 | try {
73 | const [ethCollectibles, solCollectibles] = await Promise.all([
74 | this.getEthereumCollectibles(args.ethWallets ?? []),
75 | this.getSolanaCollectibles(args.solWallets ?? [])
76 | ])
77 | return { ethCollectibles, solCollectibles }
78 | } catch (e) {
79 | console.error(`FetchNFTClient | getCollectibles | error: ${e}`)
80 | return e
81 | }
82 | }
83 | }
84 |
85 | export { Collectible, CollectibleState }
86 |
--------------------------------------------------------------------------------
/src/sol/helius.ts:
--------------------------------------------------------------------------------
1 | import { HeliusNFT } from './types'
2 |
3 | const HELIUS_NUM_ASSETS_PER_PAGE_LIMIT = 1000
4 |
5 | export type HeliusConfig = {
6 | apiEndpoint?: string
7 | apiKey?: string
8 | limit?: number
9 | }
10 |
11 | export class HeliusClient {
12 | private readonly apiUrl: string = 'https://mainnet.helius-rpc.com'
13 | private readonly apiKey: string = ''
14 | private readonly limit: number = HELIUS_NUM_ASSETS_PER_PAGE_LIMIT
15 |
16 | constructor(props?: HeliusConfig) {
17 | this.apiUrl = props?.apiEndpoint ?? this.apiUrl
18 | this.apiKey = props?.apiKey ?? this.apiKey
19 | this.limit = props?.limit ?? this.limit
20 |
21 | if (this.apiKey) {
22 | this.apiUrl = `${this.apiUrl}/?api_key=${this.apiKey}`
23 | }
24 | }
25 |
26 | async getNFTsForWallet(wallet: string): Promise {
27 | let nfts: HeliusNFT[] = []
28 | try {
29 | let page = 1
30 | while (true) {
31 | const response = await fetch(this.apiUrl, {
32 | method: 'POST',
33 | headers: {
34 | 'Content-Type': 'application/json'
35 | },
36 | body: JSON.stringify({
37 | id: 'test-drive', // todo: what should this be
38 | jsonrpc: '2.0',
39 | method: 'getAssetsByOwner',
40 | params: {
41 | ownerAddress: wallet,
42 | page,
43 | limit: this.limit,
44 | sortBy: {
45 | sortBy: 'id',
46 | sortDirection: 'asc'
47 | },
48 | displayOptions: {
49 | showUnverifiedCollections: false,
50 | showCollectionMetadata: true
51 | }
52 | }
53 | })
54 | })
55 | const { result } = await response.json()
56 | nfts = [...nfts, ...result.items]
57 | const isEmptyResult = result.items.length === 0
58 | const isResultLengthBelowLimit =
59 | result.items.length < this.limit
60 | if (isEmptyResult || isResultLengthBelowLimit) {
61 | break
62 | } else {
63 | page++
64 | }
65 | }
66 | return nfts
67 | } catch (e) {
68 | console.error(`HeliusClient | getNFTsForWallet | error for wallet ${wallet}: ${e}`)
69 | throw e
70 | }
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/src/sol/helpers.ts:
--------------------------------------------------------------------------------
1 | import type { Metadata } from '@metaplex-foundation/mpl-token-metadata'
2 |
3 | import { Nullable } from 'utils/typeUtils'
4 | import { Collectible, CollectibleMediaType } from 'utils/types'
5 |
6 | import {
7 | Blocklist,
8 | HeliusNFT,
9 | MetaplexNFT,
10 | MetaplexNFTPropertiesFile,
11 | SolanaNFT,
12 | SolanaNFTType,
13 | StarAtlasNFT
14 | } from './types'
15 |
16 | type SolanaNFTMedia = {
17 | collectibleMediaType: CollectibleMediaType
18 | url: string
19 | frameUrl: Nullable
20 | }
21 |
22 | const fetchWithTimeout = async (
23 | resource: RequestInfo,
24 | options: { timeout?: number } & RequestInit = {}
25 | ) => {
26 | const { timeout = 4000 } = options
27 |
28 | const controller = new AbortController()
29 | const id = setTimeout(() => controller.abort(), timeout)
30 | const response = await fetch(resource, {
31 | ...options,
32 | signal: controller.signal
33 | })
34 | clearTimeout(id)
35 | return response
36 | }
37 |
38 | /**
39 | * NFT is a gif if it has a file with MIME type image/gif
40 | * if it's a gif, we compute an image frame from the gif
41 | */
42 | const metaplexNftGif = async (
43 | nft: MetaplexNFT
44 | ): Promise> => {
45 | const gifFile = (nft.properties?.files ?? []).find(
46 | (file: any) => typeof file === 'object' && file.type === 'image/gif'
47 | )
48 | if (gifFile) {
49 | let url = (gifFile as MetaplexNFTPropertiesFile).uri
50 | if (!url) {
51 | url = (gifFile as unknown as any).file
52 | }
53 | // frame url for the gif is computed later in the collectibles page
54 | return {
55 | collectibleMediaType: CollectibleMediaType.GIF,
56 | url,
57 | frameUrl: null
58 | }
59 | }
60 | return null
61 | }
62 |
63 | /**
64 | * NFT is a 3D object if:
65 | * - its category is vr, or
66 | * - it has an animation url that ends in glb, or
67 | * - it has a file whose type is glb, or
68 | *
69 | * if the 3D has a poster/thumbnail, it would be:
70 | * - either in the image property, or
71 | * - the properties files with a type of image
72 | */
73 | const metaplexNftThreeDWithFrame = async (
74 | nft: MetaplexNFT
75 | ): Promise> => {
76 | const files = nft.properties?.files ?? []
77 | const objFile = files.find(
78 | (file: any) => typeof file === 'object' && file.type?.includes('glb')
79 | ) as MetaplexNFTPropertiesFile
80 | const objUrl = files.find(
81 | (file: any) => typeof file === 'string' && file.endsWith('glb')
82 | ) as string
83 | const is3DObject =
84 | nft.properties?.category === 'vr' ||
85 | nft.animation_url?.endsWith('glb') ||
86 | objFile ||
87 | objUrl
88 | if (is3DObject) {
89 | let frameUrl
90 | if (!nft.image?.endsWith('glb')) {
91 | frameUrl = nft.image
92 | } else {
93 | const imageFile = files?.find(
94 | (file: any) => typeof file === 'object' && file.type?.includes('image')
95 | ) as MetaplexNFTPropertiesFile
96 | if (imageFile) {
97 | frameUrl = imageFile.uri
98 | }
99 | }
100 | if (frameUrl) {
101 | let url: string
102 | if (nft.animation_url && nft.animation_url.endsWith('glb')) {
103 | url = nft.animation_url
104 | } else if (objFile) {
105 | url = objFile.uri
106 | } else if (objUrl) {
107 | url = objUrl
108 | } else {
109 | return null
110 | }
111 | return {
112 | collectibleMediaType: CollectibleMediaType.THREE_D,
113 | url,
114 | frameUrl
115 | }
116 | }
117 | }
118 | return null
119 | }
120 |
121 | /**
122 | * NFT is a video if:
123 | * - its category is video, or
124 | * - it has an animation url that does not end in glb, or
125 | * - it has a file whose type is video, or
126 | * - it has a file whose url includes watch.videodelivery.net
127 | *
128 | * if the video has a poster/thumbnail, it would be in the image property
129 | * otherwise, we later use the first video frame as the thumbnail
130 | */
131 | const metaplexNftVideo = async (
132 | nft: MetaplexNFT
133 | ): Promise> => {
134 | const files = nft.properties?.files ?? []
135 | // In case we want to restrict to specific file extensions, see below link
136 | // https://github.com/metaplex-foundation/metaplex/blob/81023eb3e52c31b605e1dcf2eb1e7425153600cd/js/packages/web/src/views/artCreate/index.tsx#L318
137 | const videoFile = files.find(
138 | (file: any) =>
139 | typeof file === 'object' &&
140 | file.type?.includes('video') &&
141 | !file.type?.endsWith('glb')
142 | ) as MetaplexNFTPropertiesFile
143 | const videoUrl = files.find(
144 | (file: any) =>
145 | typeof file === 'string' &&
146 | // https://github.com/metaplex-foundation/metaplex/blob/397ceff70b3524aa0543540584c7200c79b198a0/js/packages/web/src/components/ArtContent/index.tsx#L107
147 | file.startsWith('https://watch.videodelivery.net/')
148 | ) as string
149 | const isVideo =
150 | nft.properties?.category === 'video' ||
151 | (nft.animation_url && !nft.animation_url.endsWith('glb')) ||
152 | videoFile ||
153 | videoUrl
154 | if (isVideo) {
155 | let url: string
156 | if (nft.animation_url && !nft.animation_url.endsWith('glb')) {
157 | url = nft.animation_url
158 | } else if (videoFile) {
159 | url = videoFile.uri
160 | } else if (videoUrl) {
161 | url = videoUrl
162 | } else if (files.length) {
163 | // if there is only one file, then that's the video
164 | // otherwise, the second file is the video (the other files are image/audio files)
165 | // https://github.com/metaplex-foundation/metaplex/blob/397ceff70b3524aa0543540584c7200c79b198a0/js/packages/web/src/components/ArtContent/index.tsx#L103
166 | if (files.length === 1) {
167 | url = typeof files[0] === 'object' ? files[0].uri : files[0]
168 | } else {
169 | url = typeof files[1] === 'object' ? files[1].uri : files[1]
170 | }
171 | } else {
172 | return null
173 | }
174 | return {
175 | collectibleMediaType: CollectibleMediaType.VIDEO,
176 | url,
177 | frameUrl: nft.image || null
178 | }
179 | }
180 | return null
181 | }
182 |
183 | /**
184 | * NFT is an image if:
185 | * - its category is image, or
186 | * - it has a file whose type is image, or
187 | * - it has an image property
188 | */
189 | const metaplexNftImage = async (
190 | nft: MetaplexNFT
191 | ): Promise> => {
192 | const files = nft.properties?.files ?? []
193 | // In case we want to restrict to specific file extensions, see below link
194 | // https://github.com/metaplex-foundation/metaplex/blob/81023eb3e52c31b605e1dcf2eb1e7425153600cd/js/packages/web/src/views/artCreate/index.tsx#L316
195 | const imageFile = files.find(
196 | (file: any) => typeof file === 'object' && file.type?.includes('image')
197 | ) as MetaplexNFTPropertiesFile
198 | const isImage =
199 | nft.properties?.category === 'image' || nft.image?.length || imageFile
200 | if (isImage) {
201 | let url
202 | if (nft.image?.length) {
203 | url = nft.image
204 | } else if (imageFile) {
205 | url = imageFile.uri
206 | } else if (files.length) {
207 | if (files.length === 1) {
208 | url = typeof files[0] === 'object' ? files[0].uri : files[0]
209 | } else {
210 | url = typeof files[1] === 'object' ? files[1].uri : files[1]
211 | }
212 | } else {
213 | return null
214 | }
215 | return {
216 | collectibleMediaType: CollectibleMediaType.IMAGE,
217 | url,
218 | frameUrl: url
219 | }
220 | }
221 | return null
222 | }
223 |
224 | /**
225 | * If not easily discoverable tha nft is gif/video/image, we check whether it has files
226 | * if it does not, then we discard the nft
227 | * otherwise, we fetch the content type of the first file and check its MIME type:
228 | * - if gif, we also compute an image frame from it
229 | * - if video, we later use the first video frame as the thumbnail
230 | * - if image, the image url is also the frame url
231 | */
232 | const metaplexNftComputedMedia = async (
233 | nft: MetaplexNFT
234 | ): Promise> => {
235 | const files = nft.properties?.files ?? []
236 | if (!files.length) {
237 | return null
238 | }
239 |
240 | const url = typeof files[0] === 'object' ? files[0].uri : files[0]
241 | const headResponse = await fetchWithTimeout(url, { method: 'HEAD' })
242 | const contentType = headResponse.headers.get('Content-Type')
243 | if (contentType?.includes('gif')) {
244 | // frame url for the gif is computed later in the collectibles page
245 | return {
246 | collectibleMediaType: CollectibleMediaType.GIF,
247 | url,
248 | frameUrl: null
249 | }
250 | }
251 | if (contentType?.includes('video')) {
252 | return {
253 | collectibleMediaType: CollectibleMediaType.VIDEO,
254 | url,
255 | frameUrl: null
256 | }
257 | }
258 | if (contentType?.includes('image')) {
259 | return {
260 | collectibleMediaType: CollectibleMediaType.IMAGE,
261 | url,
262 | frameUrl: url
263 | }
264 | }
265 |
266 | return null
267 | }
268 |
269 | const starAtlasNFTToCollectible = async (
270 | nft: StarAtlasNFT,
271 | solanaChainMetadata: Nullable
272 | ): Promise => {
273 | const identifier = [nft._id, nft.symbol, nft.name, nft.image]
274 | .filter(Boolean)
275 | .join(':::')
276 |
277 | const collectible = {
278 | id: identifier,
279 | tokenId: nft._id,
280 | name: nft.name,
281 | description: nft.description,
282 | isOwned: true,
283 | chain: 'sol',
284 | solanaChainMetadata
285 | } as Collectible
286 |
287 | // todo: check if there are gif or video nfts for star atlas
288 | const is3DObj = [nft.image, nft.media?.thumbnailUrl]
289 | .filter(Boolean)
290 | .some((item) =>
291 | ['glb', 'gltf'].some((extension) => item.endsWith(extension))
292 | )
293 | const hasImageFrame = [nft.image, nft.media?.thumbnailUrl]
294 | .filter(Boolean)
295 | .some((item) =>
296 | ['glb', 'gltf'].every((extension) => !item.endsWith(extension))
297 | )
298 | if (is3DObj && hasImageFrame) {
299 | collectible.mediaType = CollectibleMediaType.THREE_D
300 | collectible.threeDUrl = ['glb', 'gltf'].some((extension) =>
301 | nft.image?.endsWith(extension)
302 | )
303 | ? nft.image
304 | : nft.media?.thumbnailUrl
305 | collectible.frameUrl = ['glb', 'gltf'].every(
306 | (extension) => !nft.image.endsWith(extension)
307 | )
308 | ? nft.image
309 | : nft.media?.thumbnailUrl
310 | } else {
311 | collectible.mediaType = CollectibleMediaType.IMAGE
312 | collectible.imageUrl = nft.image
313 | collectible.frameUrl = nft.media?.thumbnailUrl?.length
314 | ? nft.media.thumbnailUrl
315 | : nft.image
316 | }
317 | collectible.dateCreated = nft.createdAt
318 |
319 | return collectible
320 | }
321 |
322 | const getMediaInfo = async (
323 | nft: MetaplexNFT
324 | ): Promise> => {
325 | try {
326 | const mediaInfo =
327 | (await metaplexNftGif(nft)) ||
328 | (await metaplexNftThreeDWithFrame(nft)) ||
329 | (await metaplexNftVideo(nft)) ||
330 | (await metaplexNftImage(nft)) ||
331 | (await metaplexNftComputedMedia(nft))
332 | return mediaInfo
333 | } catch (e) {
334 | return null
335 | }
336 | }
337 |
338 | const metaplexNFTToCollectible = async (
339 | nft: MetaplexNFT,
340 | solanaChainMetadata: Nullable,
341 | wallet: string
342 | ): Promise => {
343 | const identifier = [nft.symbol, nft.name, nft.image]
344 | .filter(Boolean)
345 | .join(':::')
346 |
347 | const collectible = {
348 | id: identifier,
349 | tokenId: identifier,
350 | name: nft.name,
351 | description: nft.description,
352 | externalLink: nft.external_url,
353 | isOwned: true,
354 | chain: 'sol',
355 | wallet,
356 | solanaChainMetadata
357 | } as Collectible
358 |
359 | if (
360 | (nft.properties?.creators ?? []).some(
361 | (creator: any) => creator.address === wallet
362 | )
363 | ) {
364 | collectible.isOwned = false
365 | }
366 |
367 | const mediaInfo = await getMediaInfo(nft)
368 | const { url, frameUrl, collectibleMediaType } = (mediaInfo ??
369 | {}) as SolanaNFTMedia
370 | collectible.frameUrl = frameUrl
371 | collectible.mediaType = collectibleMediaType
372 | if (collectibleMediaType === CollectibleMediaType.GIF) {
373 | collectible.gifUrl = url
374 | } else if (collectibleMediaType === CollectibleMediaType.THREE_D) {
375 | collectible.threeDUrl = url
376 | } else if (collectibleMediaType === CollectibleMediaType.VIDEO) {
377 | collectible.videoUrl = url
378 | } else if (collectibleMediaType === CollectibleMediaType.IMAGE) {
379 | collectible.imageUrl = url
380 | }
381 |
382 | return collectible
383 | }
384 |
385 | // Can build the metadata from the helius nft fields, or
386 | // can fetch it from the json_uri of the helius nft
387 | const getMetaplexMetadataFromHeliusNFT = async (
388 | nft: HeliusNFT,
389 | useFetch = false
390 | ): Promise> => {
391 | try {
392 | if (useFetch) {
393 | const metaplexData = await fetch(nft.content.json_uri)
394 | const metaplexJson = await metaplexData.json()
395 | return metaplexJson as MetaplexNFT
396 | }
397 | const { metadata, links, files } = nft.content
398 | return {
399 | ...metadata,
400 | ...links,
401 | properties: {
402 | files: files.map((file: { uri: string; mime: string }) => ({
403 | uri: file.uri,
404 | type: file.mime
405 | })),
406 | creators: nft.creators
407 | }
408 | } as MetaplexNFT
409 | } catch (e) {
410 | return null
411 | }
412 | }
413 |
414 | const heliusNFTToCollectible = async (
415 | nft: HeliusNFT,
416 | solanaChainMetadata: Nullable,
417 | wallet: string
418 | ): Promise> => {
419 | const { id, content, grouping, ownership } = nft
420 | const { metadata, links } = content
421 | const { name, symbol, description } = metadata
422 | const { image, external_url: externalUrl } = links
423 |
424 | const identifier = [id, symbol, name, image].filter(Boolean).join(':::')
425 |
426 | const collectible = {
427 | id: identifier,
428 | tokenId: id,
429 | name,
430 | description,
431 | externalLink: externalUrl,
432 | isOwned: ownership.owner === wallet,
433 | chain: 'sol',
434 | wallet,
435 | solanaChainMetadata
436 | } as Collectible
437 |
438 | const collectionGroup = grouping.find(
439 | ({ group_key }) => group_key === 'collection'
440 | )
441 | if (collectionGroup && collectionGroup.collection_metadata) {
442 | collectible.heliusCollection = {
443 | address: collectionGroup.group_value,
444 | name: collectionGroup.collection_metadata.name,
445 | imageUrl: collectionGroup.collection_metadata.image,
446 | externalLink: collectionGroup.collection_metadata.external_url
447 | }
448 | }
449 |
450 | const metaplexMetadata = await getMetaplexMetadataFromHeliusNFT(nft, true)
451 | if (!metaplexMetadata) {
452 | console.warn(
453 | `Could not get nft media info from Helius fields for nft with id ${nft.id}.`
454 | )
455 | return null
456 | }
457 | const mediaInfo = await getMediaInfo(metaplexMetadata)
458 | if (!mediaInfo) {
459 | console.warn(
460 | `Could not get nft media info from Helius metaplex metadata for nft with id ${nft.id}.`
461 | )
462 | return null
463 | }
464 | const { url, frameUrl, collectibleMediaType } = mediaInfo
465 | collectible.frameUrl = frameUrl
466 | collectible.mediaType = collectibleMediaType
467 | if (collectibleMediaType === CollectibleMediaType.GIF) {
468 | collectible.gifUrl = url
469 | } else if (collectibleMediaType === CollectibleMediaType.THREE_D) {
470 | collectible.threeDUrl = url
471 | } else if (collectibleMediaType === CollectibleMediaType.VIDEO) {
472 | collectible.videoUrl = url
473 | } else if (collectibleMediaType === CollectibleMediaType.IMAGE) {
474 | collectible.imageUrl = url
475 | }
476 |
477 | return collectible
478 | }
479 |
480 | const audiusBlocklistUrls = [
481 | '.pro',
482 | '.site',
483 | '.click',
484 | '.fun',
485 | 'sol-drift.com',
486 | 'myrovoucher.com',
487 | 'magiceden.club',
488 | 'tensor.markets',
489 | 'mnde.network',
490 | '4000w.io',
491 | 'juppi.io',
492 | 'jupdao.com',
493 | 'jupgem.com',
494 | 'juptreasure.com',
495 | 'slerfdrop.com',
496 | 'airdrop.drift.exchange'
497 | ]
498 | const audiusBlocklistNames = [
499 | '$1000',
500 | '00jup',
501 | 'airdrop',
502 | 'voucher',
503 | ...audiusBlocklistUrls
504 | ]
505 | export const isHeliusNFTValid = (nft: HeliusNFT, blocklist: Blocklist) => {
506 | const {
507 | blocklist: urlBlocklist,
508 | nftBlocklist,
509 | stringFilters: { nameContains, symbolContains }
510 | } = blocklist
511 | const {
512 | grouping,
513 | content: {
514 | metadata: { name, symbol },
515 | links: { external_url: externalUrl }
516 | }
517 | } = nft
518 | const urlBlocklistExtended = [...urlBlocklist, ...audiusBlocklistUrls]
519 | const isExternalUrlBlocked = urlBlocklistExtended.some((item) =>
520 | externalUrl?.toLowerCase().includes(item.toLowerCase())
521 | )
522 | if (isExternalUrlBlocked) {
523 | return false
524 | }
525 | const isNftIdBlocked = nftBlocklist.includes(nft.id)
526 | if (isNftIdBlocked) {
527 | return false
528 | }
529 | const nameContainsExtended = [...nameContains, ...audiusBlocklistNames]
530 | const isNameBlocked = nameContainsExtended.some((item) =>
531 | name?.toLowerCase().includes(item.toLowerCase())
532 | )
533 | if (isNameBlocked) {
534 | return false
535 | }
536 | const isCollectionNameBlocked = grouping.some((group) =>
537 | nameContainsExtended.some((item) =>
538 | group.collection_metadata?.name
539 | ?.toLowerCase()
540 | .includes(item.toLowerCase())
541 | )
542 | )
543 | if (isCollectionNameBlocked) {
544 | return false
545 | }
546 | const isSymbolBlocked = symbolContains.some((item) =>
547 | symbol?.toLowerCase().includes(item.toLowerCase())
548 | )
549 | if (isSymbolBlocked) {
550 | return false
551 | }
552 | return true
553 | }
554 |
555 | export const solanaNFTToCollectible = async (
556 | nft: SolanaNFT,
557 | wallet: string,
558 | type: SolanaNFTType,
559 | solanaChainMetadata: Nullable
560 | ): Promise> => {
561 | let collectible: Nullable = null
562 | try {
563 | switch (type) {
564 | case SolanaNFTType.HELIUS:
565 | collectible = await heliusNFTToCollectible(
566 | nft as HeliusNFT,
567 | solanaChainMetadata,
568 | wallet
569 | )
570 | break
571 | case SolanaNFTType.METAPLEX:
572 | collectible = await metaplexNFTToCollectible(
573 | nft as MetaplexNFT,
574 | solanaChainMetadata,
575 | wallet
576 | )
577 | break
578 | case SolanaNFTType.STAR_ATLAS:
579 | collectible = await starAtlasNFTToCollectible(
580 | nft as StarAtlasNFT,
581 | solanaChainMetadata
582 | )
583 | break
584 | default:
585 | break
586 | }
587 | return collectible
588 | } catch (e) {
589 | return null
590 | }
591 | }
592 |
--------------------------------------------------------------------------------
/src/sol/provider.ts:
--------------------------------------------------------------------------------
1 | import { Connection, PublicKey } from '@solana/web3.js'
2 |
3 | import { allSettled } from 'utils/allSettled'
4 | import { Nullable } from 'utils/typeUtils'
5 | import { Collectible, CollectibleState } from 'utils/types'
6 |
7 | import { HeliusClient } from './helius'
8 | import { isHeliusNFTValid, solanaNFTToCollectible } from './helpers'
9 | import { Blocklist, HeliusNFT, SolanaNFTType } from './types'
10 |
11 | const RPC_ENDPOINT = 'https://api.mainnet-beta.solana.com'
12 | const METADATA_PROGRAM_ID_PUBLIC_KEY = new PublicKey(
13 | 'metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s'
14 | )
15 |
16 | const BLOCKLIST_URL =
17 | 'https://raw.githubusercontent.com/solflare-wallet/blocklist-automation/master/dist/blocklist.json'
18 |
19 | type SolanaCollectiblesProviderCtorArgs = {
20 | heliusClient: HeliusClient
21 | rpcEndpoint?: string
22 | metadataProgramId?: string
23 | }
24 |
25 | export class SolanaCollectiblesProvider {
26 | private readonly heliusClient: HeliusClient
27 | private readonly rpcEndpoint: string = RPC_ENDPOINT
28 | private readonly metadataProgramIdPublicKey: PublicKey = METADATA_PROGRAM_ID_PUBLIC_KEY
29 | private readonly connection: Nullable = null
30 | private blocklist: Nullable = null
31 |
32 | constructor(props: SolanaCollectiblesProviderCtorArgs) {
33 | this.heliusClient = props.heliusClient
34 | this.rpcEndpoint = props.rpcEndpoint ?? this.rpcEndpoint
35 | this.metadataProgramIdPublicKey = props.metadataProgramId
36 | ? new PublicKey(props.metadataProgramId)
37 | : this.metadataProgramIdPublicKey
38 | try {
39 | this.connection = new Connection(this.rpcEndpoint, 'confirmed')
40 | } catch (e) {
41 | console.error('Could create Solana RPC connection', e)
42 | this.connection = null
43 | }
44 | }
45 |
46 | async getCollectibles(wallets: string[]): Promise {
47 | if (!this.blocklist) {
48 | try {
49 | const blocklistResponse = await fetch(BLOCKLIST_URL)
50 | this.blocklist = await blocklistResponse.json()
51 | } catch (e) {
52 | console.error('Could not fetch Solana nft blocklist', e)
53 | }
54 | }
55 |
56 | const nfts = await allSettled(
57 | wallets.map((wallet) => this.heliusClient.getNFTsForWallet(wallet))
58 | ).then((results: PromiseSettledResult[]) =>
59 | results.map((result, i) => ({ result, wallet: wallets[i] }))
60 | .filter(
61 | (
62 | item
63 | ): item is {
64 | result: PromiseFulfilledResult
65 | wallet: string
66 | } => {
67 | const { result, wallet } = item
68 | const fulfilled = 'value' in result
69 | if (!fulfilled) {
70 | console.error(
71 | `Unable to get Helius NFTs for wallet ${wallet}: ${result.reason}`
72 | )
73 | }
74 | return fulfilled
75 | }
76 | )
77 | .map(({ result, wallet }) => {
78 | const blocklist = this.blocklist
79 | if (blocklist) {
80 | return result.value
81 | .filter((nft) => isHeliusNFTValid(nft, blocklist))
82 | .map((nft) => ({ ...nft, wallet }))
83 | }
84 | return result.value.map((nft) => ({ ...nft, wallet }))
85 | })
86 | )
87 |
88 | const solanaCollectibles = await Promise.all(
89 | nfts.map(async (nftsForWallet: (HeliusNFT & { wallet: string })[]) => {
90 | if (nftsForWallet.length === 0) return []
91 | const wallet = nftsForWallet[0].wallet
92 | const mintAddresses = nftsForWallet.map((nft) => nft.id)
93 | const programAddresses = mintAddresses.map(
94 | (mintAddress) =>
95 | PublicKey.findProgramAddressSync(
96 | [
97 | Buffer.from('metadata'),
98 | this.metadataProgramIdPublicKey.toBytes(),
99 | new PublicKey(mintAddress).toBytes()
100 | ],
101 | this.metadataProgramIdPublicKey
102 | )[0]
103 | )
104 | const chainMetadatas = await Promise.all(
105 | programAddresses.map(async (address) => {
106 | try {
107 | if (!this.connection) return null
108 | const { Metadata } = await import(
109 | '@metaplex-foundation/mpl-token-metadata'
110 | )
111 | return Metadata.fromAccountAddress(this.connection, address)
112 | } catch (e) {
113 | return null
114 | }
115 | })
116 | )
117 | const collectibles = await Promise.all(
118 | nftsForWallet.map(
119 | async (nft, i) =>
120 | await solanaNFTToCollectible(
121 | nft,
122 | wallet,
123 | SolanaNFTType.HELIUS,
124 | chainMetadatas[i]
125 | )
126 | )
127 | )
128 | return collectibles.filter(Boolean) as Collectible[]
129 | })
130 | )
131 |
132 | const collectiblesMap = solanaCollectibles.reduce(
133 | (result, collectibles) => {
134 | if (collectibles.length === 0) return result
135 | result[collectibles[0].wallet] = collectibles
136 | return result
137 | },
138 | {}
139 | )
140 |
141 | return collectiblesMap
142 | }
143 |
144 | async getMetadataFromChain(mintAddress: string) {
145 | if (this.connection === null) return null
146 |
147 | try {
148 | const programAddress = (
149 | PublicKey.findProgramAddressSync(
150 | [
151 | Buffer.from('metadata'),
152 | this.metadataProgramIdPublicKey.toBytes(),
153 | new PublicKey(mintAddress).toBytes()
154 | ],
155 | this.metadataProgramIdPublicKey
156 | )
157 | )[0]
158 |
159 | const { Metadata } = await import(
160 | '@metaplex-foundation/mpl-token-metadata'
161 | )
162 | const metadata = await Metadata.fromAccountAddress(
163 | this.connection,
164 | programAddress
165 | )
166 | const response = await fetch(metadata.data.uri.replaceAll('\x00', ''))
167 | const result = (await response.json()) ?? {}
168 | const imageUrl = result?.image
169 | return {
170 | metadata,
171 | imageUrl
172 | }
173 | } catch (e) {
174 | console.error(`SolanaCollectiblesProvider | getCollectionMetadata | error for mint address ${mintAddress}: ${e}`)
175 | return null
176 | }
177 | }
178 | }
179 |
--------------------------------------------------------------------------------
/src/sol/types.ts:
--------------------------------------------------------------------------------
1 | import { Metadata } from '@metaplex-foundation/mpl-token-metadata'
2 |
3 | import { Nullable } from 'utils/typeUtils'
4 |
5 | /* Metaplex types */
6 |
7 | type MetaplexNFTCreator = {
8 | address: string
9 | verified: boolean
10 | share: number
11 | }
12 |
13 | export type MetaplexNFTPropertiesFile = {
14 | type: string
15 | uri: string
16 | }
17 |
18 | type MetaplexNFTProperties = {
19 | files: (string | MetaplexNFTPropertiesFile)[]
20 | creators: MetaplexNFTCreator[]
21 | category?: string
22 | }
23 |
24 | // may live outside arweave and still have this format
25 | // examples:
26 | // https://cdn.piggygang.com/meta/3ad355d46a9cb2ee57049db4df57088f.json
27 | // https://d1b6hed00dtfsr.cloudfront.net/9086.json
28 | // Also, some nft metadatas are minimal, hence all the many nullable properties
29 | // e.g. https://ipfs.io/ipfs/QmS2BZecgTM5jy1PWzFbxcP6jDsLoq5EbGNmmwCPbi7YNH/6177.json
30 | export type MetaplexNFT = {
31 | name: string
32 | description: Nullable
33 | symbol: Nullable
34 | image: string
35 | animation_url: Nullable
36 | external_url: Nullable
37 | properties: Nullable
38 | }
39 |
40 | /* Star Atlas types */
41 |
42 | // example: https://galaxy.staratlas.com/nfts/2iMhgB4pbdKvwJHVyitpvX5z1NBNypFonUgaSAt9dtDt
43 | export type StarAtlasNFT = {
44 | _id: string
45 | name: string
46 | description: string
47 | symbol: string
48 | image: string
49 | media: {
50 | thumbnailUrl: string // may be empty string
51 | }
52 | deactivated: boolean
53 | createdAt: string
54 | solanaChainMetadata: Metadata
55 | }
56 |
57 | export type Blocklist = {
58 | blocklist: string[] // list of urls
59 | nftBlocklist: string[] // list of nft ids
60 | stringFilters: {
61 | nameContains: string[]
62 | symbolContains: string[]
63 | }
64 | contentHash: string
65 | }
66 |
67 | /* Helius DAS API types */
68 |
69 | export type HeliusCollection = {
70 | address: string
71 | name: string
72 | imageUrl: string
73 | externalLink: string
74 | }
75 |
76 | export type HeliusNFT = {
77 | interface: string
78 | id: string
79 | content: {
80 | $schema: string
81 | json_uri: string
82 | files: {
83 | uri: string
84 | mime: string
85 | }[]
86 | metadata: {
87 | description: string
88 | name: string
89 | symbol: string
90 | token_standard: string
91 | }
92 | links: {
93 | image: string
94 | animation_url: string
95 | external_url: string
96 | }
97 | }
98 | compression: {
99 | compressed: boolean
100 | }
101 | grouping: {
102 | group_key: string
103 | group_value: string
104 | collection_metadata?: {
105 | name: string
106 | symbol: string
107 | image: string
108 | description: string
109 | external_url: string
110 | }
111 | }[]
112 | creators: {
113 | address: string
114 | verified: boolean
115 | share: number
116 | }[]
117 | ownership: {
118 | owner: string
119 | }
120 | token_standard: string
121 | name: string
122 | description: string
123 | image_url: string
124 | metadata_url: string
125 | opensea_url: string
126 | // Audius added fields
127 | wallet: string
128 | }
129 |
130 | /* Generic */
131 |
132 | export enum SolanaNFTType {
133 | HELIUS = 'HELIUS',
134 | METAPLEX = 'METAPLEX',
135 | STAR_ATLAS = 'STAR_ATLAS'
136 | }
137 |
138 | export type SolanaNFT = HeliusNFT | MetaplexNFT | StarAtlasNFT
139 |
--------------------------------------------------------------------------------
/src/utils/allSettled.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-restricted-properties */
2 | export const allSettled = Promise.allSettled
3 | ? Promise.allSettled.bind(Promise)
4 | : (promises: any[]) =>
5 | Promise.all(
6 | promises.map((p: Promise) =>
7 | p
8 | .then((value: any) => ({
9 | status: 'fulfilled',
10 | value
11 | }))
12 | .catch((reason: any) => ({
13 | status: 'rejected',
14 | reason
15 | }))
16 | )
17 | )
18 |
--------------------------------------------------------------------------------
/src/utils/placeholderImage.ts:
--------------------------------------------------------------------------------
1 | export const placeholderImage = 'data:image/webp;base64,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'
2 |
--------------------------------------------------------------------------------
/src/utils/typeUtils.ts:
--------------------------------------------------------------------------------
1 | export type Nullable = T | null
2 |
--------------------------------------------------------------------------------
/src/utils/types.ts:
--------------------------------------------------------------------------------
1 |
2 | import { Metadata } from '@metaplex-foundation/mpl-token-metadata'
3 |
4 | import { EthTokenStandard } from 'eth/types'
5 | import { HeliusCollection } from 'sol/types'
6 | import { Nullable } from 'utils/typeUtils'
7 |
8 | export type Chain = 'eth' | 'sol'
9 |
10 | export type CollectiblesMetadata = {
11 | [key: string]: object
12 | order: string[]
13 | }
14 |
15 | export enum CollectibleMediaType {
16 | IMAGE = 'IMAGE',
17 | VIDEO = 'VIDEO',
18 | GIF = 'GIF',
19 | THREE_D = 'THREE_D',
20 | ANIMATED_WEBP = 'ANIMATED_WEBP'
21 | }
22 |
23 | export type Collectible = {
24 | id: string
25 | tokenId: string
26 | name: Nullable
27 | description: Nullable
28 | mediaType: CollectibleMediaType
29 | frameUrl: Nullable
30 | imageUrl: Nullable
31 | gifUrl: Nullable
32 | videoUrl: Nullable
33 | threeDUrl: Nullable
34 | animationUrl: Nullable
35 | hasAudio: boolean
36 | isOwned: boolean
37 | dateCreated: Nullable
38 | dateLastTransferred: Nullable
39 | externalLink: Nullable
40 | permaLink: Nullable
41 | chain: Chain
42 | wallet: string
43 | duration?: number
44 |
45 | // ethereum nfts
46 | assetContractAddress: Nullable
47 | standard: Nullable
48 | collectionSlug: Nullable
49 | collectionName: Nullable
50 | collectionImageUrl: Nullable
51 |
52 | // solana nfts
53 | solanaChainMetadata?: Nullable
54 | heliusCollection?: Nullable
55 | }
56 |
57 | export type CollectibleState = {
58 | [wallet: string]: Collectible[]
59 | }
60 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "outDir": ".",
4 | "baseUrl": "./src",
5 | "module": "esnext",
6 | "target": "es5",
7 | "lib": ["es6", "dom", "es2016", "es2017"],
8 | "sourceMap": true,
9 | "allowJs": false,
10 | "jsx": "react",
11 | "declaration": true,
12 | "moduleResolution": "node",
13 | "rootDirs": ["src"],
14 | "forceConsistentCasingInFileNames": true,
15 | "allowSyntheticDefaultImports": true,
16 | "noImplicitReturns": true,
17 | "noImplicitThis": true,
18 | "noImplicitAny": true,
19 | "strictNullChecks": false,
20 | "suppressImplicitAnyIndexErrors": true,
21 | "noUnusedLocals": true,
22 | "noUnusedParameters": true,
23 | "skipLibCheck": true
24 | },
25 | "include": ["src"],
26 | "exclude": ["node_modules", "build", "dist", "example", "rollup.config.ts"]
27 | }
28 |
--------------------------------------------------------------------------------
/tsconfig.test.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "module": "commonjs"
5 | }
6 | }
--------------------------------------------------------------------------------