From 4abbbe1aa30802e0be626249b510c9a0733d2527 Mon Sep 17 00:00:00 2001 From: 0xMakkkka <92357475+0xMakka@users.noreply.github.com> Date: Tue, 23 Jul 2024 10:14:02 +0100 Subject: [PATCH] Makka/base poc (#2378) * UI polish * initial cross chain scaffolding * cleanup and updates * add hacky any to web3State to temporarily fix build * fix formatting of abi * add function to calculate retirement cost, additional cleanup * fix maxAmountIn decimals * disable submit when insufficient balance * fix tx hash link * use polygon address and not base address * move base logo to base components * remove export from lib * add 1% slippage to cost * fix prettier formatting * Update polygon target contract * add transaction modal and handle errors * fix supportedChains as base * fix supportedChains as base * reset form state after successful transaction * handle quantity error, fix insufficient balance check * run prettier and fix build --------- Co-authored-by: Atmosfearful Co-authored-by: Cujowolf --- base/components/Buttons/styles.ts | 2 +- base/components/Connect/index.tsx | 12 +- base/components/DropdownWithModal/index.tsx | 2 +- base/components/Logos/BaseLogo.tsx | 18 + base/components/Modal/index.tsx | 2 +- base/components/Text/index.tsx | 44 - base/components/Text/styles.ts | 22 - .../TransactionModal/HighlightValue.tsx | 60 + base/components/TransactionModal/index.tsx | 124 + base/components/TransactionModal/styles.ts | 137 ++ base/components/pages/Home/index.tsx | 369 ++- base/components/pages/Home/styles.ts | 132 +- base/hooks/useIsMounted.ts | 14 + base/lib/constants.ts | 17 +- base/lib/getTokenInfo.ts | 5 +- base/lib/submitCrossChain.ts | 163 ++ base/package.json | 3 +- base/styles/globals.css | 2 +- lib/abi/InterchainTokenService.json | 2148 +++++++++++++++++ lib/components/Logos/LogoWithClaim.tsx | 7 +- lib/components/index.ts | 2 +- lib/constants/index.ts | 2 + lib/utils/useProvider/index.tsx | 5 +- package-lock.json | 733 +++++- 24 files changed, 3765 insertions(+), 260 deletions(-) create mode 100644 base/components/Logos/BaseLogo.tsx delete mode 100644 base/components/Text/index.tsx delete mode 100644 base/components/Text/styles.ts create mode 100644 base/components/TransactionModal/HighlightValue.tsx create mode 100644 base/components/TransactionModal/index.tsx create mode 100644 base/components/TransactionModal/styles.ts create mode 100644 base/hooks/useIsMounted.ts create mode 100644 base/lib/submitCrossChain.ts create mode 100644 lib/abi/InterchainTokenService.json diff --git a/base/components/Buttons/styles.ts b/base/components/Buttons/styles.ts index bd4a9de7dc..572835dac2 100644 --- a/base/components/Buttons/styles.ts +++ b/base/components/Buttons/styles.ts @@ -1,5 +1,5 @@ import { css } from "@emotion/css"; -import { button } from "../../styles/typography"; +import { button } from "@klimadao/lib/theme/typography"; const buttonBase = css` ${button}; diff --git a/base/components/Connect/index.tsx b/base/components/Connect/index.tsx index db7ce44355..37628b88fb 100644 --- a/base/components/Connect/index.tsx +++ b/base/components/Connect/index.tsx @@ -1,10 +1,13 @@ import { cx } from "@emotion/css"; import { ConnectButton } from "@rainbow-me/rainbowkit"; +import { FC } from "react"; import { ButtonPrimary } from "../Buttons/ButtonPrimary"; import * as styles from "./styles"; /* example from https://www.rainbowkit.com/docs/custom-connect-button */ -export const Connect = () => ( +export const Connect: FC<{ + className?: string; +}> = ({ className }) => ( {({ account, @@ -17,6 +20,7 @@ export const Connect = () => ( }) => { // Note: If your app doesn't use authentication, you // can remove all 'authenticationStatus' checks + const buttonClassName = cx(styles.connectButton, className); const ready = mounted && authenticationStatus !== "loading"; const connected = ready && @@ -35,7 +39,7 @@ export const Connect = () => ( ); } @@ -44,7 +48,7 @@ export const Connect = () => ( ); } @@ -71,7 +75,7 @@ export const Connect = () => ( ); diff --git a/base/components/DropdownWithModal/index.tsx b/base/components/DropdownWithModal/index.tsx index d205d8cd24..2af7f14522 100644 --- a/base/components/DropdownWithModal/index.tsx +++ b/base/components/DropdownWithModal/index.tsx @@ -1,9 +1,9 @@ import { cx } from "@emotion/css"; +import { Text } from "@klimadao/lib/components"; import KeyboardArrowDown from "@mui/icons-material/KeyboardArrowDown"; import Image, { StaticImageData } from "next/image"; import { FC, ReactNode } from "react"; import { Modal } from "../Modal"; -import { Text } from "../Text"; import * as styles from "./styles"; interface Item { diff --git a/base/components/Logos/BaseLogo.tsx b/base/components/Logos/BaseLogo.tsx new file mode 100644 index 0000000000..d83f83a67a --- /dev/null +++ b/base/components/Logos/BaseLogo.tsx @@ -0,0 +1,18 @@ +export const BaseLogo = (props: { className?: string }) => ( + + + + +); diff --git a/base/components/Modal/index.tsx b/base/components/Modal/index.tsx index 5e11a4672e..c5e3087a5d 100644 --- a/base/components/Modal/index.tsx +++ b/base/components/Modal/index.tsx @@ -1,7 +1,7 @@ import { cx } from "@emotion/css"; +import { Text } from "@klimadao/lib/components"; import Close from "@mui/icons-material/Close"; import { FC, ReactNode } from "react"; -import { Text } from "../Text"; import * as styles from "./styles"; interface Props { diff --git a/base/components/Text/index.tsx b/base/components/Text/index.tsx deleted file mode 100644 index 569ee5bd97..0000000000 --- a/base/components/Text/index.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { cx } from "@emotion/css"; -import React, { FC, HTMLAttributes } from "react"; -import * as typography from "../../styles/typography"; -import * as styles from "./styles"; - -export type TypographyStyle = keyof typeof typography; - -type Props = HTMLAttributes & { - /** Which typography styles to apply. Default: body1 */ - t?: TypographyStyle; - /** Determine the tag type to be rendered. Default:

*/ - as?: "span" | "p" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6"; - /** Default=font-01, lighter=font-02, lightest=font-03 */ - color?: "default" | "lighter" | "lightest"; - /** Text align */ - align?: "start" | "center" | "end"; - /** Apply text-transform: uppercase if true */ - uppercase?: boolean; -}; - -/** Render any element w/ typography styles. Element and styles are independent. - * @example - * Hello World - */ -export const Text: FC = ({ - as = "p", - t = "body1", - color = "default", - align = "start", - uppercase = false, - children, - ...props -}) => { - return React.createElement( - as, - { - ...props, - "data-color": color, - "data-align": align, - className: cx(typography[t], styles.text, { uppercase }, props.className), - }, - children - ); -}; diff --git a/base/components/Text/styles.ts b/base/components/Text/styles.ts deleted file mode 100644 index 9e937c83be..0000000000 --- a/base/components/Text/styles.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { css } from "@emotion/css"; - -export const text = css` - color: var(--font-01); - transition: color 0.25s ease-in-out; - text-align: start; - &.uppercase { - text-transform: uppercase; - } - &[data-align="center"] { - text-align: center; - } - &[data-align="end"] { - text-align: end; - } - &[data-color="lighter"] { - color: var(--font-02); - } - &[data-color="lightest"] { - color: var(--font-03); - } -`; diff --git a/base/components/TransactionModal/HighlightValue.tsx b/base/components/TransactionModal/HighlightValue.tsx new file mode 100644 index 0000000000..867bf3dc62 --- /dev/null +++ b/base/components/TransactionModal/HighlightValue.tsx @@ -0,0 +1,60 @@ +import { cx } from "@emotion/css"; +import { Anchor, Text } from "@klimadao/lib/components"; +import Image, { StaticImageData } from "next/image"; +import { FC, ReactNode } from "react"; +import * as styles from "./styles"; + +interface HighlightValueProps { + label: ReactNode; + value: string; + icon?: StaticImageData; + iconName?: string; + warn?: boolean; + /** If you want to wrap the value in a hyperlink, e.g. to polygonscan*/ + valueHref?: string; +} + +export const HighlightValue: FC = (props) => { + return ( +

+
{props.label}
+
+ {props.icon && ( + {props.iconName + )} + {props.valueHref ? ( + + + {props.value} + + + ) : ( + + {props.value} + + )} +
+
+ ); +}; diff --git a/base/components/TransactionModal/index.tsx b/base/components/TransactionModal/index.tsx new file mode 100644 index 0000000000..aa5b3b81b3 --- /dev/null +++ b/base/components/TransactionModal/index.tsx @@ -0,0 +1,124 @@ +import { cx } from "@emotion/css"; +import { ButtonPrimary, Spinner, Text } from "@klimadao/lib/components"; +import { urls } from "@klimadao/lib/constants"; +import { concatAddress } from "@klimadao/lib/utils"; +import CheckIcon from "@mui/icons-material/Check"; +import SendRounded from "@mui/icons-material/SendRounded"; +import { Modal } from "components/Modal"; +import { StatusMessage, TxnStatus } from "components/pages/Home"; +import { StaticImageData } from "next/image"; +import Link from "next/link"; +import { FC, ReactNode } from "react"; +import { HighlightValue } from "./HighlightValue"; +import * as styles from "./styles"; + +interface Props { + title: ReactNode; + value: string; + tokenName: string; + status: StatusMessage; + tokenIcon: StaticImageData; + spenderAddress: string; + onSubmit: () => void; + onCloseModal: () => void; +} + +const getStatusMessage = (statusType: TxnStatus, message?: string) => { + if (statusType === "error" && message) { + if (message === "userRejected") { + return "❌ You chose to reject the transaction"; + } + return "❌ Error: something went wrong..."; + } else if (statusType === "networkConfirmation") { + return "Transaction initiated. Waiting for network confirmation."; + } else if (statusType === "done") { + return ( + <> + Transaction complete. Track progress on{" "} + + Axelarscan + + + ); + } + return null; +}; + +export const TransactionModal: FC = (props) => { + const statusType = props.status?.statusType; + + const success = statusType === "done"; + const isPending = statusType === "networkConfirmation"; + + const showCloseButton = !isPending && success; + const showSubmitButton = !isPending && !success; + + const onModalClose = !isPending ? props.onCloseModal : undefined; + + return ( + +
+
+ + Please submit the transaction. Note: This can take between 5-20 + minutes to complete on Axelar. + + + Contract address + + } + value={concatAddress(props.spenderAddress)} + valueHref={urls.basescan + `/address/${props.spenderAddress}`} + /> + + Confirm amount + + } + value={props.value || "0"} + icon={props.tokenIcon} + iconName={props.tokenName} + /> +
+ {!!props.status && ( +
+ {success && } + + {getStatusMessage(props.status.statusType, props.status.message)} + +
+ )} +
+ {isPending && ( +
+ +
+ )} + {showSubmitButton && ( + } + label="Submit" + onClick={() => props.onSubmit()} + className={styles.submitButton} + /> + )} + {showCloseButton && ( + props.onCloseModal()} + className={styles.submitButton} + /> + )} +
+
+
+ ); +}; diff --git a/base/components/TransactionModal/styles.ts b/base/components/TransactionModal/styles.ts new file mode 100644 index 0000000000..72f6d1cb95 --- /dev/null +++ b/base/components/TransactionModal/styles.ts @@ -0,0 +1,137 @@ +import { css } from "@emotion/css"; +import breakpoints from "@klimadao/lib/theme/breakpoints"; +import * as typography from "@klimadao/lib/theme/typography"; + +export const container = css` + display: grid; + gap: 2rem; + background-color: var(--surface-02); + border: 2px solid var(--surface-03); + padding: 2.4rem; + border-radius: 1.2rem; +`; + +export const contentContainer = css` + display: grid; + gap: 2rem; + + &.success { + opacity: 0.3; + } +`; + +export const viewSwitch = css` + display: grid; + grid-template-columns: 1fr 1fr; + justify-content: stretch; + align-items: center; + border-radius: 0.8rem; + padding: 0.4rem; + background-color: var(--surface-01); +`; + +export const switchButton = css` + ${typography.button}; + display: flex; + align-items: center; + display: flex; + justify-content: center; + background-color: var(--surface-01); + min-height: 4.8rem; + + &:hover { + opacity: 0.8; + } + + &[data-active="false"] { + color: var(--font-01); + } + + &[data-active="true"] { + font-weight: bold; + border-bottom: 3px solid var(--klima-green); + } + + &:disabled { + opacity: 50%; + } +`; + +export const statusMessage = css` + display: flex; + justify-content: center; + align-items: center; + gap: 0.8rem; + + & svg { + color: var(--klima-green); + } + + & a { + text-decoration: underline; + } +`; + +export const buttonRow = css` + display: flex; + justify-content: center; +`; + +export const buttonRow_spinner = css` + padding: 0 0.8rem; + display: flex; + align-items: center; + min-height: 4.8rem; +`; + +export const submitButton = css` + width: 100%; + border: none; +`; + +export const spinner_container = css` + padding: 0.8rem; + display: flex; + align-items: center; + justify-content: center; + min-height: 4.8rem; +`; + +export const valueContainer = css` + display: grid; + flex-direction: column; + width: 100%; + gap: 0.6rem; + + .label { + height: 2rem; + + ${breakpoints.medium} { + justify-self: flex-start; + } + } + + .value.warn { + color: var(--warn); + } +`; + +export const value = css` + display: flex; + justify-content: space-between; + align-items: center; + background-color: var(--surface-01); + color: var(--font-01); + height: 5.6rem; + border-radius: 1rem; + padding: 0.4rem 0.8rem; + + .icon { + min-width: 4.8rem; + align-self: center; + } + + p { + padding: 0 0.6rem; + } +`; diff --git a/base/components/pages/Home/index.tsx b/base/components/pages/Home/index.tsx index 94452d8112..a487562e23 100644 --- a/base/components/pages/Home/index.tsx +++ b/base/components/pages/Home/index.tsx @@ -1,107 +1,320 @@ +import { Anchor, LogoWithClaim, Text } from "@klimadao/lib/components"; +import { OffsetInputToken } from "@klimadao/lib/constants"; +import { + getTokenDecimals, + safeAdd, + trimStringDecimals, +} from "@klimadao/lib/utils"; import GppMaybeOutlinedIcon from "@mui/icons-material/GppMaybeOutlined"; +import { BaseLogo } from "components/Logos/BaseLogo"; +import { TransactionModal } from "components/TransactionModal"; +import { + BrowserProvider, + JsonRpcSigner, + Signer, + formatUnits, + parseUnits, +} from "ethers"; +import { useIsMounted } from "hooks/useIsMounted"; import { formatTonnes } from "lib/formatTonnes"; +import { + getOffsetConsumptionCost, + submitCrossChain, +} from "lib/submitCrossChain"; import Image from "next/image"; -import { useState } from "react"; -import { RedeemablePoolToken, tokenInfoMap } from "../../../lib/getTokenInfo"; +import { useEffect, useState } from "react"; +import { Address, useAccount, useBalance, useNetwork } from "wagmi"; +import { addresses } from "../../../lib/constants"; +import { tokenInfoMap } from "../../../lib/getTokenInfo"; import { ButtonPrimary } from "../../Buttons/ButtonPrimary"; import { Connect } from "../../Connect"; -import { DropdownWithModal } from "../../DropdownWithModal"; -import { Text } from "../../Text"; import * as styles from "./styles"; +export type TxnStatus = "networkConfirmation" | "done" | "error" | null; + +export type StatusMessage = { + statusType: TxnStatus; + message?: string; +} | null; + +const initialFormState = { + quantity: "0", + beneficiaryString: "", + retirementMessage: "Doing my part to support climate action", +}; + +// TODO: add lingui for translations export const Home = () => { - const [quantity, setQuantity] = useState("0"); + const { chain } = useNetwork(); + const isMounted = useIsMounted(); + const { address, isConnected, connector } = useAccount(); + const { data } = useBalance({ + address, + token: addresses.base.klima as Address, + }); + const [paymentToken] = useState("klima"); + const [status, setStatus] = useState(null); + const [signer, setSigner] = useState(); + const [showTransactionModal, setShowTransactionModal] = useState(false); + + const [cost, setCost] = useState(""); + const [error, setError] = useState(""); + const [quantity, setQuantity] = useState(initialFormState.quantity); + const [beneficiaryString, setBeneficiaryString] = useState( + initialFormState.beneficiaryString + ); const [retirementMessage, setRetirementMessage] = useState( - "Doing my part to support climate action" + initialFormState.retirementMessage ); - const [pool, setPool] = useState("bct"); // only support bct initially... - const [isPoolTokenModalOpen, setPoolTokenModalOpen] = useState(false); + + useEffect(() => { + if (!connector) return; + connector + .getProvider() + .then((provider) => { + const browserProvider = new BrowserProvider(provider); + setSigner(new JsonRpcSigner(browserProvider, address as Address)); + }) + .catch((e) => console.error("An error occurred", e)); + }, [connector]); + + useEffect(() => { + if (Number(quantity) === 0) return; + const offsetConsumptionCost = async () => { + const [cost] = await getOffsetConsumptionCost({ + inputToken: paymentToken, + retirementToken: "bct", + quantity: quantity, + }); + // handle error when calculating cost + if (cost === "e") { + setError("Quantity too large"); + return; + } + setError(""); + setCost(cost); + }; + offsetConsumptionCost(); + }, [quantity]); const handleQuantityChange = (value: string) => { - const valueToWholeNumber = Math.ceil(Number(value)).toString(); + // only allow up to 10 digits in input field + const truncatedValue = value.slice(0, 10); + const valueToWholeNumber = Math.ceil(Number(truncatedValue)).toString(); setQuantity(valueToWholeNumber); }; + const resetFormState = () => { + // TODO - code smell - clean this up with react-hook-form or useReducer + setCost(""); + setQuantity(initialFormState.quantity); + setBeneficiaryString(initialFormState.beneficiaryString); + setRetirementMessage(initialFormState.retirementMessage); + }; + + const closeModal = () => { + setStatus(null); + setShowTransactionModal(false); + }; + + const getRetirementCost = (): string => { + if (!cost) return "0"; + const onePercent = + BigInt(parseUnits(cost, getTokenDecimals(paymentToken))) / BigInt("100"); + return safeAdd( + cost, + formatUnits(onePercent, getTokenDecimals(paymentToken)) + ); + }; + + const insufficientBalance = data?.value + ? Number(cost) > Number(formatUnits(data.value.toString(), 9)) + : "0"; + + const wrongNetworkOrNotConnected = + !isConnected || !address || !!chain?.unsupported; + + const getButtonProps = () => { + if (!quantity || !Number(quantity)) { + return { + label: `Enter quantity`, + disabled: true, + }; + } else if (error !== "") { + return { + label: error, + disabled: true, + }; + } else if (insufficientBalance) { + return { + label: "Insufficient balance", + disabled: true, + }; + } else if (beneficiaryString === "") { + return { + label: "Beneficiary required", + disabled: true, + }; + } else if (retirementMessage === "") { + return { + label: "Retirment message required", + disabled: true, + }; + } + return { + label: "Retire Carbon", + onClick: () => setShowTransactionModal(true), + }; + }; + + const handleCrossChainRetirement = async () => { + await submitCrossChain({ + signer, + quantity, + beneficiaryString, + retirementMessage, + maxAmountIn: getRetirementCost(), + beneficiaryAddress: address as string, + onStatus: (statusType, message) => setStatus({ statusType, message }), + }); + resetFormState(); + }; + + const formattedCost = () => { + const cost = getRetirementCost(); + return !cost + ? "0" + : Number(cost) > 1 + ? trimStringDecimals(cost, 3) + : trimStringDecimals(cost, 5); + }; + return (
-
-
-
-
-
- setPoolTokenModalOpen((s) => !s)} - onItemSelect={() => setPool("bct")} - /> -
-
- - handleQuantityChange(e.target.value)} - /> -
-
- - console.log("onChange")} +
+ + ✨ New! + + + Retire carbon on + BASE + + + KlimaDAO is officially{" "} + + building on Base + + . This proof-of-concept cross-chain retirement application gives Base + users access to the 17 million tokenized carbon credits already on + Polygon. + + + Stay tuned as we continue to deploy more advanced Base integrations + across the entire KlimaDAO and{" "} + Carbonmark{" "} + ecosystem 🌍. + +
+
+
+
+
+ + handleQuantityChange(e.target.value)} + /> +
+
+ + setBeneficiaryString(e.target.value)} + /> +
+
+ +