├── .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 | {collectible.name} 28 |
29 | )) 30 | } 31 |
Solana Collectibles
32 | { 33 | collectibles?.solCollectibles['GrWNH9qfwrvoCEoTm65hmnSh4z3CD96SfhtfQY6ZKUfY'] 34 | .map(collectible => ( 35 |
36 |
{collectible.name}
37 | {collectible.name} 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,UklGRiISAABXRUJQVlA4WAoAAAAQAAAAHwQAHwQAQUxQSGgFAAABHLRt24b6/++kHbNFxAR0237xrf6pMn6AbltabVt5JIm6BjDwBGAAAzGAgBjAQAQ0AmIAAxHwDGDg9sxUJ3xU8QfWdERMgKxItm1FErUNXANHAAYwgAEEtIFlAAGzBWAAAwg4Bo6BM8/bcG7H/lk9URExAYH/8T/+x//4H//jf/yP//E//sf/+B//43/8j//xP/7H//gf/+N//I//8T/+x//4H//jf/yP//E//sf/+B//43/8j//xP/7H//gf/+N//I//8T/+x//4H//jf/yP//E//sf/+B//43/8j//xP/7H//gf/+N//I//8T/+x//4H//jf/yP//E//sf/+B//43/8j//xP/7H//gf/+N//I//8T/+x//4H//jf/yP//E//sf/+B//43/8j//xP+U93+591nM879dmQZvGLOzj6j8as7i9eU97zgLLeU5jlvjHZjunMYv8cJ3WZ5l/mE6fhf6wHM1Kj2Y4bdY6DSeLNTe/6dWS3VxmtbvdZLlmc5tnva5uM+sts9kKlmbTltErXXlZkHaLl8YduiCIFvddWgwkwXOvK4GAonKuAzxKh1WAR/nwPUBAzft3ABFVt/4NgNSJi/8F1H7Yn1Q7u19E/WF+WwM38zMNXA40bsruXhcabMe5bRHR7uaVWohxFMXf07tiE/0gin+nGxnrfXj52RR704lcUN78aIr9aUMu8X4TZxPjCIrPpgeZg0+b0Ba2eUDF59OBRNkDTQPnAyhemf5jM/twDdzWU7w23UcyOzka6MspXp3eI8pesql2nqsrXp/WE9kNQ7VcTfHONB7PjrKpdJmLK96bvqM98ajUF1O8O11nY1f0VW5zbcX703TOzrKtEObaihXTc9gZsy0WuLZizXQc1x25FzrYtmLVNJz9DTBKAXuxbcW66TfHO6AGeSCRjStWTruJb4Fk3MwPsie2rlg7TYukpjOeV2b7itXTtrpVrJ9epzhiOp3imOlziqOmyymOmx6nOHI6nOLY6W+Ko6e7KY6f3qb4CtPZFF9j+priq7Q1hfHlIMbFWgrjU8G/oY7C+FTwOtRQGJ8Kfg7lFMangruhlML4VHA/vEZhfCp4qlcojE8Fz/U5hfGpoKQ+ozA+FZTVPoXxqaC09iiMTwXl9S+F8amgpv6mMD4V1NUfCuNTQW3NqTA+FdSXwvhU8NENSgVrAxWsDVSwNlDB2kAFi4OIX//hP/yH//6HcD8XcHKq7K//GEHbgwE7Zhsdr32Guew6x8vNMB+79vHCDJO7zHCdU0zfhTRa2xQzt11usBRzzMcupLHyk8xjn+SRiphk5nYL+0CpTDO6hzBMWTDNDHMPxyBli3lmhgcIQ6SCmSabB3A6PofBVMP4BPA6NslhRn0P3SPAn3lUNDjMqW/K8gyA3fyAWoNp9U28TInZ/l38Wh7wWB7wa3nAJKsDqqwOyLA8oPrVAal+dUBqkMUBSY2bXRu81PTpmz19fvz3Hxib2ahgYbbXej3dptUr3SZ6uS52o2r1sNutWuk3kcVqhtNGqRSO+1GpHp6bderNdOJRpXEK192yRuMUxqsKPVtYb+vVGQr7vT4qM7SFA7fr/TkK0u+3c+B//I//8T/+x//4H//jf/yP//E//sf/+B//43/8j//xP/7H//gf/+N//I//8T/+x//4H//jf/yP//E//sf/+B//43/8j//xP/7H//gf/+N//I//8T/+x//4H//jf/yP//E//sf/+B//43/8j//xP/7H//gf/+N//I//8T/+x//4H//jf/yP//E//sf/+B//43/8j//xP/7H//gf/+N//I//8T/+x//4H//jf/yP//E//sf/+B//43/8T3kBVlA4IJQMAADQEAGdASogBCAEPpFIoUylpKMiINJ4CLASCWlu4XdhG/Np8Xf4jtV/yPCgfMPennbKTewHay8yV564XVXjqP9P+zvms/Ov9B7A366+lJ7HP2Z9i/9Q//+BBxdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xlAPdMFjqe7btu27btu27btjAyQZ25xlAPdTfRtQ5xlAPdTfRtQ5xlAPdTfPyfFkWiQGQEGddbRHOACsUSH97qb6NqHOMoB7qb6NqHOMoB7qb6NqHOAKd+kG2+RJZgL+8YWRxKEZRZNiR4YzZkbUOcZQD3U30bUOcZQD3U30bUOcZO4Iz9sQoe8tsQBQUbP+ljCv1QhhlcxFHFCgztADAkSVaJrOVTNmRtQ5xlAPdTfRtQ5xlAPdTfRtQ5xk7pUxoAUwvxf2GFFEujig6oWxXDrFUJAP0e4eZsyNqHOMoB7qb6NqHOMoB7qb6NqHOMndKmNACgfg+Bhl2gqagDjOGWBQlPz44lrGgEjF1N9G1DnGUA91N9G1DnGUA91N9G0sxn4TQGOIWKyOgzgGNAJGLqb6NqHOMoB7qb6NqHOMoB7qb6NpZjMk1quzSfuolou4yHtu27btu27btlkAPgZsyNqHOMoB7qb6NqHOMoB7qb6NqHOMndKj7lhjAsFEGXaosL63qXqXqXqXqXqXqXqDwVgMx57G6/RtQ5xlAPdTfRtQ5xlAPdTfRtQ5xkNmeDij4dqDRAcR/8ybJsmybJsmyVSF3gVKwEb6NqHOMoB7qb6NqHOMoB7qb6NqHMAGHBitQZQD3TByidLBNiMXU30bUOcZQD3U30bUOcZQD3U30bSxZIYv+R23bdt23bdt23bRxIAOifG1DnGUA91N9G1DnGUA91N9G1DnGUA91O9yKEu+d1N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGUA91N9G1DnGQwAAP7/3gioBqgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAN01vPjiKUgvvodx0J8yrUTB5wpwgbg/knThKh065GxF0iASqOPXxyRghLSqmuk3KI+SaKt0eGhkgQbsKyltPsy+1/0hM5sHhSoyRUsS2T5HfcWhNWhbZUCYeETnmxMDh1a7HYGY+dcZya9cgAoU8F2hA31VTFceiiWemWATYdXhlDDub4GK1zHv6ou4Xt/R2dyj+I/AoQxZqcgIajDV20ixUoaNx5yVtab+ySvxZJxRNeLqwSbU7c3c7MIiMzJNZOzFchnMuOckc4XNkFOALnywAcA41zoHXJCf/gQUzpxspI1giLa7xoRsM976OczS+hxMdmf5D6pGEuTcLATIcreHPK8/9m/OhJVlk782FryfdSLhSmcHlFj/7hHZDOgtvQ5kr2gN+mJuWjeJh6UQ33ksu0hyEWC6h+AB68E/Osemx70YnCLaK+DYTw4eiAIk1PXtPQJn619/7+Ijda1KAsO/32w8225kCh/bqRRAsc5e1u+w5e12rBWNjlhGOpTS1aEfEibeZYRkNuv2FatCNoHBCVVszFsEATONkRujV020o0uf46bHspMMUBBoQ0Je5rEvFppo7oVAgHIvT7/1QrhJIeOi68jh22R81o0hynSHvmq5h0d7+xB0cze5NMecNueHj+OUSbhGOpWh5YRinS743E37X0DTgAFg9c33p4fYA5qkHcQZ1pwZsPBAS8F1rBVavncbv0ISGfr5bU7mwWtiDm5CGLBg2sj9QuHIZ+dugF2a8c9ZkPYmaRBt2CI5epDmJZDZJWcXCdR7kfO/1HuQkHjvOTf4RH31B8PNSGKpXUJfTAfdyAFb60nEDgE69sOXtdvH9bN1kbuysYB66uUoA2QXfi5k6z4XbvGgiZ/WoihuNVxQAB3+TuTjnAyVaki5oT8nULa7R5STYTlvmGoAJBtbt/jvxb3S+SyO1Kx488s7jDFm7VsHTeye/ScKLDvhhTMtgUJv/JYO2NTt2Yiwo78NtJbDhASdvDgjaEDWN0yCJRBsfCArTfaUaEWgie0sp7BJxtGg12IH0tNP61Ig1XP6NfKOMYAV2JR7BrmT0of23jyp8NxNDUbwdgb0rmvpUHuPEK7PsHNL0fxSheeG21mjt4SS5X+q5lFpcAB3q2nstGZS7/NAFipD5Bu+FD9v5F1fFQs1Uw3dS9N1ZmWNJk204SFdAT4K3IiD5wiaYBZJrcwISBZSfgLngyQq37zblDBQOwEPXYNeUyBmr7oeN/qEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' 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 | } --------------------------------------------------------------------------------