diff --git a/contracts/lib/breadchain b/contracts/lib/breadchain index 952757e8..006c2006 160000 --- a/contracts/lib/breadchain +++ b/contracts/lib/breadchain @@ -1 +1 @@ -Subproject commit 952757e85598922624ddf0d9f4bb363c04e72f25 +Subproject commit 006c2006e8e37c7a15f4c2c10c600449fa3918e1 diff --git a/src/abi/Distributor.ts b/src/abi/Distributor.ts index 9f2db166..66595685 100644 --- a/src/abi/Distributor.ts +++ b/src/abi/Distributor.ts @@ -57,12 +57,30 @@ export const distributorAbi = [ { type: "function", name: "allowlistedMultipliers", - inputs: [{ name: "", type: "uint256", internalType: "uint256" }], + inputs: [{ name: "index", type: "uint256", internalType: "uint256" }], outputs: [ - { name: "", type: "address", internalType: "contract IMultiplier" }, + { + name: "multiplier", + type: "address", + internalType: "contract IMultiplier", + }, ], stateMutability: "view", }, + { + type: "function", + name: "calculateTotalMultipliers", + inputs: [ + { name: "_user", type: "address", internalType: "address" }, + { + name: "_multiplierIndexes", + type: "uint256[]", + internalType: "uint256[]", + }, + ], + outputs: [{ name: "", type: "uint256", internalType: "uint256" }], + stateMutability: "nonpayable", + }, { type: "function", name: "castVote", @@ -136,20 +154,6 @@ export const distributorAbi = [ outputs: [{ name: "", type: "uint256", internalType: "uint256" }], stateMutability: "view", }, - { - type: "function", - name: "getTotalMultipliers", - inputs: [ - { name: "_user", type: "address", internalType: "address" }, - { - name: "_multiplierIndexes", - type: "uint256[]", - internalType: "uint256[]", - }, - ], - outputs: [{ name: "", type: "uint256", internalType: "uint256" }], - stateMutability: "view", - }, { type: "function", name: "getValidMultiplierIndexes", @@ -178,7 +182,11 @@ export const distributorAbi = [ name: "initialize", inputs: [ { name: "_bread", type: "address", internalType: "address" }, - { name: "_butteredBread", type: "address", internalType: "address" }, + { + name: "_butteredBread", + type: "address", + internalType: "address", + }, { name: "_precision", type: "uint256", internalType: "uint256" }, { name: "_minRequiredVotingPower", @@ -186,7 +194,11 @@ export const distributorAbi = [ internalType: "uint256", }, { name: "_maxPoints", type: "uint256", internalType: "uint256" }, - { name: "_cycleLength", type: "uint256", internalType: "uint256" }, + { + name: "_cycleLength", + type: "uint256", + internalType: "uint256", + }, { name: "_yieldFixedSplitDivisor", type: "uint256", @@ -197,11 +209,22 @@ export const distributorAbi = [ type: "uint256", internalType: "uint256", }, - { name: "_projects", type: "address[]", internalType: "address[]" }, + { + name: "_projects", + type: "address[]", + internalType: "address[]", + }, ], outputs: [], stateMutability: "nonpayable", }, + { + type: "function", + name: "initialize", + inputs: [], + outputs: [], + stateMutability: "nonpayable", + }, { type: "function", name: "lastClaimedBlockNumber", @@ -320,7 +343,11 @@ export const distributorAbi = [ type: "function", name: "setButteredBread", inputs: [ - { name: "_butteredBread", type: "address", internalType: "address" }, + { + name: "_butteredBread", + type: "address", + internalType: "address", + }, ], outputs: [], stateMutability: "nonpayable", diff --git a/src/app/core/components/Icons/CheckDouble.tsx b/src/app/core/components/Icons/CheckDouble.tsx new file mode 100644 index 00000000..3b520240 --- /dev/null +++ b/src/app/core/components/Icons/CheckDouble.tsx @@ -0,0 +1,18 @@ +function CheckDoubleIcon() { + return ( + + + + ); +} + +export default CheckDoubleIcon; + diff --git a/src/app/core/components/MobileMenu/MobileNavigation.tsx b/src/app/core/components/MobileMenu/MobileNavigation.tsx index 2878abd2..b168d380 100644 --- a/src/app/core/components/MobileMenu/MobileNavigation.tsx +++ b/src/app/core/components/MobileMenu/MobileNavigation.tsx @@ -75,6 +75,29 @@ export function MobileNavigation({ handleNavToggle }: IProps) { Vaults + {" "} + handleNavToggle()} + className="text-neutral-900 dark:text-breadgray-rye dark:hover:text-breadgray-light-grey flex gap-2 items-center justify-end px-2" + > + + + + + + + Boosters )} diff --git a/src/app/governance/boosters/BoosterPage.tsx b/src/app/governance/boosters/BoosterPage.tsx new file mode 100644 index 00000000..9c260102 --- /dev/null +++ b/src/app/governance/boosters/BoosterPage.tsx @@ -0,0 +1,55 @@ +"use client"; +import { useEffect, useState } from "react"; +import { useConnectedUser } from "@/app/core/hooks/useConnectedUser"; +import { getConfig } from "@/app/core/hooks/WagmiProvider/config/getConfig"; +import { PageGrid } from "@/app/governance/components/PageGrid"; +import { VotingPowerPanel } from "./components/VotingPowerPanel"; +import { BoosterCard } from "./components/BoosterCard"; +import { boostData, mapBoostToCardProps } from "./data/BoostData"; +import Button from "@/app/core/components/Button"; + +export function BoosterPage() { + const { user } = useConnectedUser(); + + return ( +
+ +
+ +
+ +
+
+

All boosters?

+
+ {BoosterList()} +
+
+
+ ); +} + +function TitleSection() { + return ( +
+

+ Voting Power Boosters +

+
+

+ Active voter? Top baker? If this is you, the Breadchain cooperative + rewards you with voting power boosters for your engaging support of + post-capitalism. Be active and your voice will be amplified.{" "} +

+
+
+ ); +} + +function BoosterList() { + return boostData.map((boost, index) => { + return ( + + ); + }); +} diff --git a/src/app/governance/boosters/components/BoosterCard.tsx b/src/app/governance/boosters/components/BoosterCard.tsx new file mode 100644 index 00000000..401069e6 --- /dev/null +++ b/src/app/governance/boosters/components/BoosterCard.tsx @@ -0,0 +1,229 @@ +import { ReactElement } from "react"; +import { CheckIcon } from "@/app/core/components/Icons/CheckIcon"; +import Tooltip from "@/app/core/components/Tooltip"; +import BoosterIcon, { + IconName, +} from "@/app/governance/boosters/components/BoosterIcon"; +import { Boost } from "@/app/governance/boosters/data/BoostData"; +import { useModal } from "@/app/core/context/ModalContext"; +import { DetailedBoosterCard } from "./DetailedBoosterCard"; +import { mapBoostToDetailedCardProps } from "../data/BoostData"; + +export function BoosterCard({ + boost, + iconName, + boosterName, + verified, + boostAmount, + descriptionShort, + descriptionLong, + expiration, + expirationUrgent = false, +}: { + boost: Boost; + iconName: string; + boosterName: string; + verified: boolean; + boostAmount: string; + descriptionShort: string; + descriptionLong: string; + expiration: number | undefined; + expirationUrgent: boolean; +}) { + return ( +
+ {header(iconName, boosterName, verified)} + {boostPowerSection(boostAmount)} +

{descriptionShort}

+ + {expiry(expiration, expirationUrgent, "Helpful information loading...")} +
+ ); +} + +export function header( + iconName: string, + boosterName: string, + verified: boolean, + extraItem?: ReactElement | null +): ReactElement { + return ( +
+
+ {getIcon(iconName)} + + {boosterName} + + {extraItem && verifiedBadge(verified)} +
+ {extraItem ? extraItem : verifiedBadge(verified)} +
+ ); +} + +function verifiedBadge(verified: boolean): ReactElement { + const colorClass = verified + ? "text-status-success bg-status-success/10" + : "bg-[rgba(152,151,151,0.1)]"; + return ( +
+ {verified ? "Verified" : "Unverified"} +
+ ); +} + +export function boostPowerSection(amount: string): ReactElement { + return ( +
+
+ {/* This element simply adds the gradient. It has opacity and layers ontop of the solid background */} +
+

+ x{amount} +

+
+

Voting power boost

+ Helpful information loading... +
+
+ ); +} + +function getIcon(iconName: string): ReactElement { + // To be updated once we have actual icons + const iconNames = Object.values(IconName); + const randomIconName = iconNames[ + Math.floor(Math.random() * iconNames.length) + ] as IconName; + return ( + + ); +} + +// This is React function since it makes use of useModel, which requires special React magic +const ViewButton = ({ + verified, + boost, +}: { + verified: boolean; + boost: Boost; +}) => { + const { setModal } = useModal(); + + const openModal = () => { + setModal({ + type: "GENERIC_MODAL", + showCloseButton: false, + includeContainerStyling: false, + children: ( + setModal(null)} + {...mapBoostToDetailedCardProps(boost)} + /> + ), + }); + }; + + const buttonStyles = verified + ? "bg-[rgba(152,151,151,0.1)] dark:bg-breadgray-charcoal text-status-success" // Verified + : "bg-[#FFCCF1] dark:bg-[#402639] text-breadviolet-violet dark:text-breadpink-shaded"; // Not verified + + const verifiedButtonContent = ( +
+
+ +
+ Verified + + view + +
+ ); + + return boosterCardButton( + openModal, + buttonStyles, + verified ? verifiedButtonContent : "View" + ); +}; + +export function boosterCardButton( + action: () => void, + styles: String, + content: String | ReactElement +): ReactElement { + return ( + + ); +} + +export function expiry( + expiration: number | undefined, + expirationUrgent: boolean, + tooltipContent: string +): ReactElement | undefined { + if (!expiration) { + return undefined; + } + const textColorClass = expirationUrgent + ? "text-[#F2D54E]" + : "text-breadgray-grey"; + return ( +
+ + {expiration} days until booster expires + + {tooltipContent} +
+ ); +} diff --git a/src/app/governance/boosters/components/BoosterIcon.tsx b/src/app/governance/boosters/components/BoosterIcon.tsx new file mode 100644 index 00000000..9ddb1c57 --- /dev/null +++ b/src/app/governance/boosters/components/BoosterIcon.tsx @@ -0,0 +1,42 @@ +import React, { ReactElement } from "react"; +import { Metamask } from "@/app/core/components/Icons/Metamask"; +import { Coinbase } from "@/app/core/components/Icons/Coinbase"; +import { BreadSVG } from "@/app/core/components/Icons/Bread"; + +// To be filled in once we have the appropriate icons +export enum IconName { + MetaMask = "metaMask", + Coinbase = "coinBase", + Network = "network" +} + +interface SvgIconProps { + name: IconName; + className?: string; +} + +const getIcon = (name: IconName): ReactElement => { + switch (name) { + case "metaMask": + return Metamask(); + case "coinBase": + return Coinbase(); + case "network": + return BreadSVG({}); + default: + throw new Error("Invalid icon name"); + } +}; + +function BoosterIcon({ name, className }: SvgIconProps) { + const wrapperClasses = `inline-flex items-center justify-center rounded-full overflow-hidden w-[45px] h-[45px] ${className || ""}`; + const IconComponent = getIcon(name); + + return ( +
+ {IconComponent} +
+ ); +} + +export default BoosterIcon; diff --git a/src/app/governance/boosters/components/DetailedBoosterCard.tsx b/src/app/governance/boosters/components/DetailedBoosterCard.tsx new file mode 100644 index 00000000..c2072baf --- /dev/null +++ b/src/app/governance/boosters/components/DetailedBoosterCard.tsx @@ -0,0 +1,192 @@ +import React, { ReactElement } from "react"; +import { + header, + boostPowerSection, + boosterCardButton, + expiry, +} from "@/app/governance/boosters/components/BoosterCard"; +import CloseIcon from "@/app/core/components/Icons/CloseIcon"; +import CheckDoubleIcon from "@/app/core/components/Icons/CheckDouble"; + +export interface ProgressItem { + title: string; + value: number; + achieved: boolean; +} + +export function DetailedBoosterCard({ + iconName, + boosterName, + verified, + boostAmount, + descriptionShort, + descriptionLong, + expiration, + expirationUrgent = false, + progress, + requirements, + close, +}: { + iconName: string; + boosterName: string; + verified: boolean; + boostAmount: string; + descriptionShort: string; + descriptionLong: string; + expiration: number | undefined; + expirationUrgent: boolean; + progress: ProgressItem[]; + requirements: ProgressItem[]; + close: () => void; +}) { + return ( +
+ {header(iconName, boosterName, verified, closeIcon(close))} + {boostPowerSection(boostAmount)} + {detailsSection(descriptionLong, requirements, progress, boostAmount)} + {buttons()} + {expiry(expiration, expirationUrgent, "Helpful information loading...")} +
+ ); +} + +function closeIcon(close: () => void): ReactElement { + return ( + + ); +} + +function buttons(): ReactElement { + const buttonStyleVerify = + "bg-[#FFCCF1] dark:bg-[#402639] text-breadviolet-violet dark:text-breadpink-shaded"; + const buttonStyleGet = + "text-[#FFCCF1] dark:text-[#402639] bg-breadviolet-violet dark:bg-breadpink-shaded"; + return ( + <> + {boosterCardButton(close, buttonStyleGet, "Get")} + {boosterCardButton(close, buttonStyleVerify, "Verify")} + + ); +} + +function detailsSection( + description: String, + requirements: ProgressItem[], + progress: ProgressItem[], + boostAmount: string +): ReactElement { + return ( +
+ {progress.length > 0 && boostProgress(progress, Number(boostAmount))} + {requirements.length > 0 && requirementsList(requirements)} +

{description}

+
+ ); +} + +// Displays the horizontal progress bar with text beneath it +// Note, this component expects Boost Progress to be ordered with completed items at the start. +function boostProgress( + progress: ProgressItem[], + boostAmount: number +): ReactElement { + let numberItems = progress.length; + let numberCompleted = progress.findIndex((item) => item.value > boostAmount); + if (numberCompleted == -1) { + numberCompleted = numberItems; + } + const percentComplete = + numberItems > 0 ? (numberCompleted / numberItems) * 100 : 5; + return ( +
+ {progressBar(percentComplete)} + {/* Sequential items display */} +
+ {progress.map((item, index) => ( + + {item.title != "" && ( +
+ {progressText(item.value.toString())} +

{item.title}

+
+ )} +
+ ))} +
+
+ ); +} + +function progressText(title: String | null): ReactElement { + return ( +
+ x{title} +
+ ); +} + +function progressBar(percentComplete: Number): ReactElement { + return ( +
+
+
+
+
+ ); +} + +function requirementsList(requirements: ProgressItem[]): ReactElement { + return ( +
+ {requirements.map((item) => ( + + {requirement(item.title, item.achieved)} + + ))} +
+ ); +} + +// Displays a requirement line item, with the tick or cross icon next to it +function requirement(text: String, complete: Boolean): ReactElement { + return ( +
+ {complete ? ( + + {CheckDoubleIcon()} + + ) : ( + + {CloseIcon()} + + )} + + {text} + +
+ ); +} diff --git a/src/app/governance/boosters/components/VotingPowerPanel.tsx b/src/app/governance/boosters/components/VotingPowerPanel.tsx new file mode 100644 index 00000000..fa8ab7d4 --- /dev/null +++ b/src/app/governance/boosters/components/VotingPowerPanel.tsx @@ -0,0 +1,228 @@ +import { ReactElement } from "react"; +import { CardBox } from "@/app/core/components/CardBox"; +import { FistIcon } from "@/app/core/components/Icons/FistIcon"; +import { AccountMenu } from "@/app/core/components/Header/AccountMenu"; +import { LinkIcon } from "@/app/core/components/Icons/LinkIcon"; +import { + TUserConnected, + useConnectedUser, +} from "@/app/core/hooks/useConnectedUser"; +import { useVotingPower } from "../../context/VotingPowerContext"; +import { formatBalance } from "@/app/core/util/formatter"; +import { useCurrentAccumulatedVotingPower } from "../../useCurrentAccumulatedVotingPower"; +import Elipsis from "@/app/core/components/Elipsis"; +import { useTokenBalances } from "@/app/core/context/TokenBalanceContext/TokenBalanceContext"; + +import { useCycleLength } from "../../useCycleLength"; +import Tooltip from "@/app/core/components/Tooltip"; +import { useDistributions } from "../../useDistributions"; +import { useVotingPowerMultiplier } from "../../useVotingPowerMultiplier"; + +export function VotingPowerPanel() { + const { user } = useConnectedUser(); + const votingPower = useVotingPower(); + const { totalDistributions } = useDistributions(); + const { BREAD } = useTokenBalances(); + + const renderFormattedDecimalNumber = ( + number: string, + icon?: ReactElement + ) => { + const part1 = number.split(".")[0]; + const part2 = number.split(".")[1]; + + return ( +
+
+ {icon &&
{icon}
} + {part1} +
+
.
+
{part2}
+
+ ); + }; + + return ( +
+
+ +
+

+ MY VOTING POWER +

+
+
+
+ {votingPower && + votingPower.bread.status === "success" && + votingPower.butteredBread.status === "success" ? ( + renderFormattedDecimalNumber( + formatBalance( + Number( + votingPower.bread.value + + votingPower.butteredBread.value + ) / + 10 ** 18, + 1 + ), + + ) + ) : ( +
+ + - +
+ )} +
+
+
+ {user.status === "CONNECTED" ? ( + <> + + + + + ) : ( + <> + )} +
+
+ Accessible voting power + + Your total available voting power for the current voting cycle + #{totalDistributions ? totalDistributions + 1 + "." : "-"} + +
+
+ + {/* voting power grid */} +
+ + +

+ Voting power from locked LP +

+ + + {votingPower && votingPower.butteredBread.status === "success" + ? formatBalance( + Number(votingPower.butteredBread.value) / 10 ** 18, + 1 + ) + : "-"} + + +

+ Voting power from $BREAD +

+ + {votingPower && votingPower.bread.status === "success" + ? formatBalance(Number(votingPower.bread.value) / 10 ** 18, 1) + : "-"} + + + +

+ Total locked LP tokens +

+ + + TODO + + + {user.status === "CONNECTED" && ( + <> +

+ Total $BREAD baked +

+ + + {BREAD && BREAD.status === "SUCCESS" + ? formatBalance(parseFloat(BREAD.value), 2) + : "-"} + + + )} + + {user.status === "CONNECTED" ? ( + <> + + +

+

+ Pending voting power + + The voting power you will receive in the next voting + cycle. + +
+

+ + + TODO{" "} + + + ) : ( + <> + )} + + {user.status === "CONNECTED" ? ( + <> + ) : ( +
+ + Connect + +
+ )} +
+ + How does this work? +
+ +
+
+
+
+
+
+ ); +} + +function VotingMultiplierDisplay({ user }: { user: TUserConnected }) { + const { + status: votingPowerMultiplierStatus, + data: votingPowerMultiplierData, + } = useVotingPowerMultiplier(user); + + return votingPowerMultiplierStatus === "success" && + votingPowerMultiplierData ? ( + formatBalance(Number(votingPowerMultiplierData) / 10 ** 18, 2) + ) : ( + + ); +} + +function DividerWithText({ text }: { text: string }) { + return ( +
+
+ + {text} + +
+
+ ); +} + +function Divider() { + return ( +
+ ); +} diff --git a/src/app/governance/boosters/data/BoostData.tsx b/src/app/governance/boosters/data/BoostData.tsx new file mode 100644 index 00000000..88ab8115 --- /dev/null +++ b/src/app/governance/boosters/data/BoostData.tsx @@ -0,0 +1,127 @@ +// Boost is just an example interface, to be replaced by whatever +// aggregate of data sources end up being needed. +// As long as the mapping functions can still map the real data +// sources to the input props, all will be well. +export interface Boost { + iconName: string; + boosterName: string; + verified: boolean; + boostAmount: string; + descriptionShort: string; + descriptionLong: string; + expiration: Date; + expirationUrgent: boolean; + progress: BoostProgress[]; + requirements: BoostRequirement[]; +} + +export interface BoostProgress { + name: string; + subtitle: string; + value: number; + achieved: boolean; +} + +export interface BoostRequirement { + name: string; + achieved: boolean; +} + +// Mapping Boost to view properties +export function mapBoostToCardProps(boost: Boost) { + const now = new Date(); + const timeDifference = boost.expiration.getTime() - now.getTime(); + const daysUntilExpiration = Math.ceil(timeDifference / (1000 * 60 * 60 * 24)); + + return { + iconName: boost.iconName, + boosterName: boost.boosterName, + verified: boost.verified, + boostAmount: boost.boostAmount, + descriptionShort: boost.descriptionShort, + descriptionLong: boost.descriptionLong, + expiration: daysUntilExpiration, + expirationUrgent: boost.expirationUrgent, + }; +} + +export function mapBoostToDetailedCardProps(boost: Boost) { + const baseProps = mapBoostToCardProps(boost); + const progress = boost.progress.map((item) => ({ + title: item.name, + subtitle: item.subtitle, + value: item.value, + })); + const requirements = boost.requirements.map((item) => ({ + title: item.name, + subtitle: null, + achieved: item.achieved, + })); + + return { + ...baseProps, + progress: progress, + requirements: requirements, + }; +} + +const boostData: Boost[] = [ + { + iconName: "bullseye", + boosterName: "Voting Streaks", + verified: false, // TODO: should check if user has an active boost and if so, set to true + boostAmount: "1.02", // TODO: should fetch from smart contract + descriptionShort: "Maintain a Breadchain voting cycle streak.", + descriptionLong: + "Maintain a Breadchain voting cycle streak. Break your streak and you will have to start over.", + expiration: new Date("2025-12-31"), // TODO: should fetch from userToValidUntil + expirationUrgent: false, // TODO: should be true if expiration is within 30 days + progress: [ + { + name: "Start (no booster)", + subtitle: "", + value: 1.0, + achieved: true, + }, + { + name: "", + subtitle: "", + value: 1.01, + achieved: true, + }, + { + name: "Streak of 2 voting cycles", + subtitle: "", + value: 1.02, + achieved: true, + }, + { + name: "", + subtitle: "", + value: 1.03, + achieved: true, + }, + { + name: "Streak of 4 voting cycles", + subtitle: "", + value: 1.04, + achieved: false, + }, + { + name: "", + subtitle: "", + value: 1.05, + achieved: true, + }, + { + name: "Streak of 6 voting cycles", + subtitle: "", + value: 1.06, + achieved: false, + }, + ], + requirements: [], + }, +]; + +export { boostData }; diff --git a/src/app/governance/boosters/page.tsx b/src/app/governance/boosters/page.tsx new file mode 100644 index 00000000..0d8eb3d7 --- /dev/null +++ b/src/app/governance/boosters/page.tsx @@ -0,0 +1,18 @@ +import { notFound } from "next/navigation"; +import { Metadata } from "next"; +import { parseFeatureVar } from "@/app/core/util/parseFeatureVar"; +import { BoosterPage } from "./BoosterPage"; + +export const metadata: Metadata = { + title: "Voting power boosters", + description: + "Get rewarded with voting power boosters. Fund post-capitalist web3.", +}; + +export default function Boosters() { + if (!parseFeatureVar(process.env.FEATURE_BOOSTERS)) { + notFound(); + } + + return ; +} diff --git a/src/app/governance/layout.tsx b/src/app/governance/layout.tsx index 47e1fbce..b1b0ecda 100644 --- a/src/app/governance/layout.tsx +++ b/src/app/governance/layout.tsx @@ -67,6 +67,23 @@ function GovernanceNavigation() {
LP Vaults + +
+ + + +
+ Voting power boosters +
); diff --git a/src/app/governance/useVotingPowerMultiplier.tsx b/src/app/governance/useVotingPowerMultiplier.tsx new file mode 100644 index 00000000..e9e4fdab --- /dev/null +++ b/src/app/governance/useVotingPowerMultiplier.tsx @@ -0,0 +1,20 @@ +import { TUserConnected } from "@/app/core/hooks/useConnectedUser"; +import { getChain } from "@/chainConfig"; +import { DISTRIBUTOR_ABI } from "@/abi"; +import { useReadContract } from "wagmi"; + +export function useVotingPowerMultiplier(user: TUserConnected) { + const chainConfig = getChain(user.chain.id); + + const { status, data } = useReadContract({ + address: chainConfig.DISBURSER.address, + abi: DISTRIBUTOR_ABI, + functionName: "getTotalMultipliers", + args: [user.address], + }); + + return { + data, + status, + }; +}