diff --git a/.gitignore b/.gitignore index 124358b46f..e2bf6fbb7a 100644 --- a/.gitignore +++ b/.gitignore @@ -19,7 +19,7 @@ log-*.txt log-*/ # intellij files -/.idea/ +.idea/ /solana.iml /.vscode/ diff --git a/explorer/package-lock.json b/explorer/package-lock.json index 306d8d30b4..f950512709 100644 --- a/explorer/package-lock.json +++ b/explorer/package-lock.json @@ -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", diff --git a/explorer/package.json b/explorer/package.json index 6d6d229cd1..2360796e04 100644 --- a/explorer/package.json +++ b/explorer/package.json @@ -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": { diff --git a/explorer/src/__tests__/parseNFTokenAccounts.ts b/explorer/src/__tests__/parseNFTokenAccounts.ts new file mode 100644 index 0000000000..4b6fa1b800 --- /dev/null +++ b/explorer/src/__tests__/parseNFTokenAccounts.ts @@ -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" + ); + }); +}); diff --git a/explorer/src/components/account/MetaplexNFTHeader.tsx b/explorer/src/components/account/MetaplexNFTHeader.tsx index f1a6329423..d19c17ca62 100644 --- a/explorer/src/components/account/MetaplexNFTHeader.tsx +++ b/explorer/src/components/account/MetaplexNFTHeader.tsx @@ -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, }: { diff --git a/explorer/src/components/account/nftoken/NFTokenAccountHeader.tsx b/explorer/src/components/account/nftoken/NFTokenAccountHeader.tsx new file mode 100644 index 0000000000..48dc9d8779 --- /dev/null +++ b/explorer/src/components/account/nftoken/NFTokenAccountHeader.tsx @@ -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 ( + Loading...}> + + + ); + } + + const collection = parseNFTokenCollectionAccount(account); + if (collection) { + return ( + Loading...}> + + + ); + } + + return ( + <> +
Details
+

Account

