explorer: Add information about NFToken NFTs and Collection (#26716)

This adds NFToken support to the Solana Explorer so that users can see NFToken NFTs and Collections and do some basic /exploring/. NFToken Docs: https://nftoken.so

## Feature Overview

[Loom Video](https://www.loom.com/share/362e24aa66ac4db198b3e014a99235cb)

<img width="1175" alt="CleanShot 2022-07-21 at 12 55 08@2x" src="https://user-images.githubusercontent.com/1319079/180270677-f02c0646-107e-4566-85b7-cd6a9f4b9eac.png">
<img width="1177" alt="CleanShot 2022-07-21 at 12 55 00@2x" src="https://user-images.githubusercontent.com/1319079/180270686-d376bcba-3477-47ef-8cb0-972ad3b8d22c.png">

## Code Overview

[Loom Video](https://www.loom.com/share/bdab68e55d73462a9222b105dd2316cb)
This commit is contained in:
Victor Pontis 2022-08-26 12:49:37 -04:00 committed by GitHub
parent 56cebf9da2
commit 900f8a3b2b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 1250 additions and 104 deletions

2
.gitignore vendored
View File

@ -19,7 +19,7 @@ log-*.txt
log-*/
# intellij files
/.idea/
.idea/
/solana.iml
/.vscode/

View File

@ -36,6 +36,7 @@
"@types/react-router-dom": "^5.3.2",
"@types/react-select": "^3.1.2",
"@types/socket.io-client": "^3.0.0",
"axios": "^0.27.2",
"bignumber.js": "^9.0.2",
"bn.js": "^5.2.0",
"bootstrap": "~5.1.3",
@ -46,6 +47,7 @@
"coingecko-api": "^1.0.10",
"cross-fetch": "^3.1.4",
"humanize-duration-ts": "^2.1.1",
"p-limit": "^3.0.0",
"prettier": "^2.7.1",
"react": "^18.1.0",
"react-chartjs-2": "^2.11.2",
@ -59,6 +61,7 @@
"react-select": "^4.3.1",
"sass": "^1.53.0",
"superstruct": "^0.15.3",
"swr": "^1.3.0",
"typescript": "^4.7.4"
}
},
@ -2954,6 +2957,20 @@
"node": ">=8"
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
@ -3231,6 +3248,20 @@
"node": ">=8"
}
},
"node_modules/@jest/core/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@jest/core/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
@ -3767,6 +3798,20 @@
"node": ">=8"
}
},
"node_modules/@jest/reporters/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@jest/reporters/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
@ -4554,6 +4599,14 @@
"node": ">= 10"
}
},
"node_modules/@metaplex/js/node_modules/axios": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.25.0.tgz",
"integrity": "sha512-cD8FOb0tRH3uuEe6+evtAbgJtfxr7ly3fQjYcMcuPlgkwVS9xboaVIpcDV+cYQe+yGykgwZCs1pzjntcGa6l5g==",
"dependencies": {
"follow-redirects": "^1.14.7"
}
},
"node_modules/@metaplex/js/node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
@ -7236,11 +7289,25 @@
}
},
"node_modules/axios": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.25.0.tgz",
"integrity": "sha512-cD8FOb0tRH3uuEe6+evtAbgJtfxr7ly3fQjYcMcuPlgkwVS9xboaVIpcDV+cYQe+yGykgwZCs1pzjntcGa6l5g==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz",
"integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==",
"dependencies": {
"follow-redirects": "^1.14.7"
"follow-redirects": "^1.14.9",
"form-data": "^4.0.0"
}
},
"node_modules/axios/node_modules/form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/axobject-query": {
@ -8046,9 +8113,9 @@
"integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="
},
"node_modules/bn.js": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.0.tgz",
"integrity": "sha512-D7iWRBvnZE8ecXiLj/9wbxH7Tk79fAh8IHaTNq1RWRixsS02W+5qS+iE9yq6RYl0asXx5tw0bLhmT5pIfbSquw=="
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.1.tgz",
"integrity": "sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ=="
},
"node_modules/body-parser": {
"version": "1.19.0",
@ -12370,9 +12437,9 @@
}
},
"node_modules/follow-redirects": {
"version": "1.14.8",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.8.tgz",
"integrity": "sha512-1x0S9UVJHsQprFcEC/qnNzBLcIxsjAV905f/UkQxbclCsoTWlacCNOpQa/anodLl2uaEKFhfWOvM2Qg77+15zA==",
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.1.tgz",
"integrity": "sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA==",
"funding": [
{
"type": "individual",
@ -13672,6 +13739,20 @@
"node": ">=8"
}
},
"node_modules/import-local/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/import-local/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
@ -14890,6 +14971,20 @@
"node": ">=8"
}
},
"node_modules/jest-config/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/jest-config/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
@ -16300,6 +16395,20 @@
"node": ">=8"
}
},
"node_modules/jest-resolve/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/jest-resolve/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
@ -16524,6 +16633,20 @@
"node": ">=8"
}
},
"node_modules/jest-runner/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/jest-runner/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
@ -16781,6 +16904,20 @@
"node": ">=8"
}
},
"node_modules/jest-runtime/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/jest-runtime/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
@ -17091,6 +17228,20 @@
"node": ">=8"
}
},
"node_modules/jest-snapshot/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/jest-snapshot/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
@ -17792,6 +17943,20 @@
"node": ">=8"
}
},
"node_modules/jest/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/jest/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
@ -19558,14 +19723,14 @@
}
},
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
"dependencies": {
"p-try": "^2.0.0"
"yocto-queue": "^0.1.0"
},
"engines": {
"node": ">=6"
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
@ -19582,6 +19747,20 @@
"node": ">=6"
}
},
"node_modules/p-locate/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-map": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz",
@ -21716,6 +21895,20 @@
"node": ">=8"
}
},
"node_modules/react-dev-utils/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/react-dev-utils/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
@ -24381,6 +24574,14 @@
"domelementtype": "1"
}
},
"node_modules/swr": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/swr/-/swr-1.3.0.tgz",
"integrity": "sha512-dkghQrOl2ORX9HYrMDtPa7LTVHJjCTeZoB1dqTbnnEDlSvN8JEKpYIYurDfvbQFUUS8Cg8PceFVZNkW0KNNYPw==",
"peerDependencies": {
"react": "^16.11.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
@ -24642,20 +24843,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/terser-webpack-plugin/node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
"dependencies": {
"yocto-queue": "^0.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/terser-webpack-plugin/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
@ -29508,6 +29695,14 @@
"p-locate": "^4.1.0"
}
},
"p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"requires": {
"p-try": "^2.0.0"
}
},
"p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
@ -29717,6 +29912,14 @@
"p-locate": "^4.1.0"
}
},
"p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"requires": {
"p-try": "^2.0.0"
}
},
"p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
@ -30111,6 +30314,14 @@
"p-locate": "^4.1.0"
}
},
"p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"requires": {
"p-try": "^2.0.0"
}
},
"p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
@ -30700,6 +30911,14 @@
"dotenv": "10.0.0"
}
},
"axios": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.25.0.tgz",
"integrity": "sha512-cD8FOb0tRH3uuEe6+evtAbgJtfxr7ly3fQjYcMcuPlgkwVS9xboaVIpcDV+cYQe+yGykgwZCs1pzjntcGa6l5g==",
"requires": {
"follow-redirects": "^1.14.7"
}
},
"buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
@ -32717,11 +32936,24 @@
"integrity": "sha512-V+Nq70NxKhYt89ArVcaNL9FDryB3vQOd+BFXZIfO3RP6rwtj+2yqqqdHEkacutglPaZLkJeuXKCjCJDMGPtPqg=="
},
"axios": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.25.0.tgz",
"integrity": "sha512-cD8FOb0tRH3uuEe6+evtAbgJtfxr7ly3fQjYcMcuPlgkwVS9xboaVIpcDV+cYQe+yGykgwZCs1pzjntcGa6l5g==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz",
"integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==",
"requires": {
"follow-redirects": "^1.14.7"
"follow-redirects": "^1.14.9",
"form-data": "^4.0.0"
},
"dependencies": {
"form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"requires": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
}
}
}
},
"axobject-query": {
@ -33368,9 +33600,9 @@
"integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="
},
"bn.js": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.0.tgz",
"integrity": "sha512-D7iWRBvnZE8ecXiLj/9wbxH7Tk79fAh8IHaTNq1RWRixsS02W+5qS+iE9yq6RYl0asXx5tw0bLhmT5pIfbSquw=="
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.1.tgz",
"integrity": "sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ=="
},
"body-parser": {
"version": "1.19.0",
@ -36791,9 +37023,9 @@
}
},
"follow-redirects": {
"version": "1.14.8",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.8.tgz",
"integrity": "sha512-1x0S9UVJHsQprFcEC/qnNzBLcIxsjAV905f/UkQxbclCsoTWlacCNOpQa/anodLl2uaEKFhfWOvM2Qg77+15zA=="
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.1.tgz",
"integrity": "sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA=="
},
"for-in": {
"version": "1.0.2",
@ -37825,6 +38057,14 @@
"p-locate": "^4.1.0"
}
},
"p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"requires": {
"p-try": "^2.0.0"
}
},
"p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
@ -38486,6 +38726,14 @@
"p-locate": "^4.1.0"
}
},
"p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"requires": {
"p-try": "^2.0.0"
}
},
"p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
@ -38881,6 +39129,14 @@
"p-locate": "^4.1.0"
}
},
"p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"requires": {
"p-try": "^2.0.0"
}
},
"p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
@ -39857,6 +40113,14 @@
"p-locate": "^4.1.0"
}
},
"p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"requires": {
"p-try": "^2.0.0"
}
},
"p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
@ -40092,6 +40356,14 @@
"p-locate": "^4.1.0"
}
},
"p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"requires": {
"p-try": "^2.0.0"
}
},
"p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
@ -40287,6 +40559,14 @@
"p-locate": "^4.1.0"
}
},
"p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"requires": {
"p-try": "^2.0.0"
}
},
"p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
@ -40520,6 +40800,14 @@
"p-locate": "^4.1.0"
}
},
"p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"requires": {
"p-try": "^2.0.0"
}
},
"p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
@ -42270,11 +42558,11 @@
"integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4="
},
"p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
"requires": {
"p-try": "^2.0.0"
"yocto-queue": "^0.1.0"
}
},
"p-locate": {
@ -42283,6 +42571,16 @@
"integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==",
"requires": {
"p-limit": "^2.0.0"
},
"dependencies": {
"p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"requires": {
"p-try": "^2.0.0"
}
}
}
},
"p-map": {
@ -44015,6 +44313,14 @@
"p-locate": "^4.1.0"
}
},
"p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"requires": {
"p-try": "^2.0.0"
}
},
"p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
@ -46128,6 +46434,12 @@
}
}
},
"swr": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/swr/-/swr-1.3.0.tgz",
"integrity": "sha512-dkghQrOl2ORX9HYrMDtPa7LTVHJjCTeZoB1dqTbnnEDlSvN8JEKpYIYurDfvbQFUUS8Cg8PceFVZNkW0KNNYPw==",
"requires": {}
},
"symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
@ -46312,14 +46624,6 @@
"semver": "^6.0.0"
}
},
"p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
"requires": {
"yocto-queue": "^0.1.0"
}
},
"p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",

