From 38a59fa19935717cca875ffc74a32d3fdc10e3aa Mon Sep 17 00:00:00 2001 From: mehditorabiv Date: Wed, 7 Aug 2024 17:45:54 +0300 Subject: [PATCH 01/16] define auth api and use in attestation --- .gitignore | 4 +- package-lock.json | 16 +- package.json | 2 +- src/App.tsx | 2 +- src/interfaces/index.ts | 4 + .../Identifiers/Attestation/Attestation.tsx | 142 +++++++++++------- src/pages/Identifiers/Identifiers.tsx | 47 ++++-- src/router/index.tsx | 2 +- src/services/api/auth/index.ts | 8 + src/{ => services}/api/index.ts | 9 +- src/types/index.ts | 5 + 11 files changed, 156 insertions(+), 85 deletions(-) create mode 100644 src/services/api/auth/index.ts rename src/{ => services}/api/index.ts (76%) create mode 100644 src/types/index.ts diff --git a/.gitignore b/.gitignore index 5f9b8b2..9dde2e1 100644 --- a/.gitignore +++ b/.gitignore @@ -25,4 +25,6 @@ dist-ssr coverage -.env \ No newline at end of file +.env + +/src/contracts/* \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 172f27a..14af867 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "@mui/material": "^5.16.0", "@rainbow-me/rainbowkit": "^2.1.3", "@react-icons/all-files": "^4.1.0", - "@tanstack/react-query": "^5.51.16", + "@tanstack/react-query": "^5.51.21", "@tanstack/react-query-devtools": "^5.50.1", "axios": "^1.7.2", "react": "^18.3.1", @@ -7760,9 +7760,9 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.51.16", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.51.16.tgz", - "integrity": "sha512-zfV+WAtBGm1dUIbL0w/x8qTqVLKU1/Bo1p19J9LF02MmIc4FxzMImMXhFzYJQl5Hx8Wit6RiQ4tB/DvN8y9zaQ==", + "version": "5.51.21", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.51.21.tgz", + "integrity": "sha512-POQxm42IUp6n89kKWF4IZi18v3fxQWFRolvBA6phNVmA8psdfB1MvDnGacCJdS+EOX12w/CyHM62z//rHmYmvw==", "license": "MIT", "funding": { "type": "github", @@ -7779,12 +7779,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.51.16", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.51.16.tgz", - "integrity": "sha512-NZnpJ30zkwaA2ZPhxJLs/qoMbd0yNAj6yyb3JTADJx9HjSdtvnNzOY1bDa3bU1B9CZTBBb7W9E1PpWlNXdgESg==", + "version": "5.51.21", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.51.21.tgz", + "integrity": "sha512-Q/V81x3sAYgCsxjwOkfLXfrmoG+FmDhLeHH5okC/Bp8Aaw2c33lbEo/mMcMnkxUPVtB2FLpzHT0tq3c+OlZEbw==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.51.16" + "@tanstack/query-core": "5.51.21" }, "funding": { "type": "github", diff --git a/package.json b/package.json index 66e4cf7..7f01530 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "@mui/material": "^5.16.0", "@rainbow-me/rainbowkit": "^2.1.3", "@react-icons/all-files": "^4.1.0", - "@tanstack/react-query": "^5.51.16", + "@tanstack/react-query": "^5.51.21", "@tanstack/react-query-devtools": "^5.50.1", "axios": "^1.7.2", "react": "^18.3.1", diff --git a/src/App.tsx b/src/App.tsx index e5b7f43..e82c7b3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,7 +18,7 @@ import { getAddress } from 'viem'; import { createSiweMessage } from 'viem/siwe'; import theme from './libs/theme'; import { router } from './router'; -import { api } from './api'; +import { api } from './services/api'; import { AuthProvider, useAuth } from './context/authContext'; const queryClient = new QueryClient({ diff --git a/src/interfaces/index.ts b/src/interfaces/index.ts index 47f0f0f..d5bfc9b 100644 --- a/src/interfaces/index.ts +++ b/src/interfaces/index.ts @@ -9,3 +9,7 @@ export interface MenuItem { }; children?: MenuItem[]; } + +export interface PlatformAuthenticationParams { + platformType: 'DISCORD' | 'GOOGLE'; +} diff --git a/src/pages/Identifiers/Attestation/Attestation.tsx b/src/pages/Identifiers/Attestation/Attestation.tsx index c73416e..6083d1a 100644 --- a/src/pages/Identifiers/Attestation/Attestation.tsx +++ b/src/pages/Identifiers/Attestation/Attestation.tsx @@ -1,69 +1,101 @@ import { useState } from 'react'; import Paper from '@mui/material/Paper'; -import StepperComponent from '../../../components/shared/CustomStepper'; import Typography from '@mui/material/Typography'; import Button from '@mui/material/Button'; +import { useParams } from 'react-router-dom'; +import StepperComponent from '../../../components/shared/CustomStepper'; +import { platformAuthentication } from '../../../services/api/auth'; const steps = [{ label: 'Auth' }, { label: 'Attest' }, { label: 'Transact' }]; export function Attestation() { - const [activeStep, setActiveStep] = useState(0); + const { provider } = useParams<{ provider: 'DISCORD' | 'GOOGLE' }>(); - const handleNext = () => { - setActiveStep((prevActiveStep) => Math.min(prevActiveStep + 1, steps.length - 1)); - }; + const [activeStep, setActiveStep] = useState(0); - return ( - - -
- {activeStep === 0 && ( -
- - Let’s get started! - - - Please sign in with Discord. - - -
- )} - {activeStep === 1 && ( -
- - Generate an attestation. - - - An attestation is a proof that links your Discord account to your wallet address. - - -
- )} - {activeStep === 2 && ( -
- - Sign Transaction. - - - Signing the transaction will put your attestation on-chain. - - - - This will cost a small amount of gas. - -
- )} -
-
+ const handleAuthorize = () => { + if (!provider) return; + platformAuthentication({ platformType: provider }); + }; + + const handleNext = () => { + setActiveStep((prevActiveStep) => + Math.min(prevActiveStep + 1, steps.length - 1) ); + }; + + return ( + + +
+ {activeStep === 0 && ( +
+ + Let’s get started! + + + Please sign in with {provider}. + + +
+ )} + {activeStep === 1 && ( +
+ + Generate an attestation. + + + An attestation is a proof that links your Discord account to your + wallet address. + + +
+ )} + {activeStep === 2 && ( +
+ + Sign Transaction. + + + Signing the transaction will put your attestation on-chain. + + + + This will cost a small amount of gas. + +
+ )} +
+
+ ); } export default Attestation; diff --git a/src/pages/Identifiers/Identifiers.tsx b/src/pages/Identifiers/Identifiers.tsx index 15870c0..081271c 100644 --- a/src/pages/Identifiers/Identifiers.tsx +++ b/src/pages/Identifiers/Identifiers.tsx @@ -1,22 +1,36 @@ -import { List, ListItem, ListItemText, ListItemSecondaryAction, Button, Typography, Divider, Paper, Box, Avatar } from '@mui/material'; +import { + List, + ListItem, + ListItemText, + ListItemSecondaryAction, + Button, + Typography, + Divider, + Paper, + Box, + Avatar, +} from '@mui/material'; import VerifiedIcon from '@mui/icons-material/Verified'; -import { FaDiscord, FaTelegram, FaGoogle } from 'react-icons/fa'; +import { FaDiscord, FaGoogle } from 'react-icons/fa'; +import { useNavigate } from 'react-router-dom'; const identifiers = [ - { name: 'Discord', icon: FaDiscord, verified: true, color: 'text-blue-500' }, - { name: 'Telegram', icon: FaTelegram, verified: false, color: 'text-blue-400' }, + { name: 'Discord', icon: FaDiscord, verified: false, color: 'text-blue-500' }, { name: 'Google', icon: FaGoogle, verified: false, color: 'text-red-500' }, ]; -const handleRevoke = (identifier: string) => { - console.log(`Revoke attestation for ${identifier}`); -}; +export function Identifiers() { + const navigate = useNavigate(); -const handleConnect = (identifier: string) => { - console.log(`Connect identifier for ${identifier}`); -}; + const handleRevoke = (identifier: string) => { + console.log(`Revoke attestation for ${identifier}`); + }; + + const handleConnect = (identifier: string) => { + console.log(`Connect identifier for ${identifier}`); + navigate(`/identifiers/${identifier.toLowerCase()}/attestation`); + }; -export function Identifiers() { return (
@@ -34,15 +48,20 @@ export function Identifiers() { {identifiers.map((identifier, index) => ( - + - + - {identifier.verified && } + {identifier.verified && ( + + )} {identifier.name}
} diff --git a/src/router/index.tsx b/src/router/index.tsx index 491d1f6..d0ccdf9 100644 --- a/src/router/index.tsx +++ b/src/router/index.tsx @@ -36,7 +36,7 @@ export const router = createBrowserRouter([ ), }, { - path: '/attestation', + path: 'identifiers/:provider/attestation', element: ( diff --git a/src/services/api/auth/index.ts b/src/services/api/auth/index.ts new file mode 100644 index 0000000..3b0fdab --- /dev/null +++ b/src/services/api/auth/index.ts @@ -0,0 +1,8 @@ +import { baseURL } from '..'; +import { PlatformAuthenticationParams } from '@/interfaces'; + +export const platformAuthentication = async ({ + platformType, +}: PlatformAuthenticationParams) => { + window.location.replace(`${baseURL}auth/${platformType}/authenticate`); +}; diff --git a/src/api/index.ts b/src/services/api/index.ts similarity index 76% rename from src/api/index.ts rename to src/services/api/index.ts index 7cbd484..8147533 100644 --- a/src/api/index.ts +++ b/src/services/api/index.ts @@ -1,6 +1,6 @@ import axios from 'axios'; -const baseURL = import.meta.env.VITE_API_BASE_URL; +export const baseURL = import.meta.env.VITE_API_BASE_URL; if (!baseURL) { throw new Error( @@ -8,14 +8,14 @@ if (!baseURL) { ); } -export const api = axios.create({ +const apiInstance = axios.create({ baseURL, headers: { 'Content-Type': 'application/json', }, }); -api.interceptors.request.use( +apiInstance.interceptors.request.use( // eslint-disable-next-line @typescript-eslint/no-explicit-any (config: any) => { const token = localStorage.getItem('OCI_TOKEN'); @@ -35,4 +35,5 @@ api.interceptors.request.use( } ); -export default api; +export default apiInstance; +export { apiInstance as api }; diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..bb3191f --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,5 @@ +export interface ApiResponse { + data: T; + success: boolean; + error?: string; +} From 154b79c49bd5a9801bb48454481f43b66b1c59df Mon Sep 17 00:00:00 2001 From: mehditorabiv Date: Wed, 7 Aug 2024 18:00:42 +0300 Subject: [PATCH 02/16] add callback --- package-lock.json | 10 +++++ package.json | 1 + src/pages/Callback/Callback.tsx | 73 +++++++++++++++++++++++++++++++++ src/pages/Callback/index.ts | 3 ++ src/router/index.tsx | 5 +++ 5 files changed, 92 insertions(+) create mode 100644 src/pages/Callback/Callback.tsx create mode 100644 src/pages/Callback/index.ts diff --git a/package-lock.json b/package-lock.json index 14af867..2042c13 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@tanstack/react-query": "^5.51.21", "@tanstack/react-query-devtools": "^5.50.1", "axios": "^1.7.2", + "jwt-decode": "^4.0.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-hook-form": "^7.52.1", @@ -15078,6 +15079,15 @@ "node": ">=4.0" } }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/keccak": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/keccak/-/keccak-3.0.4.tgz", diff --git a/package.json b/package.json index 7f01530..32029ef 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@tanstack/react-query": "^5.51.21", "@tanstack/react-query-devtools": "^5.50.1", "axios": "^1.7.2", + "jwt-decode": "^4.0.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-hook-form": "^7.52.1", diff --git a/src/pages/Callback/Callback.tsx b/src/pages/Callback/Callback.tsx new file mode 100644 index 0000000..945e940 --- /dev/null +++ b/src/pages/Callback/Callback.tsx @@ -0,0 +1,73 @@ +import { useEffect } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { jwtDecode } from 'jwt-decode'; +import { Backdrop, CircularProgress } from '@mui/material'; + +interface DecodedJwt { + exp: number; + iat: number; + provider: string; + sub: string; +} + +const useQueryParams = () => { + const { search } = useLocation(); + return new URLSearchParams(search); +}; + +const isJwt = (token: string): boolean => { + const jwtRegex = /^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/; + return jwtRegex.test(token); +}; + +const storeTokens = ( + tokens: Array<{ token: string; exp: number; provider: string }> +) => { + localStorage.setItem('OCI_PROVIDER_TOKENS', JSON.stringify(tokens)); + console.log('Stored OCI_PROVIDER_TOKENS in localStorage'); +}; + +export function Callback() { + const queryParams = useQueryParams(); + const jwtArray = queryParams.getAll('jwt'); + const navigate = useNavigate(); + + useEffect(() => { + if (jwtArray.length > 0) { + const storedTokens: Array<{ + token: string; + exp: number; + provider: string; + }> = []; + + jwtArray.forEach((jwt) => { + if (isJwt(jwt)) { + try { + const decodedJwt: DecodedJwt = jwtDecode(jwt); + const { provider, exp } = decodedJwt; + storedTokens.push({ token: jwt, exp, provider }); + + // Redirect to the current JWT provider route + navigate(`/identifiers/${provider}/attestation?jwt=${jwt}`); + } catch (error) { + console.error('Invalid JWT:', error); + } + } else { + console.error('Invalid JWT format:', jwt); + } + }); + + storeTokens(storedTokens); + } else { + console.error('No JWTs found in query parameters'); + } + }, [jwtArray, navigate]); + + return ( + + + + ); +} + +export default Callback; diff --git a/src/pages/Callback/index.ts b/src/pages/Callback/index.ts new file mode 100644 index 0000000..4ab3846 --- /dev/null +++ b/src/pages/Callback/index.ts @@ -0,0 +1,3 @@ +import { Callback } from './Callback'; + +export default Callback; diff --git a/src/router/index.tsx b/src/router/index.tsx index d0ccdf9..c97f4b4 100644 --- a/src/router/index.tsx +++ b/src/router/index.tsx @@ -5,6 +5,7 @@ import Dashboard from '../pages/Dashboard'; import Identifiers from '../pages/Identifiers'; import Permissions from '../pages/Permissions'; import Attestation from '../pages/Identifiers/Attestation'; +import Callback from '../pages/Callback'; import DefaultLayout from '../layouts/DefaultLayout'; import ProtectedRoute from '../ProtectedRoute'; @@ -53,6 +54,10 @@ export const router = createBrowserRouter([ }, ], }, + { + path: '/callback', + element: , + }, { path: '*', element:
Not found
, From 1b5dbda3f66b25ac919b17ebcf3a98550ecb47fd Mon Sep 17 00:00:00 2001 From: mehditorabiv Date: Wed, 7 Aug 2024 18:05:46 +0300 Subject: [PATCH 03/16] fix callback page issue --- src/pages/Callback/Callback.tsx | 58 +++++++++++++++++++-------------- 1 file changed, 33 insertions(+), 25 deletions(-) diff --git a/src/pages/Callback/Callback.tsx b/src/pages/Callback/Callback.tsx index 945e940..1adfeb3 100644 --- a/src/pages/Callback/Callback.tsx +++ b/src/pages/Callback/Callback.tsx @@ -20,6 +20,15 @@ const isJwt = (token: string): boolean => { return jwtRegex.test(token); }; +const getStoredTokens = (): Array<{ + token: string; + exp: number; + provider: string; +}> => { + const storedTokens = localStorage.getItem('OCI_PROVIDER_TOKENS'); + return storedTokens ? JSON.parse(storedTokens) : []; +}; + const storeTokens = ( tokens: Array<{ token: string; exp: number; provider: string }> ) => { @@ -29,39 +38,38 @@ const storeTokens = ( export function Callback() { const queryParams = useQueryParams(); - const jwtArray = queryParams.getAll('jwt'); + const jwt = queryParams.get('jwt'); const navigate = useNavigate(); useEffect(() => { - if (jwtArray.length > 0) { - const storedTokens: Array<{ - token: string; - exp: number; - provider: string; - }> = []; + if (jwt && isJwt(jwt)) { + try { + const decodedJwt: DecodedJwt = jwtDecode(jwt); + const { provider, exp } = decodedJwt; + + // Retrieve existing tokens from localStorage + const storedTokens = getStoredTokens(); + + // Remove any existing token for the current provider + const updatedTokens = storedTokens.filter( + (token) => token.provider !== provider + ); - jwtArray.forEach((jwt) => { - if (isJwt(jwt)) { - try { - const decodedJwt: DecodedJwt = jwtDecode(jwt); - const { provider, exp } = decodedJwt; - storedTokens.push({ token: jwt, exp, provider }); + // Add the new token + updatedTokens.push({ token: jwt, exp, provider }); - // Redirect to the current JWT provider route - navigate(`/identifiers/${provider}/attestation?jwt=${jwt}`); - } catch (error) { - console.error('Invalid JWT:', error); - } - } else { - console.error('Invalid JWT format:', jwt); - } - }); + // Store updated tokens back to localStorage + storeTokens(updatedTokens); - storeTokens(storedTokens); + // Redirect to the current JWT provider route + navigate(`/identifiers/${provider}/attestation?jwt=${jwt}`); + } catch (error) { + console.error('Invalid JWT:', error); + } } else { - console.error('No JWTs found in query parameters'); + console.error('No valid JWT found in query parameters'); } - }, [jwtArray, navigate]); + }, [jwt, navigate]); return ( From 3b17df6cbbadd46f328eb832c405c88c78e4a437 Mon Sep 17 00:00:00 2001 From: mehditorabiv Date: Wed, 7 Aug 2024 18:06:37 +0300 Subject: [PATCH 04/16] update else state --- src/pages/Callback/Callback.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/pages/Callback/Callback.tsx b/src/pages/Callback/Callback.tsx index 1adfeb3..a0e1f20 100644 --- a/src/pages/Callback/Callback.tsx +++ b/src/pages/Callback/Callback.tsx @@ -33,7 +33,6 @@ const storeTokens = ( tokens: Array<{ token: string; exp: number; provider: string }> ) => { localStorage.setItem('OCI_PROVIDER_TOKENS', JSON.stringify(tokens)); - console.log('Stored OCI_PROVIDER_TOKENS in localStorage'); }; export function Callback() { @@ -67,7 +66,7 @@ export function Callback() { console.error('Invalid JWT:', error); } } else { - console.error('No valid JWT found in query parameters'); + navigate('/identifiers'); } }, [jwt, navigate]); From f25dd6ca9075c3d03f781ec0c4fa4cae0a91df36 Mon Sep 17 00:00:00 2001 From: mehditorabiv Date: Thu, 8 Aug 2024 11:29:02 +0300 Subject: [PATCH 05/16] setup attestation --- .../Identifiers/Attestation/Attestation.tsx | 87 +++++++++++++++++-- src/services/api/linking/index.ts | 15 ++++ src/services/api/linking/query.ts | 19 ++++ 3 files changed, 112 insertions(+), 9 deletions(-) create mode 100644 src/services/api/linking/index.ts create mode 100644 src/services/api/linking/query.ts diff --git a/src/pages/Identifiers/Attestation/Attestation.tsx b/src/pages/Identifiers/Attestation/Attestation.tsx index 6083d1a..83081ec 100644 --- a/src/pages/Identifiers/Attestation/Attestation.tsx +++ b/src/pages/Identifiers/Attestation/Attestation.tsx @@ -1,27 +1,96 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import Paper from '@mui/material/Paper'; import Typography from '@mui/material/Typography'; import Button from '@mui/material/Button'; -import { useParams } from 'react-router-dom'; +import { useLocation, useNavigate, useParams } from 'react-router-dom'; +import { jwtDecode } from 'jwt-decode'; import StepperComponent from '../../../components/shared/CustomStepper'; import { platformAuthentication } from '../../../services/api/auth'; +import { useLinkIdentifierMutation } from '../../../services/api/linking/query'; const steps = [{ label: 'Auth' }, { label: 'Attest' }, { label: 'Transact' }]; +type Provider = 'DISCORD' | 'GOOGLE'; +type Token = { token: string; exp: number; provider: Provider }; +type DecodedToken = { provider: Provider; iat: number; exp: number }; + export function Attestation() { const { provider } = useParams<{ provider: 'DISCORD' | 'GOOGLE' }>(); - + const location = useLocation(); + const navigate = useNavigate(); + const { mutate: mutateIdentifier, data } = useLinkIdentifierMutation(); const [activeStep, setActiveStep] = useState(0); + const handleNext = () => { + setActiveStep((prevActiveStep) => + Math.min(prevActiveStep + 1, steps.length - 1) + ); + }; + + useEffect(() => { + if (data) { + handleNext(); + } + }, [data]); + + useEffect(() => { + const searchParams = new URLSearchParams(location.search); + const jwtToken = searchParams.get('jwt'); + + if (jwtToken) { + try { + const decoded: DecodedToken = jwtDecode(jwtToken); + const { provider: jwtProvider } = decoded; + + const existingTokens: Token[] = JSON.parse( + localStorage.getItem('OCI_PROVIDER_TOKENS') || '[]' + ); + const updatedTokens = existingTokens.filter( + (token) => token.provider !== jwtProvider + ); + + updatedTokens.push({ + token: jwtToken, + exp: decoded.exp, + provider: jwtProvider, + }); + localStorage.setItem( + 'OCI_PROVIDER_TOKENS', + JSON.stringify(updatedTokens) + ); + + navigate(location.pathname, { replace: true }); + + setActiveStep(1); + } catch (error) { + console.error('Invalid JWT token:', error); + } + } + }, [location.search, location.pathname, navigate]); + const handleAuthorize = () => { if (!provider) return; platformAuthentication({ platformType: provider }); }; - const handleNext = () => { - setActiveStep((prevActiveStep) => - Math.min(prevActiveStep + 1, steps.length - 1) + const getTokenForProvider = (jwtProvider: string) => { + const tokens = + JSON.parse(localStorage.getItem('OCI_PROVIDER_TOKENS') || '') || []; + const tokenObject = tokens.find( + (token: { provider: string }) => + token.provider.toLowerCase() === jwtProvider.toLowerCase() ); + return tokenObject ? tokenObject.token : null; + }; + + const handleLinkIdentifier = async () => { + const siweJwt = localStorage.getItem('OCI_TOKEN'); + if (!siweJwt || !provider) return; + const anyJwt = getTokenForProvider(provider); + mutateIdentifier({ + siweJwt, + anyJwt, + }); }; return ( @@ -59,13 +128,13 @@ export function Attestation() { Generate an attestation. - An attestation is a proof that links your Discord account to your - wallet address. + An attestation is a proof that links your {provider} account to + your wallet address.