+ + ); +} + +export function NFTokenNFTHeader({ nft }: { nft: NftokenTypes.NftAccount }) { + const { data: metadata } = useNftokenMetadata(nft.metadata_url); + + return ( +
+
+ +
+ +
+ {
NFToken NFT
} +
+

+ {metadata ? metadata.name || "No NFT name was found" : "Loading..."} +

+
+ +
+
+ {`${ + nft.authority_can_update ? "Mutable" : "Immutable" + }`} + + +
+
+
+
+ ); +} + +export function NFTokenCollectionHeader({ + collection, +}: { + collection: NftokenTypes.CollectionAccount; +}) { + const { data: metadata } = useNftokenMetadata(collection.metadata_url); + + return ( +
+
+ +
+ +
+ {
NFToken Collection
} +
+

+ {metadata + ? metadata.name || "No collection name was found" + : "Loading..."} +

+
+ +
+
+ {`${ + collection.authority_can_update ? "Mutable" : "Immutable" + }`} + + +
+
+
+
+ ); +} diff --git a/explorer/src/components/account/nftoken/NFTokenAccountSection.tsx b/explorer/src/components/account/nftoken/NFTokenAccountSection.tsx new file mode 100644 index 0000000000..2ff35eea4a --- /dev/null +++ b/explorer/src/components/account/nftoken/NFTokenAccountSection.tsx @@ -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 ; + } + + const collection = parseNFTokenCollectionAccount(account); + if (collection) { + return ; + } + + return ; +} + +const NFTCard = ({ nft }: { nft: NftokenTypes.NftAccount }) => { + const fetchInfo = useFetchAccountInfo(); + const refresh = () => fetchInfo(new PublicKey(nft.address)); + + return ( +
+
+

+ Overview +

+ +
+ + + + Address + +
+ + + + Authority + +
+ + + + Holder + +
+ + + + Delegate + + {nft.delegate ? ( +
+ ) : ( + "Not Delegated" + )} + + + + Collection + + {nft.collection ? ( +
+ ) : ( + "No Collection" + )} + + + +
+ ); +}; + +export const NftokenImage = ({ + url, + size, +}: { + url: string | undefined; + size: number; +}) => { + const [isLoading, setIsLoading] = useState(true); + const [showError, setShowError] = useState(false); + const [timeout, setTimeout] = useState(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 ? ( +
+ ) : ( + <> + {isLoading && ( +
+ )} +
+ {"nft"} { + setIsLoading(false); + }} + onError={() => { + setShowError(true); + }} + /> +
+ + )} + + ); +}; + +const CollectionCard = ({ + collection, +}: { + collection: NftokenTypes.CollectionAccount; +}) => { + const fetchInfo = useFetchAccountInfo(); + const refresh = () => fetchInfo(new PublicKey(collection.address)); + + return ( +
+
+

+ Overview +

+ +
+ + + + Address + +
+ + + + Authority + +
+ + + + Number of NFTs + + Loading...
}> + + + + + +
+ ); +}; + +const NumNfts = ({ collection }: { collection: string }) => { + const { data: nfts } = useCollectionNfts({ collectionAddress: collection }); + return
{nfts.length}
; +}; diff --git a/explorer/src/components/account/nftoken/NFTokenCollectionNFTGrid.tsx b/explorer/src/components/account/nftoken/NFTokenCollectionNFTGrid.tsx new file mode 100644 index 0000000000..464aa1e6a0 --- /dev/null +++ b/explorer/src/components/account/nftoken/NFTokenCollectionNFTGrid.tsx @@ -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 ( +
+
+

NFTs

+ + +
+ +
+ {nfts.length === 0 &&
No NFTs Found
} + + {nfts.length > 0 && ( +
+ {nfts.map((nft) => ( +
+ + +
+ +
{nft.name ?? "No Name"}
+ +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/explorer/src/components/account/nftoken/README.md b/explorer/src/components/account/nftoken/README.md new file mode 100644 index 0000000000..87aa582f92 --- /dev/null +++ b/explorer/src/components/account/nftoken/README.md @@ -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) diff --git a/explorer/src/components/account/nftoken/isNFTokenAccount.ts b/explorer/src/components/account/nftoken/isNFTokenAccount.ts new file mode 100644 index 0000000000..cd37bd8a92 --- /dev/null +++ b/explorer/src/components/account/nftoken/isNFTokenAccount.ts @@ -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; + } +}; diff --git a/explorer/src/components/account/nftoken/nftoken-hooks.tsx b/explorer/src/components/account/nftoken/nftoken-hooks.tsx new file mode 100644 index 0000000000..b7a9d67d87 --- /dev/null +++ b/explorer/src/components/account/nftoken/nftoken-hooks.tsx @@ -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["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["mutate"]; +} => { + const swrKey = [metadataUrl]; + const { data, error, mutate } = useSWR(swrKey, getMetadataFetcher, { + suspense: true, + }); + // Not nullable since we use suspense + return { data: data!, error, mutate }; +}; diff --git a/explorer/src/components/account/nftoken/nftoken-types.ts b/explorer/src/components/account/nftoken/nftoken-types.ts new file mode 100644 index 0000000000..ab676bd040 --- /dev/null +++ b/explorer/src/components/account/nftoken/nftoken-types.ts @@ -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; + + 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"), + ]); +} diff --git a/explorer/src/components/account/nftoken/nftoken.ts b/explorer/src/components/account/nftoken/nftoken.ts new file mode 100644 index 0000000000..d6c23f3c67 --- /dev/null +++ b/explorer/src/components/account/nftoken/nftoken.ts @@ -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 => { + 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 => { + if (!url) { + return null; + } + + const metadataMap = await getMetadataMap({ + urls: [url], + }); + return metadataMap.get(url) ?? null; + }; + + export const getMetadataMap = async ({ + urls: _urls, + }: { + urls: Array; + }): Promise> => { + const urls = Array.from( + new Set(_urls.filter((url): url is string => Boolean(url))) + ); + + const metadataMap = new Map(); + + 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; + }; +} diff --git a/explorer/src/components/common/NFTArt.tsx b/explorer/src/components/common/NFTArt.tsx index eb58033618..e31e749a12 100644 --- a/explorer/src/components/common/NFTArt.tsx +++ b/explorer/src/components/common/NFTArt.tsx @@ -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 = () => ( { ); }; -const CachedImageContent = ({ uri }: { uri?: string }) => { +export const CachedImageContent = ({ uri }: { uri?: string }) => { const [isLoading, setIsLoading] = useState(true); const [showError, setShowError] = useState(false); const [timeout, setTimeout] = useState(undefined); diff --git a/explorer/src/pages/AccountDetailsPage.tsx b/explorer/src/pages/AccountDetailsPage.tsx index 1e69cc3d39..5d66ee2ba1 100644 --- a/explorer/src/pages/AccountDetailsPage.tsx +++ b/explorer/src/pages/AccountDetailsPage.tsx @@ -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 ( - ); } + const nftokenNFT = account && isNFTokenAccount(account); + if (nftokenNFT && account) { + return ; + } + 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 ; } else if (data && data.program === "spl-token") { return ; } 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" && ( + } + > + + + )} {tab === "attributes" && (