From 868dc63f8cbe5f9a79213ffc4c650950e1c32e43 Mon Sep 17 00:00:00 2001 From: Iris Date: Wed, 14 Aug 2024 18:58:57 +0200 Subject: [PATCH] feat: add claim X & 2FA tickets and update partners wallets (#15) * feat: add claim 2FA & X tickets * feat: update partner wallets * fix: build error * fix: show claim x ticket on mobile * fix: build error * fix: add suspense for search params * fix: unused imports * fix: window undefined * fix: changes in api * fix: claim x & 2FA tickets * fix: build errors * fix: bugs * fix: add notif on claim successful * fix: description modal * fix: remove logs * fix: btn centered + twitter process from start if we close * fix: X notif --- app/components/NotifXTicket.tsx | 54 +++++++ app/components/claimXTicket.tsx | 96 +++++++++++++ app/components/connection/starknetConnect.tsx | 2 +- app/components/extraClickModal.tsx | 135 ++++++++++++++++++ app/components/tryAgainModal.tsx | 66 ++++++--- app/components/welcomeModal.tsx | 63 +++++--- app/page.tsx | 116 +++++++++++++-- constants/types.ts | 10 ++ hooks/getWalletType.tsx | 45 ++++++ hooks/hasArgent.tsx | 27 ---- hooks/hasStarknetWallets.tsx | 60 ++++++++ hooks/isDeployed.tsx | 3 +- public/visuals/2FA.svg | 3 + services/apiService.ts | 57 ++++++-- services/localStorageService.ts | 37 +++++ utils/codeChallenge.ts | 42 ++++++ utils/starknetConnectorsWrapper.ts | 16 +-- utils/stringService.ts | 12 ++ 18 files changed, 746 insertions(+), 98 deletions(-) create mode 100644 app/components/NotifXTicket.tsx create mode 100644 app/components/claimXTicket.tsx create mode 100644 app/components/extraClickModal.tsx create mode 100644 hooks/getWalletType.tsx delete mode 100644 hooks/hasArgent.tsx create mode 100644 hooks/hasStarknetWallets.tsx create mode 100644 public/visuals/2FA.svg create mode 100644 utils/codeChallenge.ts diff --git a/app/components/NotifXTicket.tsx b/app/components/NotifXTicket.tsx new file mode 100644 index 0000000..0069e01 --- /dev/null +++ b/app/components/NotifXTicket.tsx @@ -0,0 +1,54 @@ +"use client"; + +import React, { FunctionComponent, useEffect, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { storeHasClaimedXTicket } from "@/services/localStorageService"; +import Notification from "./notification"; + +type NotifXTicketProps = { + hasClaimedX?: boolean; + setHasClaimedX: (hasClaimedX: boolean) => void; +}; + +const NotifXTicket: FunctionComponent = ({ + hasClaimedX, + setHasClaimedX, +}) => { + const searchParams = useSearchParams(); + const claimXStatus = searchParams.get("success"); + const claimXError = searchParams.get("error_msg"); + const [showErrorMsg, setShowErrorMsg] = useState(false); + const [errorMsg, setErrorMsg] = useState(""); + + useEffect(() => { + if (!claimXStatus) return; + if (claimXStatus === "true") { + if (!hasClaimedX) { + storeHasClaimedXTicket(); + setHasClaimedX(true); + } + } else if (claimXStatus === "false" && claimXError) { + // show error message + if ((claimXError as string).includes("already claimed")) { + setHasClaimedX(true); + } + setErrorMsg(claimXError); + setShowErrorMsg(true); + } + }, [claimXStatus, claimXError]); + + const closeErrorMsg = () => { + setShowErrorMsg(false); + setErrorMsg(""); + }; + + return ( + <> + + <>{errorMsg} + + + ); +}; + +export default NotifXTicket; diff --git a/app/components/claimXTicket.tsx b/app/components/claimXTicket.tsx new file mode 100644 index 0000000..ff25869 --- /dev/null +++ b/app/components/claimXTicket.tsx @@ -0,0 +1,96 @@ +"use client"; + +import React, { FunctionComponent, useEffect, useState } from "react"; +import Button from "./button"; +import { getExtraTicket } from "@/utils/codeChallenge"; +import styles from "../styles/components/stats.module.css"; +import { useSearchParams } from "next/navigation"; +import { storeHasClaimedXTicket } from "@/services/localStorageService"; +import Notification from "./notification"; + +type ClaimXTicketProps = { + address?: string; + isConnected: boolean; + isFinished: boolean; + hasClaimedX?: boolean; + showClaimed?: boolean; + width?: number; + setHasClaimedX: (hasClaimedX: boolean) => void; +}; + +const ClaimXTicket: FunctionComponent = ({ + address, + isConnected, + isFinished, + hasClaimedX, + showClaimed = true, + width = 200, + setHasClaimedX, +}) => { + const searchParams = useSearchParams(); + const claimXStatus = searchParams.get("success"); + const claimXError = searchParams.get("error_msg"); + const [showErrorMsg, setShowErrorMsg] = useState(false); + const [errorMsg, setErrorMsg] = useState(""); + + useEffect(() => { + if (!claimXStatus) return; + if (claimXStatus === "true") { + if (!hasClaimedX) { + storeHasClaimedXTicket(); + setHasClaimedX(true); + } + } else if (claimXStatus === "false" && claimXError) { + // show error message + if ((claimXError as string).includes("already claimed")) { + setHasClaimedX(true); + } + setErrorMsg(claimXError); + setShowErrorMsg(true); + } + }, [claimXStatus, claimXError]); + + const closeErrorMsg = () => { + setShowErrorMsg(false); + setErrorMsg(""); + }; + + return ( + <> + {isConnected && !isFinished && hasClaimedX !== undefined && address ? ( +
+

Retweet to get an extra ticket !

+ {!hasClaimedX ? ( +
+ +
+ ) : showClaimed ? ( +
+
+ Already claimed +
+
+ ) : null} +
+ ) : null} + + <>{errorMsg} + + + ); +}; + +export default ClaimXTicket; diff --git a/app/components/connection/starknetConnect.tsx b/app/components/connection/starknetConnect.tsx index 65788d0..d255525 100644 --- a/app/components/connection/starknetConnect.tsx +++ b/app/components/connection/starknetConnect.tsx @@ -103,7 +103,7 @@ const StarknetWalletConnect: FunctionComponent = ({ onClick={() => tryConnect(connector, isAvailable)} > + + ) : null} + + {!hasClaimedX ? ( + <> + {hasClicked ? ( + + ) : ( + <> +
+ Retweet to get an extra ticket ! +
+ + + )} + + ) : null} +
+ Close +
+ + + + + ); +}; + +export default ExtraClickModal; diff --git a/app/components/tryAgainModal.tsx b/app/components/tryAgainModal.tsx index c44284c..c203452 100644 --- a/app/components/tryAgainModal.tsx +++ b/app/components/tryAgainModal.tsx @@ -5,12 +5,9 @@ import { Modal } from "@mui/material"; import styles from "../styles/components/welcomeModal.module.css"; import modalStyles from "../styles/components/modal.module.css"; import Button from "./button"; -import { NetworkType } from "@/constants/types"; +import { NetworkType, WalletType } from "@/constants/types"; import WalletIcon from "./iconComponents/walletIcon"; -import { - getArgentIcon, - getArgentWebsite, -} from "@/utils/starknetConnectorsWrapper"; +import hasStarknetWallets from "@/hooks/hasStarknetWallets"; type TryAgainModalProps = { closeModal: () => void; @@ -18,6 +15,9 @@ type TryAgainModalProps = { network?: NetworkType; hasEthTokens: boolean; openWalletModal?: () => void; + walletType?: WalletType; + hasClaimed2FA?: boolean; + claim2FATicket?: () => void; }; const TryAgainModal: FunctionComponent = ({ @@ -26,7 +26,20 @@ const TryAgainModal: FunctionComponent = ({ network, hasEthTokens, openWalletModal, + walletType, + hasClaimed2FA, + claim2FATicket, }) => { + const wallets = hasStarknetWallets(); + const canClaim2FA = + walletType && !hasClaimed2FA && network === NetworkType.STARKNET; + + const get2FAText = () => { + if (canClaim2FA) { + return "Enable 2FA on your wallet and claim a free ticket. "; + } else return ""; + }; + const modalDescription = network === NetworkType.EVM && hasEthTokens ? ( <> @@ -35,8 +48,8 @@ const TryAgainModal: FunctionComponent = ({ ) : ( <> - Unfortunately, you don't have clicks left. You get another click - for each Starknet domain purchased. + Unfortunately, you don't have clicks left. {get2FAText}You get + another click for each Starknet domain purchased. ); @@ -67,23 +80,34 @@ const TryAgainModal: FunctionComponent = ({ {network === NetworkType.EVM && hasEthTokens ? (
- - {/* */} + {wallets.map((wallet) => ( + + ))}
) : (
+ {canClaim2FA && claim2FATicket ? ( + + ) : null} - ) : ( + ))} +
+ ) : totalClicks == 0 ? ( +
+ {isWhitelisted && walletType && !hasClaimed2FA ? ( - )} -
- ) : totalClicks == 0 ? ( -
+ ) : null}
) : null} + {!isLoaded ? ( + setExtraClickModal(false)} + open={extraClickModal} + network={network} + address={address} + hasClaimed2FA={hasClaimed2FA} + claim2FATicket={claim2FATicket} + hasClaimedX={hasClaimedX} + setHasClaimedX={setHasClaimedX} + isFinished={isFinished} /> setTryAgainModal(false)} @@ -712,6 +801,9 @@ export default function Home() { network={network} hasEthTokens={hasEthTokens} openWalletModal={openWalletModal} + walletType={walletType} + hasClaimed2FA={hasClaimed2FA} + claim2FATicket={claim2FATicket} /> setRecoverTokenModal(false)} @@ -742,6 +834,10 @@ export default function Home() { <>{errorMsg} + ) : (
diff --git a/constants/types.ts b/constants/types.ts index edb2236..60808d8 100644 --- a/constants/types.ts +++ b/constants/types.ts @@ -65,3 +65,13 @@ export type SearchResult = { addr?: string; error?: string; }; + +export type WalletType = "argent" | "braavos"; + +export type WalletState = { + id: string; + isInstalled: boolean; + label: string; + website: string; + icon: string; +}; diff --git a/hooks/getWalletType.tsx b/hooks/getWalletType.tsx new file mode 100644 index 0000000..2fe995a --- /dev/null +++ b/hooks/getWalletType.tsx @@ -0,0 +1,45 @@ +import { NetworkType, WalletType } from "@/constants/types"; +import { useConnect } from "@starknet-react/core"; +import { useEffect, useState } from "react"; +import getStarknet from "get-starknet-core"; + +export default function getWalletType(network?: NetworkType, address?: string) { + const { connector } = useConnect(); + const [walletType, setWalletType] = useState(); + + useEffect(() => { + if (!network || network === NetworkType.EVM || !address) { + setWalletType(undefined); + return; + } + + const checkIsDeployed = async () => { + try { + const availableWallets = await getStarknet.getAvailableWallets(); + if (!availableWallets) { + setWalletType(undefined); + return; + } + + availableWallets.forEach(async (connectedWallet) => { + if ( + connectedWallet.id === connector?.id && + connectedWallet.isConnected + ) { + if (connectedWallet.id.includes("argent")) { + setWalletType("argent"); + } else if (connectedWallet.id === "braavos") { + setWalletType("braavos"); + } + } + }); + } catch (error) { + setWalletType(undefined); + } + }; + + checkIsDeployed(); + }, [network, address]); + + return walletType; +} diff --git a/hooks/hasArgent.tsx b/hooks/hasArgent.tsx deleted file mode 100644 index 15b9b26..0000000 --- a/hooks/hasArgent.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { useEffect, useState } from "react"; -import getStarknet from "get-starknet-core"; - -export default function hasArgent() { - const [isInstalled, setIsInstalled] = useState(false); - useEffect(() => { - const checkHasArgent = async () => { - try { - const availableWallets = await getStarknet.getAvailableWallets(); - if (!availableWallets) { - setIsInstalled(false); - return; - } - - const argent = availableWallets.find((item) => item.id === "argentX"); - if (argent) setIsInstalled(true); - else setIsInstalled(false); - } catch (error) { - setIsInstalled(false); - } - }; - - checkHasArgent(); - }, []); - - return isInstalled; -} diff --git a/hooks/hasStarknetWallets.tsx b/hooks/hasStarknetWallets.tsx new file mode 100644 index 0000000..01ee8d4 --- /dev/null +++ b/hooks/hasStarknetWallets.tsx @@ -0,0 +1,60 @@ +import { useEffect, useState } from "react"; +import getStarknet from "get-starknet-core"; +import { WalletState } from "@/constants/types"; +import { + getArgentIcon, + getArgentWebsite, + getBraavosIcon, + getBraavosWebsite, +} from "@/utils/starknetConnectorsWrapper"; + +export default function hasStarknetWallets() { + const defaultState = [ + { + id: "argentX", + isInstalled: false, + label: "Argent", + website: getArgentWebsite(), + icon: getArgentIcon(), + }, + { + id: "braavos", + isInstalled: false, + label: "Braavos", + website: getBraavosWebsite(), + icon: getBraavosIcon(), + }, + ]; + const [wallets, setWallets] = useState(defaultState); + useEffect(() => { + const checkWallets = async () => { + try { + const availableWallets = await getStarknet.getAvailableWallets(); + if (!availableWallets) { + setWallets(defaultState); + return; + } + + const updatedWallets = defaultState.map((wallet) => ({ + ...wallet, + isInstalled: availableWallets.some( + (item) => item.id === `${wallet.id}` + ), + })); + + // Sort so installed wallets are first + updatedWallets.sort( + (a, b) => Number(b.isInstalled) - Number(a.isInstalled) + ); + + setWallets(updatedWallets); + } catch (error) { + setWallets(defaultState); + } + }; + + checkWallets(); + }, []); + + return wallets; +} diff --git a/hooks/isDeployed.tsx b/hooks/isDeployed.tsx index da96dbb..666291a 100644 --- a/hooks/isDeployed.tsx +++ b/hooks/isDeployed.tsx @@ -25,7 +25,6 @@ export default function isStarknetDeployed( provider .getClassHashAt(address) .then((classHash) => { - console.log("Class hash", classHash); setIsDeployed(true); setDeploymentData(undefined); return; @@ -45,7 +44,7 @@ export default function isStarknetDeployed( if ( connectedWallet.id === connector?.id && connectedWallet.isConnected && - connectedWallet.id !== "braavos" // we cannot deploye braavos account for the user for now + connectedWallet.id !== "braavos" // we cannot deploy braavos account for the user for now ) { const data = await wallet.deploymentData( // @ts-ignore diff --git a/public/visuals/2FA.svg b/public/visuals/2FA.svg new file mode 100644 index 0000000..2c80a47 --- /dev/null +++ b/public/visuals/2FA.svg @@ -0,0 +1,3 @@ + + + diff --git a/services/apiService.ts b/services/apiService.ts index 1079163..d4dc0e1 100644 --- a/services/apiService.ts +++ b/services/apiService.ts @@ -1,4 +1,8 @@ -import { EthToken, GetDeploymentDataResult } from "@/constants/types"; +import { + EthToken, + GetDeploymentDataResult, + WalletType, +} from "@/constants/types"; import { Signature, stark } from "starknet"; const baseurl = process.env.NEXT_PUBLIC_ETH_BUTTON_API; @@ -76,8 +80,9 @@ export const starknetResetButton = async ( }), }); return await response.json(); - } catch (err) { + } catch (err: any) { console.log("Error while calling starknet_reset_button", err); + throw new Error(err.message); } }; @@ -104,8 +109,9 @@ export const starknetDomainResetButton = async ( }), }); return await response.json(); - } catch (err) { - console.log("Error while calling starknet_domain_reset_button", err); + } catch (err: any) { + console.log("Error while calling starknet_reset_button_from_eth", err); + throw new Error(err.message); } }; @@ -138,8 +144,9 @@ export const starknetResetButtonFromEth = async ( }), }); return await response.json(); - } catch (err) { + } catch (err: any) { console.log("Error while calling starknet_reset_button_from_eth", err); + throw new Error(err.message); } }; @@ -174,8 +181,9 @@ export const altStarknetNewAccount = async ( }), }); return await response.json(); - } catch (err) { + } catch (err: any) { console.log("Error while calling starknet_reset_button_from_eth", err); + throw new Error(err.message); } }; @@ -186,8 +194,9 @@ export const getEthEligibility = async (address: string) => { `${baseurl}/get_eth_eligibility?addr=${address}` ); return await response.json(); - } catch (err) { + } catch (err: any) { console.log("Error while fetching ethereum eligibility", err); + throw new Error(err.message); } }; @@ -201,7 +210,39 @@ export const ethResetButton = async (addr: string, sig: string) => { body: JSON.stringify({ addr, sig }), }); return await response.json(); - } catch (err) { + } catch (err: any) { console.log("Error while calling eth_reset_button", err); + throw new Error(err.message); + } +}; + +export const claim2FATicketQuery = async ( + address: string, + walletType: WalletType +) => { + try { + const response = await fetch(`${baseurl}/claim_2fa_ticket`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + addr: address, + wallet_type: walletType, + }), + }); + + const contentType = response.headers.get("content-type"); + + if (contentType && contentType.includes("application/json")) { + return await response.json(); + } else { + // If the content type is not JSON, read the response as text + const errorMessage = await response.text(); + return { error: errorMessage }; // Return a custom object containing the error message + } + } catch (error: any) { + console.error("Error fetching data: ", error.message); + throw new Error(error.message); } }; diff --git a/services/localStorageService.ts b/services/localStorageService.ts index 3835502..a114137 100644 --- a/services/localStorageService.ts +++ b/services/localStorageService.ts @@ -125,3 +125,40 @@ export const getEthSig = (): { eth_addr: string; sig: string } | null => { export const clearEthSig = (): void => { localStorage.removeItem("ethbutton-ethSig"); }; + +export const storeHasClaimedXTicket = (): void => { + localStorage.setItem("ethbutton-hasClaimedTwitter", "true"); +}; + +export const getHasClaimedXTicket = (): boolean => { + const hasClaimed = localStorage.getItem("ethbutton-hasClaimedTwitter"); + if (!hasClaimed) { + return false; + } + + try { + if (typeof hasClaimed === "string" && hasClaimed === "true") { + return true; + } else { + return false; + } + } catch (error) { + console.error("Error parsing JSON from local storage", error); + return false; + } +}; + +export const storeHasClaimed2FATicket = (addr: string): void => { + const currentClaims = JSON.parse( + localStorage.getItem("ethbutton-2faClaims") || "{}" + ); + currentClaims[addr] = true; + localStorage.setItem("ethbutton-2faClaims", JSON.stringify(currentClaims)); +}; + +export const getHasClaimed2FA = (addr: string): boolean => { + const claims = JSON.parse( + localStorage.getItem("ethbutton-2faClaims") || "{}" + ); + return !!claims[addr]; +}; diff --git a/utils/codeChallenge.ts b/utils/codeChallenge.ts new file mode 100644 index 0000000..45ff32c --- /dev/null +++ b/utils/codeChallenge.ts @@ -0,0 +1,42 @@ +import crypto from "crypto"; +import { hexToDecimal } from "./feltService"; + +function base64URLEncode(str: any) { + return str + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, ""); +} + +function sha256(buffer: any) { + return crypto.createHash("sha256").update(buffer).digest(); +} + +export function generateCodeVerifier(): string { + return base64URLEncode(crypto.randomBytes(32)); +} + +export function generateCodeChallenge(codeVerifier: string): string { + return base64URLEncode(sha256(codeVerifier)); +} + +export const getExtraTicket = (address: string) => { + const codeChallenge = generateCodeChallenge( + process.env.NEXT_PUBLIC_TWITTER_CODE_VERIFIER as string + ); + const rootUrl = "https://twitter.com/i/oauth2/authorize"; + const options = { + redirect_uri: `${ + process.env.NEXT_PUBLIC_ETH_BUTTON_API as string + }/claim_x_ticket?addr=${hexToDecimal(address)}`, + client_id: process.env.NEXT_PUBLIC_TWITTER_CLIENT_ID as string, + state: "state", + response_type: "code", + code_challenge: codeChallenge, + code_challenge_method: "S256", + scope: ["follows.read", "tweet.read", "users.read"].join(" "), + }; + const qs = new URLSearchParams(options).toString(); + window.open(`${rootUrl}?${qs}`); +}; diff --git a/utils/starknetConnectorsWrapper.ts b/utils/starknetConnectorsWrapper.ts index d9b481f..dc4cca2 100644 --- a/utils/starknetConnectorsWrapper.ts +++ b/utils/starknetConnectorsWrapper.ts @@ -130,20 +130,20 @@ export const getLastConnected = (): Connector | null => { return null; }; -export const getArgentIcon = () => { - return wallets.find((wallet) => wallet.id === "argentX")?.icon; +export const getArgentIcon = (): string => { + return wallets.find((wallet) => wallet.id === "argentX")?.icon as string; }; -export const getArgentWebsite = () => { - return wallets.find((wallet) => wallet.id === "argentX")?.website; +export const getArgentWebsite = (): string => { + return wallets.find((wallet) => wallet.id === "argentX")?.website as string; }; -export const getBraavosIcon = () => { - return wallets.find((wallet) => wallet.id === "braavos")?.icon; +export const getBraavosIcon = (): string => { + return wallets.find((wallet) => wallet.id === "braavos")?.icon as string; }; -export const getBraavosWebsite = () => { - return wallets.find((wallet) => wallet.id === "braavos")?.website; +export const getBraavosWebsite = (): string => { + return wallets.find((wallet) => wallet.id === "braavos")?.website as string; }; const wallets = [ diff --git a/utils/stringService.ts b/utils/stringService.ts index aa792e9..b4ae0ac 100644 --- a/utils/stringService.ts +++ b/utils/stringService.ts @@ -62,3 +62,15 @@ export function isHexString(str: string): boolean { if (str === "") return true; return /^0x[0123456789abcdefABCDEF]+$/.test(str); } + +export function getError(error: any, errMsg: string): string { + if (error instanceof Error) { + return error.message; + } else if (typeof error === "string") { + return error; + } else if (error && typeof error === "object") { + return JSON.stringify(error); + } else { + return errMsg; + } +}