View File

@ -31,6 +31,7 @@
"@types/react-router-dom": "^5.3.2",
"@types/react-select": "^3.1.2",
"@types/socket.io-client": "^3.0.0",
"axios": "^0.27.2",
"bignumber.js": "^9.0.2",
"bn.js": "^5.2.0",
"bootstrap": "~5.1.3",
@ -41,6 +42,7 @@
"coingecko-api": "^1.0.10",
"cross-fetch": "^3.1.4",
"humanize-duration-ts": "^2.1.1",
"p-limit": "^3.0.0",
"prettier": "^2.7.1",
"react": "^18.1.0",
"react-chartjs-2": "^2.11.2",
@ -54,6 +56,7 @@
"react-select": "^4.3.1",
"sass": "^1.53.0",
"superstruct": "^0.15.3",
"swr": "^1.3.0",
"typescript": "^4.7.4"
},
"scripts": {

View File

@ -0,0 +1,32 @@
import { PublicKey } from "@solana/web3.js";
import { expect } from "chai";
import { NFTOKEN_ADDRESS } from "../components/account/nftoken/nftoken";
import { parseNFTokenNFTAccount } from "../components/account/nftoken/isNFTokenAccount";
describe("parseNFTokenAccounts", () => {
it("parses an NFT", () => {
const buffer = new Uint8Array([
33, 180, 91, 53, 236, 15, 63, 97, 1, 13, 194, 212, 59, 127, 163, 1, 184,
232, 229, 196, 221, 132, 114, 202, 93, 251, 147, 255, 156, 194, 45, 162,
89, 138, 54, 129, 145, 16, 170, 225, 110, 171, 80, 175, 146, 42, 195, 197,
124, 142, 197, 32, 198, 20, 137, 26, 33, 27, 67, 163, 173, 127, 113, 232,
108, 17, 2, 184, 52, 59, 71, 87, 97, 1, 178, 138, 249, 251, 68, 1, 82,
163, 86, 56, 204, 21, 192, 126, 64, 94, 187, 81, 78, 188, 73, 85, 189,
140, 52, 199, 206, 30, 238, 117, 158, 114, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 67, 0, 0, 0, 104, 116, 116, 112, 115, 58, 47, 47, 99, 100, 110, 46,
103, 108, 111, 119, 46, 97, 112, 112, 47, 110, 47, 56, 56, 47, 55, 56,
101, 102, 49, 55, 99, 49, 45, 50, 98, 53, 97, 45, 52, 54, 56, 101, 45, 97,
101, 56, 102, 45, 55, 52, 48, 51, 56, 53, 54, 101, 57, 102, 48, 48, 46,
106, 115, 111, 110, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
]);
const nftAccount = parseNFTokenNFTAccount({
pubkey: new PublicKey("FagABcRBhZH27JDtu6A1Jo9woXyoznP28QujLkxkN9Hj"),
details: { rawData: buffer, owner: new PublicKey(NFTOKEN_ADDRESS) },
} as any);
expect(nftAccount!.metadata_url).to.eq(
"https://cdn.glow.app/n/88/78ef17c1-2b5a-468e-ae8f-7403856e9f00.json"
);
});
});

View File

@ -1,5 +1,4 @@
import React from "react";
import "bootstrap/dist/js/bootstrap.min.js";
import {
NFTData,
useFetchAccountInfo,
@ -13,7 +12,7 @@ import { Link } from "react-router-dom";
import { EditionInfo } from "providers/accounts/utils/getEditionInfo";
import { PublicKey } from "@solana/web3.js";
export function NFTHeader({
export function MetaplexNFTHeader({
nftData,
address,
}: {

View File

@ -0,0 +1,120 @@
import React, { Suspense } from "react";
import { Account } from "../../../providers/accounts";
import {
parseNFTokenCollectionAccount,
parseNFTokenNFTAccount,
} from "./isNFTokenAccount";
import { NftokenTypes } from "./nftoken-types";
import { InfoTooltip } from "../../common/InfoTooltip";
import { CachedImageContent } from "../../common/NFTArt";
import { useNftokenMetadata } from "./nftoken-hooks";
export function NFTokenAccountHeader({ account }: { account: Account }) {
const nft = parseNFTokenNFTAccount(account);
if (nft) {
return (
<Suspense fallback={<div>Loading...</div>}>
<NFTokenNFTHeader nft={nft} />
</Suspense>
);
}
const collection = parseNFTokenCollectionAccount(account);
if (collection) {
return (
<Suspense fallback={<div>Loading...</div>}>
<NFTokenCollectionHeader collection={collection} />
</Suspense>
);
}
return (
<>
<h6 className="header-pretitle">Details</h6>
<h2 className="header-title">Account</h2>
</>
);
}
export function NFTokenNFTHeader({ nft }: { nft: NftokenTypes.NftAccount }) {
const { data: metadata } = useNftokenMetadata(nft.metadata_url);
return (
<div className="row">
<div className="col-auto ms-2 d-flex align-items-center">
<CachedImageContent uri={metadata?.image} />
</div>
<div className="col mb-3 ms-0.5 mt-3">
{<h6 className="header-pretitle ms-1">NFToken NFT</h6>}
<div className="d-flex align-items-center">
<h2 className="header-title ms-1 align-items-center no-overflow-with-ellipsis">
{metadata ? metadata.name || "No NFT name was found" : "Loading..."}
</h2>
</div>
<div>
<div className={"d-inline-flex align-items-center mt-2"}>
<span className="badge badge-pill bg-dark">{`${
nft.authority_can_update ? "Mutable" : "Immutable"
}`}</span>
<InfoTooltip
bottom
text={
nft.authority_can_update
? "The authority of this NFT can update the Metadata."
: "The Metadata cannot be updated by anyone."
}
/>
</div>
</div>
</div>
</div>
);
}
export function NFTokenCollectionHeader({
collection,
}: {
collection: NftokenTypes.CollectionAccount;
}) {
const { data: metadata } = useNftokenMetadata(collection.metadata_url);
return (
<div className="row">
<div className="col-auto ms-2 d-flex align-items-center">
<CachedImageContent uri={metadata?.image} />
</div>
<div className="col mb-3 ms-0.5 mt-3">
{<h6 className="header-pretitle ms-1">NFToken Collection</h6>}
<div className="d-flex align-items-center">
<h2 className="header-title ms-1 align-items-center no-overflow-with-ellipsis">
{metadata
? metadata.name || "No collection name was found"
: "Loading..."}
</h2>
</div>
<div>
<div className={"d-inline-flex align-items-center mt-2"}>
<span className="badge badge-pill bg-dark">{`${
collection.authority_can_update ? "Mutable" : "Immutable"
}`}</span>
<InfoTooltip
bottom
text={
collection.authority_can_update
? "The authority of this Collection can update the Metadata and add NFTs."
: "The Metadata cannot be updated by anyone."
}
/>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,217 @@
import { PublicKey } from "@solana/web3.js";
import { Address } from "components/common/Address";
import { TableCardBody } from "components/common/TableCardBody";
import { Account, useFetchAccountInfo } from "providers/accounts";
import { Suspense, useEffect, useState } from "react";
import {
parseNFTokenCollectionAccount,
parseNFTokenNFTAccount,
} from "./isNFTokenAccount";
import { NftokenTypes } from "./nftoken-types";
import { MAX_TIME_LOADING_IMAGE, useCachedImage } from "../../common/NFTArt";
import { useCollectionNfts } from "./nftoken-hooks";
import { UnknownAccountCard } from "../UnknownAccountCard";
export function NFTokenAccountSection({ account }: { account: Account }) {
const nft = parseNFTokenNFTAccount(account);
if (nft) {
return <NFTCard nft={nft} />;
}
const collection = parseNFTokenCollectionAccount(account);
if (collection) {
return <CollectionCard collection={collection} />;
}
return <UnknownAccountCard account={account} />;
}
const NFTCard = ({ nft }: { nft: NftokenTypes.NftAccount }) => {
const fetchInfo = useFetchAccountInfo();
const refresh = () => fetchInfo(new PublicKey(nft.address));
return (
<div className="card">
<div className="card-header">
<h3 className="card-header-title mb-0 d-flex align-items-center">
Overview
</h3>
<button className="btn btn-white btn-sm" onClick={refresh}>
<span className="fe fe-refresh-cw me-2"></span>
Refresh
</button>
</div>
<TableCardBody>
<tr>
<td>Address</td>
<td className="text-lg-end">
<Address pubkey={new PublicKey(nft.address)} alignRight raw />
</td>
</tr>
<tr>
<td>Authority</td>
<td className="text-lg-end">
<Address pubkey={new PublicKey(nft.authority)} alignRight link />
</td>
</tr>
<tr>
<td>Holder</td>
<td className="text-lg-end">
<Address pubkey={new PublicKey(nft.holder)} alignRight link />
</td>
</tr>
<tr>
<td>Delegate</td>
<td className="text-lg-end">
{nft.delegate ? (
<Address pubkey={new PublicKey(nft.delegate)} alignRight link />
) : (
"Not Delegated"
)}
</td>
</tr>
<tr>
<td>Collection</td>
<td className="text-lg-end">
{nft.collection ? (
<Address pubkey={new PublicKey(nft.collection)} alignRight link />
) : (
"No Collection"
)}
</td>
</tr>
</TableCardBody>
</div>
);
};
export const NftokenImage = ({
url,
size,
}: {
url: string | undefined;
size: number;
}) => {
const [isLoading, setIsLoading] = useState<boolean>(true);
const [showError, setShowError] = useState<boolean>(false);
const [timeout, setTimeout] = useState<NodeJS.Timeout | undefined>(undefined);
useEffect(() => {
// Set the timeout if we don't have a valid uri
if (!url && !timeout) {
setTimeout(setInterval(() => setShowError(true), MAX_TIME_LOADING_IMAGE));
}
// We have a uri - clear the timeout
if (url && timeout) {
clearInterval(timeout);
}
return () => {
if (timeout) {
clearInterval(timeout);
}
};
}, [url, setShowError, timeout, setTimeout]);
const { cachedBlob } = useCachedImage(url || "");
return (
<>
{showError ? (
<div
style={{ width: size, height: size, backgroundColor: "lightgrey" }}
/>
) : (
<>
{isLoading && (
<div
style={{
width: size,
height: size,
backgroundColor: "lightgrey",
}}
/>
)}
<div className={`${isLoading ? "d-none" : "d-block"}`}>
<img
className={`rounded mx-auto ${isLoading ? "d-none" : "d-block"}`}
src={cachedBlob}
alt={"nft"}
style={{
width: size,
height: size,
}}
onLoad={() => {
setIsLoading(false);
}}
onError={() => {
setShowError(true);
}}
/>
</div>
</>
)}
</>
);
};
const CollectionCard = ({
collection,
}: {
collection: NftokenTypes.CollectionAccount;
}) => {
const fetchInfo = useFetchAccountInfo();
const refresh = () => fetchInfo(new PublicKey(collection.address));
return (
<div className="card">
<div className="card-header">
<h3 className="card-header-title mb-0 d-flex align-items-center">
Overview
</h3>
<button className="btn btn-white btn-sm" onClick={refresh}>
<span className="fe fe-refresh-cw me-2"></span>
Refresh
</button>
</div>
<TableCardBody>
<tr>
<td>Address</td>
<td className="text-lg-end">
<Address
pubkey={new PublicKey(collection.address)}
alignRight
raw
/>
</td>
</tr>
<tr>
<td>Authority</td>
<td className="text-lg-end">
<Address
pubkey={new PublicKey(collection.authority)}
alignRight
link
/>
</td>
</tr>
<tr>
<td>Number of NFTs</td>
<td className="text-lg-end">
<Suspense fallback={<div>Loading...</div>}>
<NumNfts collection={collection.address} />
</Suspense>
</td>
</tr>
</TableCardBody>
</div>
);
};
const NumNfts = ({ collection }: { collection: string }) => {
const { data: nfts } = useCollectionNfts({ collectionAddress: collection });
return <div>{nfts.length}</div>;
};

View File

@ -0,0 +1,64 @@
import React from "react";
import { Link } from "react-router-dom";
import { clusterPath } from "../../../utils/url";
import { useCollectionNfts } from "./nftoken-hooks";
import { NftokenImage } from "./NFTokenAccountSection";
export function NFTokenCollectionNFTGrid({
collection,
}: {
collection: string;
}) {
const { data: nfts, mutate } = useCollectionNfts({
collectionAddress: collection,
});
return (
<div className="card">
<div className="card-header align-items-center">
<h3 className="card-header-title">NFTs</h3>
<button className="btn btn-white btn-sm" onClick={() => mutate()}>
<span className="fe fe-refresh-cw me-2"></span>
Refresh
</button>
</div>
<div className="py-4">
{nfts.length === 0 && <div className={"px-4"}>No NFTs Found</div>}
{nfts.length > 0 && (
<div
style={{
display: "grid",
/* Creates as many columns as possible that are at least 10rem wide. */
gridTemplateColumns: "repeat(auto-fill, minmax(10rem, 1fr))",
gridGap: "1.5rem",
}}
>
{nfts.map((nft) => (
<div
key={nft.address}
style={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
gap: "1rem",
}}
>
<NftokenImage url={nft.image} size={80} />
<div>
<Link to={clusterPath(`/address/${nft.address}`)}>
<div>{nft.name ?? "No Name"}</div>
</Link>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,9 @@
# NFToken
NFToken is a cheap, simple, secure NFT standard on Solana.
You can find more information and support here:
- [Website](https://nftoken.so)
- [Twitter](https://twitter.com/nftoken_so)
- [Lead Maintainer](https://twitter.com/VictorPontis)

View File

@ -0,0 +1,88 @@
import { PublicKey } from "@solana/web3.js";
import { NFTOKEN_ADDRESS } from "./nftoken";
import { Account } from "../../../providers/accounts";
import { NftokenTypes } from "./nftoken-types";
export function isNFTokenAccount(account: Account): boolean {
return Boolean(
account.details?.owner.toBase58() === NFTOKEN_ADDRESS &&
account.details.rawData
);
}
const nftokenAccountDisc = "IbRbNewPP2E=";
export const parseNFTokenNFTAccount = (
account: Account
): NftokenTypes.NftAccount | null => {
if (!isNFTokenAccount(account)) {
return null;
}
try {
const parsed = NftokenTypes.nftAccountLayout.decode(
account!.details!.rawData!
);
if (!parsed) {
return null;
}
if (
Buffer.from(parsed!.discriminator).toString("base64") !==
nftokenAccountDisc
) {
return null;
}
return {
address: account!.pubkey.toBase58(),
holder: new PublicKey(parsed.holder).toBase58(),
authority: new PublicKey(parsed.authority).toBase58(),
authority_can_update: Boolean(parsed.authority_can_update),
collection: new PublicKey(parsed.collection).toBase58(),
delegate: new PublicKey(parsed.delegate).toBase58(),
metadata_url: parsed.metadata_url?.replace(/\0/g, "") ?? null,
};
} catch (e) {
console.error("Problem parsing NFToken NFT...", e);
return null;
}
};
const collectionAccountDisc = "RQLwA3YS2fI=";
export const parseNFTokenCollectionAccount = (
account: Account
): NftokenTypes.CollectionAccount | null => {
if (!isNFTokenAccount(account)) {
return null;
}
try {
const parsed = NftokenTypes.collectionAccountLayout.decode(
account!.details!.rawData!
);
if (!parsed) {
return null;
}
if (
Buffer.from(parsed.discriminator).toString("base64") !==
collectionAccountDisc
) {
return null;
}
return {
address: account!.pubkey.toBase58(),
authority: parsed.authority,
authority_can_update: Boolean(parsed.authority_can_update),
metadata_url: parsed.metadata_url?.replace(/\0/g, "") ?? null,
};
} catch (e) {
console.error("Problem parsing NFToken Collection...", e);
return null;
}
};

View File

@ -0,0 +1,55 @@
import useSWR, { SWRResponse } from "swr";
import { useCluster } from "../../../providers/cluster";
import { NftokenFetcher } from "./nftoken";
import { NftokenTypes } from "./nftoken-types";
const getCollectionNftsFetcher = async (
_method: string,
collectionAddress: string,
url: string
) => {
return await NftokenFetcher.getNftsInCollection({
collection: collectionAddress,
rpcUrl: url,
});
};
export const useCollectionNfts = ({
collectionAddress,
}: {
collectionAddress: string;
}): {
// We can be confident that data will be nonnull even if the request fails,
// if we defined fallbackData in the config.
data: NftokenTypes.NftInfo[];
error: any;
mutate: SWRResponse<NftokenTypes.NftInfo[], never>["mutate"];
} => {
const { url } = useCluster();
const swrKey = ["getNftsInCollection", collectionAddress, url];
const { data, error, mutate } = useSWR(swrKey, getCollectionNftsFetcher, {
suspense: true,
});
// Not nullable since we use suspense
return { data: data!, error, mutate };
};
const getMetadataFetcher = async (metadataUrl: string) => {
return await NftokenFetcher.getMetadata({ url: metadataUrl });
};
export const useNftokenMetadata = (
metadataUrl: string | null | undefined
): {
data: NftokenTypes.Metadata | null;
error: any;
mutate: SWRResponse<NftokenTypes.Metadata | null, never>["mutate"];
} => {
const swrKey = [metadataUrl];
const { data, error, mutate } = useSWR(swrKey, getMetadataFetcher, {
suspense: true,
});
// Not nullable since we use suspense
return { data: data!, error, mutate };
};

View File

@ -0,0 +1,69 @@
import * as BufferLayout from "@solana/buffer-layout";
const publicKey = (property: string) => {
return BufferLayout.blob(32, property);
};
export namespace NftokenTypes {
export type Metadata = {
name: string;
description: string | null;
image: string;
traits: any;
animation_url: string | null;
external_url: string | null;
};
export type CollectionAccount = {
address: string;
authority: string;
authority_can_update: boolean;
metadata_url: string | null;
};
export type NftAccount = {
address: string;
holder: string;
authority: string;
authority_can_update: boolean;
collection: string | null;
delegate: string | null;
metadata_url: string;
};
export type NftInfo = NftAccount & Partial<Metadata>;
export const nftAccountLayout = BufferLayout.struct([
BufferLayout.blob(8, "discriminator"),
BufferLayout.u8("version"),
publicKey("holder"),
publicKey("authority"),
BufferLayout.u8("authority_can_update"),
publicKey("collection"),
publicKey("delegate"),
BufferLayout.u8("is_frozen"),
BufferLayout.u8("unused_1"),
BufferLayout.u8("unused_2"),
BufferLayout.u8("unused_3"),
BufferLayout.u32("metadata_url_length"),
BufferLayout.utf8(400, "metadata_url"),
]);
export const collectionAccountLayout = BufferLayout.struct([
BufferLayout.blob(8, "discriminator"),
BufferLayout.u8("version"),
publicKey("authority"),
BufferLayout.u8("authority_can_update"),
BufferLayout.u8("unused_1"),
BufferLayout.u8("unused_2"),
BufferLayout.u8("unused_3"),
BufferLayout.u8("unused_4"),
BufferLayout.u32("metadata_url_length"),
BufferLayout.utf8(400, "metadata_url"),
]);
}

View File

@ -0,0 +1,143 @@
import axios from "axios";
import pLimit from "p-limit";
import { Connection, PublicKey } from "@solana/web3.js";
import bs58 from "bs58";
import { NftokenTypes } from "./nftoken-types";
export const NFTOKEN_ADDRESS = "nftokf9qcHSYkVSP3P2gUMmV6d4AwjMueXgUu43HyLL";
const nftokenAccountDiscInHex = "21b45b35ec0f3f61";
export namespace NftokenFetcher {
export const getNftsInCollection = async ({
collection,
rpcUrl,
}: {
collection: string;
rpcUrl: string;
}): Promise<NftokenTypes.NftInfo[]> => {
const connection = new Connection(rpcUrl);
const accounts = await connection.getProgramAccounts(
new PublicKey(NFTOKEN_ADDRESS),
{
filters: [
{
memcmp: {
offset: 0,
bytes: bs58.encode(Buffer.from(nftokenAccountDiscInHex, "hex")),
},
},
{
memcmp: {
offset:
8 + // discriminator
1 + // version
32 + // holder
32 + // authority
1, // authority_can_update
bytes: collection,
},
},
],
}
);
const parsed_accounts: NftokenTypes.NftAccount[] = accounts.flatMap(
(account) => {
const parsed = NftokenTypes.nftAccountLayout.decode(
account.account.data
);
if (!parsed) {
return [];
}
return {
address: account.pubkey.toBase58(),
holder: parsed.holder,
authority: parsed.authority,
authority_can_update: Boolean(parsed.authority_can_update),
collection: parsed.collection,
delegate: parsed.delegate,
metadata_url: parsed.metadata_url,
};
}
);
const metadata_urls = parsed_accounts.map((a) => a.metadata_url);
const metadataMap = await getMetadataMap({ urls: metadata_urls });
const nfts = parsed_accounts.map((account) => ({
...account,
...metadataMap.get(account.metadata_url),
}));
nfts.sort();
return nfts.sort((a, b) => {
if (a.name && b.name) {
return a.name < b.name ? -1 : 1;
}
if (a.name) {
return 1;
}
if (b.name) {
return -1;
}
return a.address < b.address ? 1 : -1;
});
};
export const getMetadata = async ({
url,
}: {
url: string | null | undefined;
}): Promise<NftokenTypes.Metadata | null> => {
if (!url) {
return null;
}
const metadataMap = await getMetadataMap({
urls: [url],
});
return metadataMap.get(url) ?? null;
};
export const getMetadataMap = async ({
urls: _urls,
}: {
urls: Array<string | null | undefined>;
}): Promise<Map<string, NftokenTypes.Metadata | null>> => {
const urls = Array.from(
new Set(_urls.filter((url): url is string => Boolean(url)))
);
const metadataMap = new Map<string, NftokenTypes.Metadata | null>();
const limit = pLimit(5);
const promises = urls.map((url) =>
limit(async () => {
try {
const { data } = await axios.get(url, {
timeout: 5_000,
});
metadataMap.set(url, {
name: data.name ?? "",
description: data.description ?? null,
image: data.image ?? "",
traits: data.traits ?? [],
animation_url: data.animation_url ?? null,
external_url: data.external_url ?? null,
});
} catch {
metadataMap.set(url, null);
}
})
);
await Promise.all(promises);
return metadataMap;
};
}

View File

@ -11,7 +11,7 @@ import ContentLoader from "react-content-loader";
import ErrorLogo from "img/logos-solana/dark-solana-logo.svg";
import { getLast } from "utils";
const MAX_TIME_LOADING_IMAGE = 5000; /* 5 seconds */
export const MAX_TIME_LOADING_IMAGE = 5000; /* 5 seconds */
const LoadingPlaceholder = () => (
<ContentLoader
@ -42,7 +42,7 @@ const ViewOriginalArtContentLink = ({ src }: { src: string }) => {
);
};
const CachedImageContent = ({ uri }: { uri?: string }) => {
export const CachedImageContent = ({ uri }: { uri?: string }) => {
const [isLoading, setIsLoading] = useState<boolean>(true);
const [showError, setShowError] = useState<boolean>(false);
const [timeout, setTimeout] = useState<NodeJS.Timeout | undefined>(undefined);

View File

@ -1,49 +1,57 @@
import React from "react";
import { PublicKey } from "@solana/web3.js";
import { CacheEntry, FetchStatus } from "providers/cache";
import {
useFetchAccountInfo,
useAccountInfo,
Account,
TokenProgramData,
useMintAccountInfo,
} from "providers/accounts";
import { StakeAccountSection } from "components/account/StakeAccountSection";
import { TokenAccountSection } from "components/account/TokenAccountSection";
import { ErrorCard } from "components/common/ErrorCard";
import { LoadingCard } from "components/common/LoadingCard";
import { useCluster, ClusterStatus } from "providers/cluster";
import { NavLink, Redirect, useLocation } from "react-router-dom";
import { clusterPath } from "utils/url";
import { UnknownAccountCard } from "components/account/UnknownAccountCard";
import { OwnedTokensCard } from "components/account/OwnedTokensCard";
import { TokenHistoryCard } from "components/account/TokenHistoryCard";
import { TokenLargestAccountsCard } from "components/account/TokenLargestAccountsCard";
import { VoteAccountSection } from "components/account/VoteAccountSection";
import { NonceAccountSection } from "components/account/NonceAccountSection";
import { VotesCard } from "components/account/VotesCard";
import { SysvarAccountSection } from "components/account/SysvarAccountSection";
import { SlotHashesCard } from "components/account/SlotHashesCard";
import { StakeHistoryCard } from "components/account/StakeHistoryCard";
import { BlockhashesCard } from "components/account/BlockhashesCard";
import { ConfigAccountSection } from "components/account/ConfigAccountSection";
import { useFlaggedAccounts } from "providers/accounts/flagged-accounts";
import { UpgradeableLoaderAccountSection } from "components/account/UpgradeableLoaderAccountSection";
import { useTokenRegistry } from "providers/mints/token-registry";
import { Identicon } from "components/common/Identicon";
import { TransactionHistoryCard } from "components/account/history/TransactionHistoryCard";
import { TokenTransfersCard } from "components/account/history/TokenTransfersCard";
import { TokenInstructionsCard } from "components/account/history/TokenInstructionsCard";
import { RewardsCard } from "components/account/RewardsCard";
import { MetaplexMetadataCard } from "components/account/MetaplexMetadataCard";
import { MetaplexNFTAttributesCard } from "components/account/MetaplexNFTAttributesCard";
import { NFTHeader } from "components/account/MetaplexNFTHeader";
import { DomainsCard } from "components/account/DomainsCard";
import isMetaplexNFT from "providers/accounts/utils/isMetaplexNFT";
import { SecurityCard } from "components/account/SecurityCard";
import { AnchorAccountCard } from "components/account/AnchorAccountCard";
import { AnchorProgramCard } from "components/account/AnchorProgramCard";
import { BlockhashesCard } from "components/account/BlockhashesCard";
import { ConfigAccountSection } from "components/account/ConfigAccountSection";
import { DomainsCard } from "components/account/DomainsCard";
import { TokenInstructionsCard } from "components/account/history/TokenInstructionsCard";
import { TokenTransfersCard } from "components/account/history/TokenTransfersCard";
import { TransactionHistoryCard } from "components/account/history/TransactionHistoryCard";
import { MetaplexMetadataCard } from "components/account/MetaplexMetadataCard";
import { MetaplexNFTAttributesCard } from "components/account/MetaplexNFTAttributesCard";
import { MetaplexNFTHeader } from "components/account/MetaplexNFTHeader";
import { NonceAccountSection } from "components/account/NonceAccountSection";
import { OwnedTokensCard } from "components/account/OwnedTokensCard";
import { RewardsCard } from "components/account/RewardsCard";
import { SecurityCard } from "components/account/SecurityCard";
import { SlotHashesCard } from "components/account/SlotHashesCard";
import { StakeAccountSection } from "components/account/StakeAccountSection";
import { StakeHistoryCard } from "components/account/StakeHistoryCard";
import { SysvarAccountSection } from "components/account/SysvarAccountSection";
import { TokenAccountSection } from "components/account/TokenAccountSection";
import { TokenHistoryCard } from "components/account/TokenHistoryCard";
import { TokenLargestAccountsCard } from "components/account/TokenLargestAccountsCard";
import { UnknownAccountCard } from "components/account/UnknownAccountCard";
import { UpgradeableLoaderAccountSection } from "components/account/UpgradeableLoaderAccountSection";
import { VoteAccountSection } from "components/account/VoteAccountSection";
import { VotesCard } from "components/account/VotesCard";
import { ErrorCard } from "components/common/ErrorCard";
import { Identicon } from "components/common/Identicon";
import { LoadingCard } from "components/common/LoadingCard";
import {
Account,
TokenProgramData,
useAccountInfo,
useFetchAccountInfo,
useMintAccountInfo,
} from "providers/accounts";
import { useFlaggedAccounts } from "providers/accounts/flagged-accounts";
import isMetaplexNFT from "providers/accounts/utils/isMetaplexNFT";
import { useAnchorProgram } from "providers/anchor";
import { CacheEntry, FetchStatus } from "providers/cache";
import { ClusterStatus, useCluster } from "providers/cluster";
import { useTokenRegistry } from "providers/mints/token-registry";
import React, { Suspense } from "react";
import { NavLink, Redirect, useLocation } from "react-router-dom";
import { clusterPath } from "utils/url";
import { NFTokenAccountHeader } from "../components/account/nftoken/NFTokenAccountHeader";
import { NFTokenAccountSection } from "../components/account/nftoken/NFTokenAccountSection";
import { NFTokenCollectionNFTGrid } from "../components/account/nftoken/NFTokenCollectionNFTGrid";
import { NFTOKEN_ADDRESS } from "../components/account/nftoken/nftoken";
import {
isNFTokenAccount,
parseNFTokenCollectionAccount,
} from "../components/account/nftoken/isNFTokenAccount";
import { isAddressLookupTableAccount } from "components/account/address-lookup-table/types";
import { AddressLookupTableAccountSection } from "components/account/address-lookup-table/AddressLookupTableAccountSection";
import { LookupTableEntriesCard } from "components/account/address-lookup-table/LookupTableEntriesCard";
@ -127,6 +135,13 @@ const TABS_LOOKUP: { [id: string]: Tab[] } = {
path: "/security",
},
],
"nftoken:collection": [
{
slug: "nftoken-collection-nfts",
title: "NFTs",
path: "/nfts",
},
],
"address-lookup-table": [
{
slug: "entries",
@ -194,13 +209,18 @@ export function AccountHeader({
if (isMetaplexNFT(data, mintInfo)) {
return (
<NFTHeader
<MetaplexNFTHeader
nftData={(data as TokenProgramData).nftData!}
address={address}
/>
);
}
const nftokenNFT = account && isNFTokenAccount(account);
if (nftokenNFT && account) {
return <NFTokenAccountHeader account={account} />;
}
if (isToken) {
let token;
let unverified = false;
@ -339,6 +359,8 @@ function InfoSection({ account }: { account: Account }) {
stakeAccountType={data.parsed.type}
/>
);
} else if (account.details?.owner.toBase58() === NFTOKEN_ADDRESS) {
return <NFTokenAccountSection account={account} />;
} else if (data && data.program === "spl-token") {
return <TokenAccountSection account={account} tokenAccount={data.parsed} />;
} else if (data && data.program === "nonce") {
@ -382,6 +404,7 @@ type TabComponent = {
export type MoreTabs =
| "history"
| "tokens"
| "nftoken-collection-nfts"
| "largest"
| "vote-history"
| "slot-hashes"
@ -454,6 +477,13 @@ function MoreSection({
nftData={(account.details?.data as TokenProgramData).nftData!}
/>
)}
{tab === "nftoken-collection-nfts" && (
<Suspense
fallback={<LoadingCard message="Loading NFTs for collection." />}
>
<NFTokenCollectionNFTGrid collection={account.pubkey.toBase58()} />
</Suspense>
)}
{tab === "attributes" && (
<MetaplexNFTAttributesCard
nftData={(account.details?.data as TokenProgramData).nftData!}
@ -529,12 +559,25 @@ function getTabs(pubkey: PublicKey, account: Account): TabComponent[] {
tabs.push(...TABS_LOOKUP[`${programTypeKey}:metaplexNFT`]);
}
const isNFToken = account && isNFTokenAccount(account);
if (isNFToken) {
const collection = parseNFTokenCollectionAccount(account);
if (collection) {
tabs.push({
slug: "nftoken-collection-nfts",
title: "NFTs",
path: "/nftoken-collection-nfts",
});
}
}
if (
!data ||
!(
TOKEN_TABS_HIDDEN.includes(data.program) ||
TOKEN_TABS_HIDDEN.includes(programTypeKey)
)
!isNFToken &&
(!data ||
!(
TOKEN_TABS_HIDDEN.includes(data.program) ||
TOKEN_TABS_HIDDEN.includes(programTypeKey)
))
) {
tabs.push({
slug: "tokens",