From 909374a3ee926430b32cf5ac80560335c7994bba Mon Sep 17 00:00:00 2001 From: geel <102362474+0xGeel@users.noreply.github.com> Date: Fri, 14 Apr 2023 14:42:02 +0200 Subject: [PATCH] Feature: Add rate limiter to Loopring API requests (#20) **TL;DR:** - Introducing a Rate Limiter for requests made to the Loopring API. - Updated the error messages, and added a to notify end-users of errors. **Why?** - Hitting the API Rate Limit caused a bug for users that connected with a wallet that contained a lot (800+) NFTs. This caused the Loopring API requests to return a HTTP 429 error, resulting in not all NFTs being included in the access verification. --- package-lock.json | 37 ++++++++++++++++++- package.json | 8 ++-- .../ConnectedPage/ConnectedPage.tsx | 9 +++-- src/pages/_app.tsx | 15 ++++++++ src/pages/api/getUserNfts.ts | 14 +++++-- src/pages/api/getUserUnlocks.ts | 24 ++++++++---- src/utils/loopring/getAllUserNftIds.ts | 10 ++++- 7 files changed, 96 insertions(+), 21 deletions(-) diff --git a/package-lock.json b/package-lock.json index bbba49bec..b98455f27 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "loopgate", - "version": "0.2.0", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "loopgate", - "version": "0.2.0", + "version": "0.2.1", "license": "BSD-2-Clause", "dependencies": { "@heroicons/react": "^2.0.13", @@ -23,6 +23,7 @@ "@radix-ui/react-tabs": "^1.0.2", "@radix-ui/react-tooltip": "^1.0.3", "axios": "^1.2.3", + "axios-rate-limit": "^1.3.0", "class-variance-authority": "^0.4.0", "clsx": "^1.2.1", "connectkit": "^1.1.1", @@ -32,6 +33,7 @@ "pinata-submarine": "^0.1.6", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-hot-toast": "^2.4.0", "siwe": "^2.1.3", "tailwind-merge": "^1.9.0", "tailwindcss-animate": "^1.0.5", @@ -5525,6 +5527,14 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/axios-rate-limit": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/axios-rate-limit/-/axios-rate-limit-1.3.0.tgz", + "integrity": "sha512-cKR5wTbU/CeeyF1xVl5hl6FlYsmzDVqxlN4rGtfO5x7J83UxKDckudsW0yW21/ZJRcO0Qrfm3fUFbhEbWTLayw==", + "peerDependencies": { + "axios": "*" + } + }, "node_modules/axobject-query": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.1.1.tgz", @@ -8410,6 +8420,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/goober": { + "version": "2.1.12", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.12.tgz", + "integrity": "sha512-yXHAvO08FU1JgTXX6Zn6sYCUFfB/OJSX8HHjDSgerZHZmFKAb08cykp5LBw5QnmyMcZyPRMqkdyHUSSzge788Q==", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, "node_modules/gopd": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", @@ -12159,6 +12177,21 @@ "react": "^18.2.0" } }, + "node_modules/react-hot-toast": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.4.0.tgz", + "integrity": "sha512-qnnVbXropKuwUpriVVosgo8QrB+IaPJCpL8oBI6Ov84uvHZ5QQcTp2qg6ku2wNfgJl6rlQXJIQU5q+5lmPOutA==", + "dependencies": { + "goober": "^2.1.10" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", diff --git a/package.json b/package.json index d05996b94..0b2e848e5 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,11 @@ { "name": "loopgate", - "version": "0.2.0", + "version": "0.2.1", "description": "Easily Token-Gate Content using Loopring Layer-2 NFTs and Pinata", "author": "Geel <@0xGeel>", "license": "BSD-2-Clause", - "repository": "https://github.com/0xGeel/loopring-token-gating.git", - "bugs": "https://github.com/0xGeel/loopring-token-gating/issues", + "repository": "https://github.com/0xGeel/loopgate.git", + "bugs": "https://github.com/0xGeel/loopgate/issues", "homepage": "https://loopgate.netlify.app", "scripts": { "dev": "next dev", @@ -30,6 +30,7 @@ "@radix-ui/react-tabs": "^1.0.2", "@radix-ui/react-tooltip": "^1.0.3", "axios": "^1.2.3", + "axios-rate-limit": "^1.3.0", "class-variance-authority": "^0.4.0", "clsx": "^1.2.1", "connectkit": "^1.1.1", @@ -39,6 +40,7 @@ "pinata-submarine": "^0.1.6", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-hot-toast": "^2.4.0", "siwe": "^2.1.3", "tailwind-merge": "^1.9.0", "tailwindcss-animate": "^1.0.5", diff --git a/src/components/ConnectedPage/ConnectedPage.tsx b/src/components/ConnectedPage/ConnectedPage.tsx index 9f8367f1c..1cb0ee1d0 100644 --- a/src/components/ConnectedPage/ConnectedPage.tsx +++ b/src/components/ConnectedPage/ConnectedPage.tsx @@ -5,6 +5,7 @@ import UnlockLink from "./UnlockLink"; import Spinner from "../Spinner"; import { useAccount } from "wagmi"; import axios from "axios"; +import toast from "react-hot-toast"; const ConnectedPage = () => { const { address } = useAccount(); @@ -19,15 +20,15 @@ const ConnectedPage = () => { setIsLoading(false); }) .catch((error) => { - console.log("Whoops. Something went wrong." + error); + toast.error(error.request.response); setIsLoading(false); }); }; // On render: make API calls to determine NFT holdings - // 1.: GET user's Loopring ID (Loopring API) ✅ - // 2.: GET user's NFTs (Loopring API) ✅ - // 3.: Check config to compare NFTs and unlocks ✅ + // 1.: GET user's Loopring ID (Loopring API) + // 2.: GET user's NFTs (Loopring API) + // 3.: Check config to compare NFTs and unlocks // 4.: GET submarined content (Pinata API) useEffect(() => { diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 7cf07224a..f894a1807 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -9,6 +9,7 @@ import { siwe } from "../utils/siwe"; import { overrides } from "../styles/ConnectKit/overrides"; import NextHeadBase from "../components/SEO/NextHeadBase"; import { inter, unbounded } from "../components/Fonts/Fonts"; +import { Toaster } from "react-hot-toast"; const App = ({ Component, pageProps }: AppProps) => { const [mounted, setMounted] = useState(false); @@ -27,6 +28,20 @@ const App = ({ Component, pageProps }: AppProps) => {
{mounted && } +
diff --git a/src/pages/api/getUserNfts.ts b/src/pages/api/getUserNfts.ts index c262ba402..79c9a0342 100644 --- a/src/pages/api/getUserNfts.ts +++ b/src/pages/api/getUserNfts.ts @@ -8,7 +8,11 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { if (!accountId || Array.isArray(accountId[0])) { // Check if multiple or no Account IDs are specified. If so: early return. - return res.status(400).json({ error: "No Loopring Account ID specified." }); + return res + .status(400) + .send( + "Invalid Request: 0x address not provided. Please provide a valid 0x address and try again." + ); } // Call Loopring API to find- and extract all user NFT IDs @@ -16,9 +20,11 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { return allNftIds ? res.status(200).json(allNftIds) - : res.status(400).json({ - error: "Unable to find any NFTs for the supplied Loopring Account ID", - }); + : res + .status(400) + .send( + "Invalid Request: Unable to find any NFTs for the specified 0x address." + ); }; export default handler; diff --git a/src/pages/api/getUserUnlocks.ts b/src/pages/api/getUserUnlocks.ts index ad0562772..6022f7ab0 100644 --- a/src/pages/api/getUserUnlocks.ts +++ b/src/pages/api/getUserUnlocks.ts @@ -16,21 +16,31 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { const siweSesh = await siwe.getSession(req, res); if (!address || Array.isArray(address)) { - return res.status(400).json({ error: "No 0x address specified." }); + return res + .status(400) + .send( + "0x address not provided. Please provide a valid 0x address and try again." + ); } // Check if there is a session. Only connected users may call this endpoint. if (!siweSesh.address || siweSesh.address !== address) { - return res.status(405).send({ message: "What are ye doin' in my swamp?!" }); + return res + .status(401) + .send( + "You are not authorized to access this resource. Sign In With Ethereum, and try again." + ); } // 1️⃣ Call the Loopring API to find the User's Loopring Account ID const accountId = await getUserAddress(address); if (!accountId) { - return res.status(400).json({ - error: "Could not find Loopring Account for the specified 0x address", - }); + return res + .status(400) + .send( + "No Loopring Account could be found for the connected 0x address. Is your L2 account activated?" + ); } // 2️⃣ Call the Loopring API to find the NFTs held by the user @@ -38,8 +48,8 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { if (!allNftIds) { return res - .status(400) - .json({ error: "Unable to find any NFTs for the specified 0x address" }); + .status(404) + .send("Unable to find any NFTs for the specified 0x address."); } // 3️⃣ Check the user's NFT IDs against the config.ts to determine unlocks diff --git a/src/utils/loopring/getAllUserNftIds.ts b/src/utils/loopring/getAllUserNftIds.ts index 0c9d0e07e..e02aad5df 100644 --- a/src/utils/loopring/getAllUserNftIds.ts +++ b/src/utils/loopring/getAllUserNftIds.ts @@ -1,7 +1,15 @@ import axios from "axios"; +import rateLimit from "axios-rate-limit"; import { API } from "./_constants"; import { headerOpts, extractNfts } from "./index"; +// Loopring API accepts 5 requests per second, max. +// Although this seems to fluctuate. 20 / sec +const rateLimitedAxios = rateLimit(axios.create(), { + maxRequests: 10, + perMilliseconds: 1000, +}); + const getAllUserNftIds = async (accountId: string | string[]) => { // Gets all Loopring L2 NFTs for a specified Account ID const LIMIT = 50; // API can handle up to 50 per call @@ -32,7 +40,7 @@ const getAllUserNftIds = async (accountId: string | string[]) => { // Call the API for all of these ^ const followUpReqs = await Promise.all( amountOfCalls.map(async (index) => { - return await axios.get( + return await rateLimitedAxios.get( `${API.USER_NFT_BALANCE}?accountId=${accountId}&limit=${LIMIT}&offset=${ LIMIT * index }`,