From dfefba004435e32becc6a0b7abe78e78a7e90619 Mon Sep 17 00:00:00 2001 From: eightrice Date: Mon, 14 Sep 2026 13:50:24 +0300 Subject: [PATCH] feat: DAO email alerts and poll-to-chain promotion Email alerts (Tezos DAOs): - Subscribe box on the DAO overview page posting to the lite backend /subscriptions; hidden on Etherlink - Confirm and unsubscribe links land back in the app with ?alerts=..., acknowledged with a toast by a shared hook on the DAO overview and DAO list pages - DAO router redirect to /overview keeps the query string - Pending votes banner for the connected account Lite polls: - Optional funding request (recipient, XTZ amount) on poll creation, with a treasury balance hint for communities linked to an on-chain DAO - Funding request card on the poll page with a "Create on-chain transfer proposal" button that opens the pre-filled transfer form on the linked baseDAO, then links the new proposal back to the poll via /polls/:id/link-proposal - The proposal key is inferred as the newest proposal by the same wallet, polled from the indexer after submission Also fixes a dev-mode crash on every lite poll page: the status badge passed a static theme prop into an emotion styled component, which MUI v6 then tried to mutate on frozen props. env.example documents REACT_APP_LITE_API_URL. Claude-Session: https://claude.ai/code/session_01UG9AFKKU31ba2d9QNLV44R --- env.example | 2 + src/models/Polls.ts | 20 ++ .../explorer/components/DAOEmailAlerts.tsx | 125 +++++++++ .../components/PendingVotesBanner.tsx | 112 ++++++++ .../explorer/components/ProposalForm.tsx | 14 +- .../explorer/hooks/useAlertsOutcomeToast.ts | 42 +++ src/modules/explorer/pages/DAO/index.tsx | 10 + src/modules/explorer/pages/DAO/router.tsx | 8 +- src/modules/explorer/pages/DAOList/index.tsx | 2 + .../components/PollFundingRequest.tsx | 262 ++++++++++++++++++ .../ProposalTableRowStatusBadge.tsx | 3 +- .../components/TreasuryBalanceHint.tsx | 52 ++++ .../explorer/hooks/useDAOTreasuryBalance.tsx | 30 ++ .../hooks/useNewestOwnProposalKey.tsx | 107 +++++++ src/modules/lite/explorer/hooks/usePoll.tsx | 4 +- .../explorer/pages/CreateProposal/index.tsx | 133 ++++++++- .../explorer/pages/ProposalDetails/index.tsx | 8 +- src/services/services/lite/lite-services.ts | 38 +++ 18 files changed, 958 insertions(+), 14 deletions(-) create mode 100644 src/modules/explorer/components/DAOEmailAlerts.tsx create mode 100644 src/modules/explorer/components/PendingVotesBanner.tsx create mode 100644 src/modules/explorer/hooks/useAlertsOutcomeToast.ts create mode 100644 src/modules/lite/explorer/components/PollFundingRequest.tsx create mode 100644 src/modules/lite/explorer/components/TreasuryBalanceHint.tsx create mode 100644 src/modules/lite/explorer/hooks/useDAOTreasuryBalance.tsx create mode 100644 src/modules/lite/explorer/hooks/useNewestOwnProposalKey.tsx diff --git a/env.example b/env.example index 7c2b35599..959306ed7 100644 --- a/env.example +++ b/env.example @@ -4,6 +4,8 @@ REACT_APP_CORS_PROXY_URL=http://localhost:8001 REACT_APP_DAO_DEPLOYER_API=http://localhost:3001 REACT_APP_ENV=DEV REACT_APP_HASURA_URL=http://localhost:8080/v1/graphql +# Homebase Lite backend: off-chain polls, communities, and DAO email alerts +REACT_APP_LITE_API_URL=http://localhost:3005 REACT_APP_LAUNCH_DARKLY_SDK_DEV=your_launch_darkly_sdk_key_here REACT_APP_MIXPANEL_DEBUG_ENABLED=false diff --git a/src/models/Polls.ts b/src/models/Polls.ts index 800ca70f3..9fbf567ef 100644 --- a/src/models/Polls.ts +++ b/src/models/Polls.ts @@ -2,6 +2,17 @@ export enum ProposalStatus { ACTIVE = "active", CLOSED = "closed" } +export interface PollFundingRequest { + recipient: string + amount: string +} + +export interface PollOnchainProposal { + daoAddress: string + proposalKey: string + network: string +} + export interface Poll { _id?: string daoID: string | undefined @@ -27,6 +38,15 @@ export interface Poll { isXTZ: boolean id?: string getStatus?: any + // Optional treasury funding attached to the poll. Sent to the lite backend + // inside the signed payload and returned by the poll read endpoints. + fundingRequest?: PollFundingRequest + // Set by the backend once the poll has been promoted to an on-chain proposal. + onchainProposal?: PollOnchainProposal + // Form-only fields for the optional "Funding request" section. They are + // folded into `fundingRequest` (or dropped) before the payload is signed. + fundingRecipient?: string + fundingAmount?: string } export interface Vote { diff --git a/src/modules/explorer/components/DAOEmailAlerts.tsx b/src/modules/explorer/components/DAOEmailAlerts.tsx new file mode 100644 index 000000000..093708fa6 --- /dev/null +++ b/src/modules/explorer/components/DAOEmailAlerts.tsx @@ -0,0 +1,125 @@ +import React, { useState } from "react" +import { Grid, styled, TextField, Typography } from "@mui/material" +import { SmallButton } from "modules/common/SmallButton" +import { useNotification } from "modules/common/hooks/useNotification" +import { useTezos } from "services/beacon/hooks/useTezos" +import { subscribeToDAOAlerts } from "services/services/lite/lite-services" +import { ContentContainer } from "./ContentContainer" + +const AlertsContainer = styled(ContentContainer)(({ theme }) => ({ + padding: "24px 38px", + [theme.breakpoints.down("lg")]: { + width: "inherit" + } +})) + +const TitleText = styled(Typography)({ + fontSize: 18, + fontWeight: 500 +}) + +const HelperText = styled(Typography)(({ theme }) => ({ + fontSize: 14, + fontWeight: 300, + color: theme.palette.primary.light +})) + +const EmailInput = styled(TextField)({ + "background": "#2f3438", + "borderRadius": 8, + "flex": "1 1 280px", + "maxWidth": 480, + "& .MuiInputBase-input": { + padding: "12px 16px", + fontSize: 16, + fontWeight: 300 + } +}) + +// Deliberately permissive: the backend is the authority on deliverability, this +// only stops obviously malformed input from costing a round trip. +const looksLikeEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim()) + +export const DAOEmailAlerts: React.FC<{ daoAddress: string; daoName?: string }> = ({ daoAddress, daoName }) => { + const { network } = useTezos() + const openNotification = useNotification() + const [email, setEmail] = useState("") + const [isSubmitting, setIsSubmitting] = useState(false) + const [confirmationSent, setConfirmationSent] = useState(false) + + const onSubscribe = async () => { + if (!looksLikeEmail(email)) { + openNotification({ + message: "Please enter a valid email address", + autoHideDuration: 3000, + variant: "error" + }) + return + } + + try { + setIsSubmitting(true) + const resp = await subscribeToDAOAlerts(email.trim(), daoAddress, network, daoName) + + if (!resp.ok) { + openNotification({ + message: "Could not subscribe to email alerts", + autoHideDuration: 3000, + variant: "error" + }) + return + } + + setConfirmationSent(true) + setEmail("") + openNotification({ + message: "Check your inbox to confirm", + autoHideDuration: 5000, + variant: "success" + }) + } catch (error) { + console.log("error: ", error) + openNotification({ + message: "Could not subscribe to email alerts", + autoHideDuration: 3000, + variant: "error" + }) + } finally { + setIsSubmitting(false) + } + } + + return ( + + + + Get email alerts for this DAO + Be notified when proposals are created and when voting is about to close. + + + setEmail(event.target.value)} + onKeyDown={event => { + if (event.key === "Enter" && !isSubmitting) { + onSubscribe() + } + }} + /> + + {isSubmitting ? "Subscribing..." : "Subscribe"} + + + {confirmationSent ? ( + + Check your inbox to confirm + + ) : null} + + + ) +} diff --git a/src/modules/explorer/components/PendingVotesBanner.tsx b/src/modules/explorer/components/PendingVotesBanner.tsx new file mode 100644 index 000000000..3e6fdc346 --- /dev/null +++ b/src/modules/explorer/components/PendingVotesBanner.tsx @@ -0,0 +1,112 @@ +import React, { useMemo } from "react" +import { Grid, styled, Typography } from "@mui/material" +import HowToVoteIcon from "@mui/icons-material/HowToVote" +import { useHistory } from "react-router-dom" +import { useDAO } from "services/services/dao/hooks/useDAO" +import { useProposals } from "services/services/dao/hooks/useProposals" +import { ProposalStatus } from "services/services/dao/mappers/proposal/types" +import { useTezos } from "services/beacon/hooks/useTezos" +import { useDAOID } from "../pages/DAO/router" +import { ContentContainer } from "./ContentContainer" + +const BannerContainer = styled(ContentContainer)(({ theme }) => ({ + "padding": "18px 38px", + "cursor": "pointer", + "border": `1px solid ${theme.palette.secondary.main}`, + "&:hover": { + opacity: 0.9 + }, + [theme.breakpoints.down("lg")]: { + width: "inherit" + } +})) + +const BannerText = styled(Typography)({ + fontSize: 16, + fontWeight: 500 +}) + +const BannerHint = styled(Typography)(({ theme }) => ({ + fontSize: 14, + fontWeight: 300, + color: theme.palette.primary.light +})) + +/** + * Nudges a governance-token holder towards proposals that are open for voting + * and that they have not voted on yet. + */ +export const PendingVotesBanner: React.FC = () => { + const daoId = useDAOID() + const navigate = useHistory() + const { account } = useTezos() + const { data: dao, cycleInfo, ledger } = useDAO(daoId) + const { data: proposals } = useProposals(daoId) + + // Only nudge people who actually have a stake in this DAO. + const isTokenHolder = useMemo(() => { + if (!account || !ledger) { + return false + } + + return ledger.some( + entry => entry.holder.address.toLowerCase() === account.toLowerCase() && entry.total_balance.gt(0) + ) + }, [account, ledger]) + + const pendingProposals = useMemo(() => { + if (!proposals || !cycleInfo || !account) { + return [] + } + + return proposals.filter(proposal => { + const status = proposal.getStatus(cycleInfo.currentLevel).status + if (status !== ProposalStatus.ACTIVE) { + return false + } + + return !proposal.voters.some( + (voter: { address: string }) => voter.address.toLowerCase() === account.toLowerCase() + ) + }) + }, [proposals, cycleInfo, account]) + + // The DAO alternates proposing/voting periods of `period` blocks. Only show a + // countdown when we have both the blocks left and an average block time. + const closesIn = useMemo(() => { + if (!cycleInfo || cycleInfo.type !== "voting" || !cycleInfo.timeEstimateForNextBlock) { + return undefined + } + + const secondsLeft = cycleInfo.blocksLeft * cycleInfo.timeEstimateForNextBlock + if (!Number.isFinite(secondsLeft) || secondsLeft <= 0) { + return undefined + } + + const hoursLeft = Math.round(secondsLeft / 3600) + if (hoursLeft < 1) { + return `${Math.max(1, Math.round(secondsLeft / 60))} minutes` + } + + return `${hoursLeft} ${hoursLeft === 1 ? "hour" : "hours"}` + }, [cycleInfo]) + + if (!dao || !isTokenHolder || pendingProposals.length === 0) { + return null + } + + return ( + navigate.push(`/explorer/dao/${daoId}/proposals`)}> + + + + + {pendingProposals.length} {pendingProposals.length === 1 ? "proposal is" : "proposals are"} waiting for your + vote + + {closesIn ? Voting closes in {closesIn} : null} + + + + ) +} diff --git a/src/modules/explorer/components/ProposalForm.tsx b/src/modules/explorer/components/ProposalForm.tsx index 12691f1dd..22e9d0af5 100644 --- a/src/modules/explorer/components/ProposalForm.tsx +++ b/src/modules/explorer/components/ProposalForm.tsx @@ -60,6 +60,9 @@ interface Props { handleClose: () => void defaultValues?: ProposalFormDefaultValues defaultTab: number + // Fired only when the form is actually submitted, so callers can tell a + // submit apart from a plain dismiss. + onSubmitted?: () => void } const enabledForms: Record< @@ -92,7 +95,13 @@ const Content = styled(Grid)({ paddingBottom: 24 }) -export const ProposalFormContainer: React.FC = ({ open, handleClose, defaultValues, defaultTab }) => { +export const ProposalFormContainer: React.FC = ({ + open, + handleClose, + defaultValues, + defaultTab, + onSubmitted +}) => { const daoId = useDAOID() const { data: dao } = useDAO(daoId) const { data: daoHoldings } = useDAOHoldings(daoId) @@ -159,8 +168,9 @@ export const ProposalFormContainer: React.FC = ({ open, handleClose, defa methods.reset() handleClose() + onSubmitted?.() }, - [dao, handleClose, methods, registryMutate] + [dao, handleClose, methods, registryMutate, onSubmitted] ) return ( diff --git a/src/modules/explorer/hooks/useAlertsOutcomeToast.ts b/src/modules/explorer/hooks/useAlertsOutcomeToast.ts new file mode 100644 index 000000000..0b2b7cbeb --- /dev/null +++ b/src/modules/explorer/hooks/useAlertsOutcomeToast.ts @@ -0,0 +1,42 @@ +import { useEffect } from "react" +import { useHistory, useLocation } from "react-router-dom" +import { useNotification } from "modules/common/hooks/useNotification" + +const OUTCOMES: Record = { + confirmed: { message: "Email alerts confirmed", variant: "success" }, + unsubscribed: { message: "Unsubscribed from email alerts", variant: "info" }, + invalid: { message: "This email alerts link is no longer valid", variant: "error" } +} + +/** + * The confirm/unsubscribe links in the alert emails land back in the app with + * ?alerts=confirmed|unsubscribed|invalid. Show the outcome once and drop the + * param, leaving any other query params untouched. + */ +export const useAlertsOutcomeToast = () => { + const location = useLocation() + const history = useHistory() + const openNotification = useNotification() + const alertsParam = new URLSearchParams(location.search).get("alerts") + + useEffect(() => { + if (!alertsParam) { + return + } + + const outcome = OUTCOMES[alertsParam] + if (outcome) { + openNotification({ + message: outcome.message, + autoHideDuration: 5000, + variant: outcome.variant + }) + } + + const searchParams = new URLSearchParams(location.search) + searchParams.delete("alerts") + history.replace({ pathname: location.pathname, search: searchParams.toString() }) + // openNotification is recreated on every render, so it is deliberately not a dependency. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [alertsParam]) +} diff --git a/src/modules/explorer/pages/DAO/index.tsx b/src/modules/explorer/pages/DAO/index.tsx index 71c99126b..f57d5e890 100644 --- a/src/modules/explorer/pages/DAO/index.tsx +++ b/src/modules/explorer/pages/DAO/index.tsx @@ -8,6 +8,9 @@ import { useDAOID } from "./router" import { ContentContainer } from "../../components/ContentContainer" import { DAOStatsRow } from "../../components/DAOStatsRow" import { UsersTable } from "../../components/UsersTable" +import { DAOEmailAlerts } from "../../components/DAOEmailAlerts" +import { PendingVotesBanner } from "../../components/PendingVotesBanner" +import { useAlertsOutcomeToast } from "modules/explorer/hooks/useAlertsOutcomeToast" import { SmallButton } from "../../../common/SmallButton" import { DaoSettingModal } from "./components/Settings" @@ -95,6 +98,8 @@ export const DAOOverview: React.FC = () => { const [openDialog, setOpenDialog] = useState(false) const [openChangeDialog, setChangeOpenDialog] = useState(false) + useAlertsOutcomeToast() + const handleCloseModal = () => { setOpenDialog(false) } @@ -125,6 +130,7 @@ export const DAOOverview: React.FC = () => { return ( + @@ -227,6 +233,10 @@ export const DAOOverview: React.FC = () => { + {data?.data.address && !data?.data.network?.startsWith("etherlink") ? ( + + ) : null} + diff --git a/src/modules/explorer/pages/DAO/router.tsx b/src/modules/explorer/pages/DAO/router.tsx index ed065b271..5091ff280 100644 --- a/src/modules/explorer/pages/DAO/router.tsx +++ b/src/modules/explorer/pages/DAO/router.tsx @@ -5,7 +5,7 @@ import { DAOOverview } from "modules/explorer/pages/DAO/index" import { User } from "modules/explorer/pages/User" import React, { useContext, useEffect, useState } from "react" import { useHistory } from "react-router" -import { Redirect, Route, RouteProps, Switch, useParams, useRouteMatch } from "react-router-dom" +import { Redirect, Route, RouteProps, Switch, useLocation, useParams, useRouteMatch } from "react-router-dom" import { Network } from "services/beacon" import { useTezos } from "services/beacon/hooks/useTezos" import { useDAO } from "services/services/dao/hooks/useDAO" @@ -91,7 +91,7 @@ const DAORoute: React.FC = ({ children, ...props }) => { const DAOContext = React.createContext("") -const DAOProvider: React.FC<{ daoId: string }> = ({ daoId, children }) => { +export const DAOProvider: React.FC<{ daoId: string }> = ({ daoId, children }) => { return {children} } @@ -101,6 +101,7 @@ export const useDAOID = () => { export const DAORouter = (): JSX.Element => { const match = useRouteMatch() + const { search } = useLocation() const { id: daoId } = useParams<{ id: string }>() return ( @@ -129,7 +130,8 @@ export const DAORouter = (): JSX.Element => { - + {/* Keep the query string: the email-alert links land on /explorer/dao/:id?alerts=... */} + diff --git a/src/modules/explorer/pages/DAOList/index.tsx b/src/modules/explorer/pages/DAOList/index.tsx index d55b8fa9d..e9732774f 100644 --- a/src/modules/explorer/pages/DAOList/index.tsx +++ b/src/modules/explorer/pages/DAOList/index.tsx @@ -17,6 +17,7 @@ import ReactPaginate from "react-paginate" import "./styles.css" import { LoadingLine } from "components/ui/LoadingLine" import { useQueryParam } from "modules/home/hooks/useQueryParam" +import { useAlertsOutcomeToast } from "modules/explorer/hooks/useAlertsOutcomeToast" import { getBlockExplorerUrl } from "modules/etherlink/utils" import AnalyticsService from "services/services/analytics" import { useRef } from "react" @@ -39,6 +40,7 @@ export const DAOList: React.FC = () => { const { network, etherlink, account } = useTezos() const history = useHistory() const location = useLocation() + useAlertsOutcomeToast() // Helper: clear multiple query params in a single history update to avoid push loops const clearQueryParams = useCallback( diff --git a/src/modules/lite/explorer/components/PollFundingRequest.tsx b/src/modules/lite/explorer/components/PollFundingRequest.tsx new file mode 100644 index 000000000..5f5310f6e --- /dev/null +++ b/src/modules/lite/explorer/components/PollFundingRequest.tsx @@ -0,0 +1,262 @@ +import React, { useMemo, useState } from "react" +import { Grid, styled, Typography } from "@mui/material" +import BigNumber from "bignumber.js" +import { Link as RouterLink } from "react-router-dom" +import { Poll } from "models/Polls" +import { Community } from "models/Community" +import { SmallButton } from "modules/common/SmallButton" +import { useNotification } from "modules/common/hooks/useNotification" +import { ProposalFormContainer } from "modules/explorer/components/ProposalForm" +import { DAOProvider } from "modules/explorer/pages/DAO/router" +import { Network } from "services/beacon" +import { useTezos } from "services/beacon/hooks/useTezos" +import { getSignature } from "services/lite/utils" +import { useIsProposalButtonDisabled } from "services/contracts/baseDAO/hooks/useCycleInfo" +import { getEthSignature } from "services/utils/utils" +import { linkPollToOnchainProposal } from "services/services/lite/lite-services" +import { useDAOTreasuryBalance } from "../hooks/useDAOTreasuryBalance" +import { useNewestOwnProposalKey } from "../hooks/useNewestOwnProposalKey" + +const Container = styled(Grid)(({ theme }) => ({ + background: theme.palette.secondary.light, + borderRadius: 8 +})) + +const CardContent = styled(Grid)(({ theme }) => ({ + padding: "40px 48px 42px 48px", + gap: 20, + [theme.breakpoints.down("lg")]: { + padding: "18px 25px" + } +})) + +const TitleText = styled(Typography)({ + fontSize: 24, + fontWeight: 600 +}) + +const RowLabel = styled(Typography)({ + fontSize: 18, + fontWeight: 600 +}) + +const RowValue = styled(Typography)({ + fontSize: 18, + fontWeight: 300, + wordBreak: "break-all" +}) + +const WarningText = styled(Typography)({ + fontSize: 16, + fontWeight: 300, + color: "#ED254E" +}) + +const HintText = styled(Typography)(({ theme }) => ({ + fontSize: 14, + fontWeight: 300, + marginTop: 8, + color: theme.palette.primary.light +})) + +const ProposalLink = styled(RouterLink)(({ theme }) => ({ + color: theme.palette.secondary.main, + fontSize: 18, + fontWeight: 300 +})) + +interface Props { + poll: Poll + community: Community | undefined + onLinked: () => void +} + +/** + * Renders a poll's optional funding request and offers to promote it to an + * on-chain transfer proposal on the community's linked baseDAO. + */ +export const PollFundingRequest: React.FC = ({ poll, community, onLinked }) => { + const { network, account, wallet, etherlink } = useTezos() + const openNotification = useNotification() + const daoContract = community?.daoContract + const { data: treasuryBalance } = useDAOTreasuryBalance(daoContract, (community?.network || network) as Network) + + const [isProposalFormOpen, setProposalFormOpen] = useState(false) + const [isLinking, setIsLinking] = useState(false) + const { startWatching, isWatching } = useNewestOwnProposalKey(daoContract) + // Same gate the explorer's own transfer entry points use: proposals can only + // be created during the proposing phase. + const isOutsideProposingPeriod = useIsProposalButtonDisabled(daoContract || "") + + const fundingRequest = poll.fundingRequest + + const requestedAmount = useMemo(() => { + if (!fundingRequest?.amount) { + return undefined + } + const amount = new BigNumber(fundingRequest.amount) + return amount.isFinite() ? amount : undefined + }, [fundingRequest]) + + const exceedsBalance = Boolean(requestedAmount && treasuryBalance && requestedAmount.gt(treasuryBalance)) + + // No outcome gate on purpose: an off-chain poll carries no authority, so the + // app only offers the shortcut and leaves the decision to the proposer. + const canPropose = Boolean(daoContract && (wallet || etherlink.isConnected) && !poll.onchainProposal) + + const linkProposal = async (proposalKey: string) => { + if (!daoContract || !poll._id) { + return + } + + const payload = { + daoAddress: daoContract, + proposalKey, + network: community?.network || network, + pollID: poll._id + } + + try { + setIsLinking(true) + + let signature: string | undefined + let payloadBytes: string + let publicKey: string | undefined + + if (wallet) { + const signed = await getSignature(account, wallet, JSON.stringify(payload)) + signature = signed.signature + payloadBytes = signed.payloadBytes + publicKey = (await wallet?.client.getActiveAccount())?.publicKey + } else { + publicKey = etherlink.account.address + const signed = await getEthSignature(publicKey, JSON.stringify(payload)) + signature = signed.signature + payloadBytes = signed.payloadBytes + } + + if (!signature) { + openNotification({ + message: `Issue with Signature`, + autoHideDuration: 3000, + variant: "error" + }) + return + } + + const resp = await linkPollToOnchainProposal(poll._id, signature, publicKey, payloadBytes, network) + + if (!resp.ok) { + const respData = await resp.json().catch(() => ({})) + openNotification({ + message: respData?.message || "Could not link the on-chain proposal", + autoHideDuration: 3000, + variant: "error" + }) + return + } + + openNotification({ + message: "On-chain proposal linked to this poll", + autoHideDuration: 5000, + variant: "success" + }) + onLinked() + } catch (error) { + console.log("error: ", error) + openNotification({ + message: "Could not link the on-chain proposal", + autoHideDuration: 3000, + variant: "error" + }) + } finally { + setIsLinking(false) + } + } + + // The proposal form fires and forgets, so watch the indexer for the proposal + // this account just created and link it once its key shows up. + const onProposalSubmitted = () => { + startWatching(linkProposal) + } + + if (!fundingRequest) { + return null + } + + const proposalFormDefaultValues = { + transferForm: { + transfers: [ + { + recipient: fundingRequest.recipient, + amount: Number(fundingRequest.amount), + asset: { symbol: "XTZ" as const } + } + ], + isBatch: false + } + } + + return ( + + + + Funding request + + + Recipient: + {fundingRequest.recipient} + + + Amount: + {fundingRequest.amount} XTZ + + {treasuryBalance ? ( + + DAO treasury balance: + {treasuryBalance.dp(6, BigNumber.ROUND_DOWN).toString()} XTZ + + ) : null} + {exceedsBalance ? ( + + Requested amount exceeds the DAO treasury balance + + ) : null} + + {poll.onchainProposal ? ( + + + On-chain proposal + + + ) : canPropose ? ( + + setProposalFormOpen(true)} + > + {isLinking || isWatching ? "Linking proposal..." : "Create on-chain transfer proposal"} + + {isOutsideProposingPeriod ? Not on proposal creation period : null} + + ) : null} + + + {daoContract ? ( + + setProposalFormOpen(false)} + defaultValues={proposalFormDefaultValues} + defaultTab={0} + onSubmitted={onProposalSubmitted} + /> + + ) : null} + + ) +} diff --git a/src/modules/lite/explorer/components/ProposalTableRowStatusBadge.tsx b/src/modules/lite/explorer/components/ProposalTableRowStatusBadge.tsx index a76ed2e71..6e872f729 100644 --- a/src/modules/lite/explorer/components/ProposalTableRowStatusBadge.tsx +++ b/src/modules/lite/explorer/components/ProposalTableRowStatusBadge.tsx @@ -2,7 +2,6 @@ import React from "react" import { Grid, GridProps, Typography } from "@mui/material" import { styled, Theme } from "@mui/material/styles" import hexToRgba from "hex-to-rgba" -import { theme } from "theme" export enum ProposalStatus { ACTIVE = "active", @@ -41,7 +40,7 @@ const Text = styled(Typography)({ }) export const TableStatusBadge: React.FC<{ status: ProposalStatus } & GridProps> = ({ status }) => ( - + {status} diff --git a/src/modules/lite/explorer/components/TreasuryBalanceHint.tsx b/src/modules/lite/explorer/components/TreasuryBalanceHint.tsx new file mode 100644 index 000000000..292a2e071 --- /dev/null +++ b/src/modules/lite/explorer/components/TreasuryBalanceHint.tsx @@ -0,0 +1,52 @@ +import React from "react" +import { Grid, styled, Typography } from "@mui/material" +import BigNumber from "bignumber.js" +import { Network } from "services/beacon" +import { useDAOTreasuryBalance } from "../hooks/useDAOTreasuryBalance" + +const HintText = styled(Typography)(({ theme }) => ({ + fontSize: 14, + fontWeight: 300, + color: theme.palette.primary.light +})) + +const WarningText = styled(Typography)({ + fontSize: 14, + fontWeight: 300, + color: "#ED254E" +}) + +interface Props { + daoContract: string | undefined + network: string | undefined + amount: string | undefined +} + +/** + * Shows the linked on-chain DAO's XTZ treasury balance next to a requested + * amount, and warns when the request cannot be covered. Renders nothing when + * the community has no on-chain DAO. + */ +export const TreasuryBalanceHint: React.FC = ({ daoContract, network, amount }) => { + const { data: balance } = useDAOTreasuryBalance(daoContract, network as Network) + + if (!daoContract || !balance) { + return null + } + + const parsedAmount = amount ? new BigNumber(amount) : undefined + const exceedsBalance = parsedAmount && parsedAmount.isFinite() && parsedAmount.gt(balance) + + return ( + + + DAO treasury balance: {balance.dp(6, BigNumber.ROUND_DOWN).toString()} XTZ + + {exceedsBalance ? ( + + Requested amount exceeds the DAO treasury balance + + ) : null} + + ) +} diff --git a/src/modules/lite/explorer/hooks/useDAOTreasuryBalance.tsx b/src/modules/lite/explorer/hooks/useDAOTreasuryBalance.tsx new file mode 100644 index 000000000..f1b38e315 --- /dev/null +++ b/src/modules/lite/explorer/hooks/useDAOTreasuryBalance.tsx @@ -0,0 +1,30 @@ +import { useQuery } from "@tanstack/react-query" +import BigNumber from "bignumber.js" +import { networkNameMap } from "services/bakingBad" +import { Network } from "services/beacon" +import { mutezToXtz } from "services/contracts/utils" + +/** + * XTZ balance of an on-chain DAO contract, read straight from TzKT. + * + * The explorer already has `useTezosBalance`, but that one depends on `useDAO` + * and therefore on the indexer plus the DAO route context. Lite pages live + * outside that context, so they read the balance directly by address. + */ +export const useDAOTreasuryBalance = (daoAddress: string | undefined, network: Network) => { + return useQuery({ + queryKey: ["daoTreasuryBalance", daoAddress, network], + queryFn: async () => { + const url = `https://api.${networkNameMap[network]}.tzkt.io/v1/accounts/${daoAddress}/balance` + const response = await fetch(url) + + if (!response.ok) { + throw new Error("Failed to fetch DAO treasury balance") + } + + const mutez = await response.json() + return mutezToXtz(new BigNumber(mutez)) + }, + enabled: !!daoAddress && !!networkNameMap[network] + }) +} diff --git a/src/modules/lite/explorer/hooks/useNewestOwnProposalKey.tsx b/src/modules/lite/explorer/hooks/useNewestOwnProposalKey.tsx new file mode 100644 index 000000000..1c722ec5e --- /dev/null +++ b/src/modules/lite/explorer/hooks/useNewestOwnProposalKey.tsx @@ -0,0 +1,107 @@ +import { useCallback, useEffect, useRef, useState } from "react" +import { useTezos } from "services/beacon/hooks/useTezos" +import { client } from "services/services/graphql" +import { GET_PROPOSALS_QUERY } from "services/services/dao/queries" + +const POLL_INTERVAL_MS = 10000 +const MAX_ATTEMPTS = 18 + +interface ProposalRow { + key: string + start_date: string + holder: { address: string } +} + +interface ProposalsResponse { + daos: { proposals: ProposalRow[] }[] +} + +/** + * The transfer-proposal form fires the origination and forgets it: nothing in + * the app returns the resulting proposal key, which only exists once the + * indexer has picked the operation up. + * + * `startWatching` therefore records the newest proposal key this account + * already has on the DAO, then polls the indexer until a newer one appears and + * hands its key to the callback. It gives up quietly after a few minutes. + */ +export const useNewestOwnProposalKey = (daoAddress: string | undefined) => { + const { account } = useTezos() + const [isWatching, setIsWatching] = useState(false) + const timerRef = useRef>() + const cancelledRef = useRef(false) + + useEffect(() => { + return () => { + cancelledRef.current = true + if (timerRef.current) { + clearTimeout(timerRef.current) + } + } + }, []) + + const fetchNewestOwnProposal = useCallback(async () => { + if (!daoAddress || !account) { + return undefined + } + + const response = await client.request(GET_PROPOSALS_QUERY, { address: daoAddress }) + const proposals = response.daos[0]?.proposals || [] + + return proposals + .filter(proposal => proposal.holder?.address?.toLowerCase() === account.toLowerCase()) + .sort((a, b) => new Date(b.start_date).getTime() - new Date(a.start_date).getTime())[0] + }, [daoAddress, account]) + + const startWatching = useCallback( + async (onFound: (proposalKey: string) => void) => { + if (!daoAddress || !account || isWatching) { + return + } + + cancelledRef.current = false + setIsWatching(true) + + let knownNewestKey: string | undefined + try { + knownNewestKey = (await fetchNewestOwnProposal())?.key + } catch (error) { + console.log("error: ", error) + } + + let attempts = 0 + + const tick = async () => { + if (cancelledRef.current) { + return + } + + attempts += 1 + + try { + const newest = await fetchNewestOwnProposal() + + if (newest?.key && newest.key !== knownNewestKey) { + setIsWatching(false) + onFound(newest.key) + return + } + } catch (error) { + console.log("error: ", error) + } + + if (attempts >= MAX_ATTEMPTS) { + setIsWatching(false) + return + } + + timerRef.current = setTimeout(tick, POLL_INTERVAL_MS) + } + + timerRef.current = setTimeout(tick, POLL_INTERVAL_MS) + }, + [daoAddress, account, isWatching, fetchNewestOwnProposal] + ) + + return { startWatching, isWatching } +} diff --git a/src/modules/lite/explorer/hooks/usePoll.tsx b/src/modules/lite/explorer/hooks/usePoll.tsx index c180e0540..4d0935a0b 100644 --- a/src/modules/lite/explorer/hooks/usePoll.tsx +++ b/src/modules/lite/explorer/hooks/usePoll.tsx @@ -6,7 +6,7 @@ import { isProposalActive } from "services/lite/utils" import { ProposalStatus } from "../components/ProposalTableRowStatusBadge" import { EnvKey, getEnv } from "services/config" -export const useSinglePoll = (pollId: string | undefined, id?: any, community?: any) => { +export const useSinglePoll = (pollId: string | undefined, id?: any, community?: any, refresh?: number) => { const [poll, setPoll] = useState() const openNotification = useNotification() @@ -49,6 +49,6 @@ export const useSinglePoll = (pollId: string | undefined, id?: any, community?: } fetchPoll() return - }, [id, community]) + }, [id, community, refresh]) return poll } diff --git a/src/modules/lite/explorer/pages/CreateProposal/index.tsx b/src/modules/lite/explorer/pages/CreateProposal/index.tsx index a4e2abb25..0f454211f 100644 --- a/src/modules/lite/explorer/pages/CreateProposal/index.tsx +++ b/src/modules/lite/explorer/pages/CreateProposal/index.tsx @@ -1,7 +1,19 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable react-hooks/exhaustive-deps */ import React, { useCallback, useState } from "react" -import { Grid, styled, Typography, TextareaAutosize, useTheme, useMediaQuery, Tooltip } from "@mui/material" +import { + Grid, + styled, + Typography, + TextareaAutosize, + useTheme, + useMediaQuery, + Tooltip, + Collapse, + Switch as ToggleSwitch +} from "@mui/material" +import BigNumber from "bignumber.js" +import { validateAddress, validateContractAddress, ValidationResult } from "@taquito/utils" import withStyles from "@mui/styles/withStyles" import withTheme from "@mui/styles/withTheme" @@ -29,6 +41,7 @@ import { ProposalCodeEditorInput } from "modules/explorer/components/ProposalFor import Prism, { highlight } from "prismjs" import "prism-themes/themes/prism-night-owl.css" import { useCommunity } from "../../hooks/useCommunity" +import { TreasuryBalanceHint } from "../../components/TreasuryBalanceHint" import { getEthSignature } from "services/utils/utils" dayjs.extend(duration) @@ -256,6 +269,39 @@ const hasDuplicates = (options: string[]) => { return new Set(trimOptions).size !== trimOptions.length } +// XTZ has 6 decimals on chain, so anything finer cannot be transferred. +const XTZ_AMOUNT_REGEX = /^\d+(\.\d{1,6})?$/ + +const isValidTezosAddress = (address: string) => + validateContractAddress(address) === ValidationResult.VALID || validateAddress(address) === ValidationResult.VALID + +/** + * The funding request is optional: both fields empty means "no request". + * If either is filled, both must be valid. + */ +const validateFundingRequest = (values: Poll, errors: FormikErrors) => { + const recipient = (values.fundingRecipient || "").trim() + const amount = (values.fundingAmount || "").trim() + + if (!recipient && !amount) { + return + } + + if (!recipient) { + errors.fundingRecipient = "Required when an amount is set" + } else if (!isValidTezosAddress(recipient)) { + errors.fundingRecipient = "Not a valid Tezos address" + } + + if (!amount) { + errors.fundingAmount = "Required when a recipient is set" + } else if (!XTZ_AMOUNT_REGEX.test(amount)) { + errors.fundingAmount = "Enter a positive amount with up to 6 decimals" + } else if (new BigNumber(amount).lte(0)) { + errors.fundingAmount = "Must be greater than zero" + } +} + const validateForm = (values: Poll) => { const errors: FormikErrors = {} @@ -314,6 +360,8 @@ const validateForm = (values: Poll) => { errors.endTimeDays = "Most be greater than zero" } + validateFundingRequest(values, errors) + return errors } @@ -350,6 +398,10 @@ export const ProposalForm = ({ const shouldShowBar = pathname.includes("/lite") ? true : false const [isMarkup, setIsMarkup] = useState(false) + // Reopen the section on re-render if the fields already hold something. + const [showFundingRequest, setShowFundingRequest] = useState( + Boolean(getIn(values, "fundingRecipient") || getIn(values, "fundingAmount")) + ) const grammar = Prism.languages.markup const codeEditorPlaceholder = ` @@ -576,6 +628,57 @@ export const ProposalForm = ({ {errors?.externalLink && touched.externalLink ? {errors.externalLink} : null} + + + { + const next = !showFundingRequest + setShowFundingRequest(next) + if (!next) { + setFieldValue("fundingRecipient", "") + setFieldValue("fundingAmount", "") + } + }} + /> + Funding request + + + + Optionally attach a treasury payout to this poll. If it passes, it can be promoted to an on-chain + transfer proposal. + + + + + + Recipient address + + {errors?.fundingRecipient && touched.fundingRecipient ? ( + {errors.fundingRecipient} + ) : null} + + + Amount (XTZ) + + {errors?.fundingAmount && touched.fundingAmount ? ( + {errors.fundingAmount} + ) : null} + + + + + + {isMobileSmall ? ( @@ -720,7 +823,29 @@ export const ProposalCreator: React.FC<{ id?: string; onClose?: any }> = props = endTimeDays: null, endTimeHours: null, endTimeMinutes: null, - isXTZ: false + isXTZ: false, + fundingRecipient: "", + fundingAmount: "" + } + + /** + * Turns the two flat form fields into the `fundingRequest` object the lite + * backend expects, and drops the form-only fields so they never reach the + * signed payload. Both fields empty means no funding request at all. + */ + const buildPollPayload = (values: Poll): Poll => { + const { fundingRecipient, fundingAmount, ...rest } = values + const recipient = (fundingRecipient || "").trim() + const amount = (fundingAmount || "").trim() + + if (!recipient || !amount) { + return rest as Poll + } + + return { + ...rest, + fundingRequest: { recipient, amount } + } as Poll } const saveProposal = useCallback( @@ -729,7 +854,7 @@ export const ProposalCreator: React.FC<{ id?: string; onClose?: any }> = props = if (wallet) { try { setIsLoading(true) - const data = values + const data = buildPollPayload(values) data.daoID = id data.startTime = String(dayjs().valueOf()) data.endTime = calculateEndTime(values.endTimeDays!, values.endTimeHours!, values.endTimeMinutes!) @@ -783,7 +908,7 @@ export const ProposalCreator: React.FC<{ id?: string; onClose?: any }> = props = } } else if (etherlink.isConnected) { try { - const data = values + const data = buildPollPayload(values) data.daoID = id data.startTime = String(dayjs().valueOf()) data.endTime = calculateEndTime(values.endTimeDays!, values.endTimeHours!, values.endTimeMinutes!) diff --git a/src/modules/lite/explorer/pages/ProposalDetails/index.tsx b/src/modules/lite/explorer/pages/ProposalDetails/index.tsx index 521308d36..7c59682cf 100644 --- a/src/modules/lite/explorer/pages/ProposalDetails/index.tsx +++ b/src/modules/lite/explorer/pages/ProposalDetails/index.tsx @@ -23,6 +23,7 @@ import { useIsMember } from "../../hooks/useIsMember" import { useHistoryLength } from "modules/explorer/context/HistoryLength" import { getEthSignature } from "services/utils/utils" import { SmallButton } from "modules/common/SmallButton" +import { PollFundingRequest } from "../../components/PollFundingRequest" const DescriptionText = styled(Typography)({ fontSize: 24, @@ -74,7 +75,7 @@ export const ProposalDetails: React.FC<{ id: string }> = ({ id }) => { const openNotification = useNotification() const [refresh, setRefresh] = useState() const community = useCommunity(id) - const poll = useSinglePoll(proposalId, id, community) + const poll = useSinglePoll(proposalId, id, community, refresh) const { state, pathname } = useLocation<{ poll: Poll; choices: Choice[]; daoId: string }>() const { data: dao } = useDAO(state?.daoId) const { data: voteWeight } = useTokenVoteWeight( @@ -225,6 +226,11 @@ export const ProposalDetails: React.FC<{ id: string }> = ({ id }) => { + {poll?.fundingRequest ? ( + + setRefresh(Math.random())} /> + + ) : null} {choices && choices.length > 0 ? ( <> diff --git a/src/services/services/lite/lite-services.ts b/src/services/services/lite/lite-services.ts index 0cf0697d8..5a2617556 100644 --- a/src/services/services/lite/lite-services.ts +++ b/src/services/services/lite/lite-services.ts @@ -204,6 +204,44 @@ export const updateCount = async (id: string) => { return resp } +export const subscribeToDAOAlerts = async (email: string, daoAddress: string, network: Network, daoName?: string) => { + const resp = await fetch(`${getEnv(EnvKey.REACT_APP_LITE_API_URL)}/subscriptions`, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + email, + daoAddress, + network, + daoName + }) + }) + return resp +} + +export const linkPollToOnchainProposal = async ( + pollId: string, + signature: string, + publicKey: string | undefined, + payloadBytes: string, + network: Network +) => { + const resp = await fetch(`${getEnv(EnvKey.REACT_APP_LITE_API_URL)}/polls/${pollId}/link-proposal`, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + signature, + publicKey, + payloadBytes, + network + }) + }) + return resp +} + export const fetchOffchainProposals = async (daoId: string) => { return await fetch(`${getEnv(EnvKey.REACT_APP_LITE_API_URL)}/daos/${daoId}?include=polls`, { method: "GET",