From 19b7dac82412169b938dfdd44432c29446436e96 Mon Sep 17 00:00:00 2001 From: Lucianosc Date: Wed, 30 Oct 2024 11:33:02 +0700 Subject: [PATCH 1/6] add display threshold required to pass --- apps/web/components/Forms/ProposalForm.tsx | 55 ++++++++++++++++++---- apps/web/hooks/useConvictionRead.ts | 10 +--- pkg/subgraph/.graphclient/index.d.ts | 2 +- pkg/subgraph/.graphclient/index.js | 1 + pkg/subgraph/src/query/queries.graphql | 1 + 5 files changed, 51 insertions(+), 18 deletions(-) diff --git a/apps/web/components/Forms/ProposalForm.tsx b/apps/web/components/Forms/ProposalForm.tsx index 8d9a044ca..1bdb0d96d 100644 --- a/apps/web/components/Forms/ProposalForm.tsx +++ b/apps/web/components/Forms/ProposalForm.tsx @@ -4,11 +4,12 @@ import React, { useState, useMemo } from "react"; import { usePathname, useRouter } from "next/navigation"; import { useForm } from "react-hook-form"; import { Address, encodeAbiParameters, parseUnits } from "viem"; -import { useToken } from "wagmi"; +import { useContractRead, useToken } from "wagmi"; import { Allo, ArbitrableConfig, CVStrategy, + CVStrategyConfig, TokenGarden, } from "#/subgraph/.graphclient"; import { FormInput } from "./FormInput"; @@ -16,16 +17,17 @@ import { FormPreview, FormRow } from "./FormPreview"; import { LoadingSpinner } from "../LoadingSpinner"; import { FormAddressInput } from "./FormAddressInput"; import { WalletBalance } from "../WalletBalance"; -import { Button, EthAddress } from "@/components"; +import { Button, EthAddress, InfoBox } from "@/components"; import { QUERY_PARAMS } from "@/constants/query-params"; import { usePubSubContext } from "@/contexts/pubsub.context"; +import { useChainIdFromPath } from "@/hooks/useChainIdFromPath"; import { useContractWriteWithConfirmations } from "@/hooks/useContractWriteWithConfirmations"; import { ConditionObject, useDisableButtons } from "@/hooks/useDisableButtons"; -import { alloABI } from "@/src/generated"; +import { alloABI, cvStrategyABI } from "@/src/generated"; import { PoolTypes } from "@/types"; import { getEventFromReceipt } from "@/utils/contracts"; import { ipfsJsonUpload } from "@/utils/ipfsUtils"; -import { formatTokenAmount } from "@/utils/numbers"; +import { calculatePercentageBigInt, formatTokenAmount } from "@/utils/numbers"; //protocol : 1 => means ipfs!, to do some checks later type FormInputs = { @@ -36,7 +38,12 @@ type FormInputs = { }; type ProposalFormProps = { - strategy: Pick; + strategy: Pick< + CVStrategy, + "id" | "token" | "maxCVSupply" | "totalEffectiveActivePoints" + > & { + config: Pick; + }; arbitrableConfig: Pick; poolId: number; proposalType: number; @@ -147,11 +154,33 @@ export const ProposalForm = ({ const [showPreview, setShowPreview] = useState(false); const [previewData, setPreviewData] = useState(); + const [requestedAmount, setRequestedAmount] = useState(); const [loading, setLoading] = useState(false); const [isEnoughBalance, setIsEnoughBalance] = useState(true); const router = useRouter(); const pathname = usePathname(); + const chainIdFromPath = useChainIdFromPath(); + + const { data: thresholdFromContract } = useContractRead({ + address: strategy.id as Address, + abi: cvStrategyABI, + chainId: chainIdFromPath, + functionName: "calculateThreshold", + args: [ + requestedAmount ? parseUnits(requestedAmount, tokenGarden.decimals) : 0n, + ], + enabled: + requestedAmount !== undefined && + PoolTypes[strategy?.config?.proposalType] === "funding", + }); + + const thresholdPct = calculatePercentageBigInt( + thresholdFromContract as bigint, + BigInt(strategy.maxCVSupply), + tokenGarden?.decimals ?? 18, + ); + const disableSubmitBtn = useMemo( () => [ { @@ -254,7 +283,7 @@ export const ProposalForm = ({ const metadata = [1, metadataIpfs as string]; const strAmount = previewData.amount?.toString() || ""; - const requestedAmount = parseUnits( + const amount = parseUnits( strAmount, // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing poolToken?.decimals || 0, @@ -263,7 +292,7 @@ export const ProposalForm = ({ console.debug([ poolId, previewData.beneficiary, - requestedAmount, + amount, poolTokenAddr, metadata, ]); @@ -273,7 +302,7 @@ export const ProposalForm = ({ poolId, previewData?.beneficiary || "0x0000000000000000000000000000000000000000", - requestedAmount, + amount, poolTokenAddr, metadata, ], @@ -327,6 +356,7 @@ export const ProposalForm = ({ subLabel={`Max ${formatNumber(spendingLimitString)} ${poolToken?.symbol} (${spendingLimitPct.toFixed(2)}% of Pool Funds)`} register={register} required + onChange={(e) => setRequestedAmount(e.target.value)} registerOptions={{ max: { value: spendingLimitNumber, @@ -350,6 +380,15 @@ export const ProposalForm = ({ /> )} + {requestedAmount && thresholdPct !== 0 && thresholdPct < 100 && ( +

Conviction required to pass {thresholdPct}%

+ )} + {requestedAmount && thresholdPct > 50 && thresholdPct < 100 && ( + + This proposal needs over half of the pool's governance weight + in conviction and it may be difficult to pass. + + )} {proposalTypeName !== "signaling" && (
& { + Pick & { strategy: Pick< CVStrategy, "id" | "maxCVSupply" | "totalEffectiveActivePoints" diff --git a/pkg/subgraph/.graphclient/index.d.ts b/pkg/subgraph/.graphclient/index.d.ts index 8d870fb9e..00284be9b 100644 --- a/pkg/subgraph/.graphclient/index.d.ts +++ b/pkg/subgraph/.graphclient/index.d.ts @@ -3626,7 +3626,7 @@ export type getPoolDataQueryVariables = Exact<{ export type getPoolDataQuery = { allos: Array>; tokenGarden?: Maybe>; - cvstrategies: Array<(Pick & { + cvstrategies: Array<(Pick & { sybilScorer?: Maybe>; memberActive?: Maybe>>; config: Pick; diff --git a/pkg/subgraph/.graphclient/index.js b/pkg/subgraph/.graphclient/index.js index b75c21da1..c481c3140 100644 --- a/pkg/subgraph/.graphclient/index.js +++ b/pkg/subgraph/.graphclient/index.js @@ -568,6 +568,7 @@ export const getPoolDataDocument = gql ` poolId totalEffectiveActivePoints isEnabled + maxCVSupply sybilScorer { id } diff --git a/pkg/subgraph/src/query/queries.graphql b/pkg/subgraph/src/query/queries.graphql index 0b3d8043f..dcac26590 100644 --- a/pkg/subgraph/src/query/queries.graphql +++ b/pkg/subgraph/src/query/queries.graphql @@ -239,6 +239,7 @@ query getPoolData($garden: ID!, $poolId: BigInt!) { poolId totalEffectiveActivePoints isEnabled + maxCVSupply sybilScorer { id } From 459a0304399c8435e890e71c680a46d34270ead1 Mon Sep 17 00:00:00 2001 From: Lucianosc Date: Thu, 31 Oct 2024 14:41:35 +0700 Subject: [PATCH 2/6] add validate input when user changes focus --- .../web/components/Forms/FormAddressInput.tsx | 90 ++++++++++++++++--- 1 file changed, 80 insertions(+), 10 deletions(-) diff --git a/apps/web/components/Forms/FormAddressInput.tsx b/apps/web/components/Forms/FormAddressInput.tsx index 2372a4b43..41b329dfd 100644 --- a/apps/web/components/Forms/FormAddressInput.tsx +++ b/apps/web/components/Forms/FormAddressInput.tsx @@ -1,4 +1,4 @@ -import { ChangeEvent, useCallback, useEffect } from "react"; +import { ChangeEvent, useCallback, useEffect, useState } from "react"; import { blo } from "blo"; import { uniqueId } from "lodash-es"; import { Address, isAddress } from "viem"; @@ -7,20 +7,24 @@ import { InfoWrapper } from "../InfoWrapper"; import { useDebounce } from "@/hooks/useDebounce"; import { isENS } from "@/utils/web3"; -/** - * Address input with ENS name resolution - */ +type ValidationStatus = { + isValid: boolean; + message?: string; +}; + type Props = { label?: string; errors?: any; required?: boolean; placeholder?: string; readOnly?: boolean; + registerKey?: string; disabled?: boolean; className?: string; value?: string; tooltip?: string; onChange?: React.ChangeEventHandler; + onValidationChange?: (status: ValidationStatus) => void; }; export const FormAddressInput = ({ @@ -29,20 +33,26 @@ export const FormAddressInput = ({ required = false, placeholder = "0x", readOnly, + registerKey, disabled, className, value = undefined, tooltip, onChange, + onValidationChange, }: Props) => { const id = uniqueId("address-input-"); const debouncedValue = useDebounce(value, 500); + const [validationStatus, setValidationStatus] = useState({ + isValid: false, + }); + const [shouldValidate, setShouldValidate] = useState(false); + const debouncedOrValue = isAddress(value ?? "") ? value : debouncedValue; const isDebouncedValueLive = debouncedOrValue === value; - const settledValue = isDebouncedValueLive ? debouncedOrValue : undefined; - const { data: ensAddress } = useEnsAddress({ + const { data: ensAddress, isError: ensError } = useEnsAddress({ name: settledValue, enabled: isENS(debouncedOrValue), chainId: 1, @@ -63,6 +73,51 @@ export const FormAddressInput = ({ cacheTime: 30_000, }); + const validateAddress = useCallback(() => { + if (!value) { + setValidationStatus({ + isValid: !required, + message: required ? "Address is required" : undefined, + }); + return; + } + + if (isAddress(value)) { + setValidationStatus({ isValid: true }); + } else if (isENS(value)) { + if (ensError) { + setValidationStatus({ + isValid: false, + message: "Invalid ENS name", + }); + } else if (ensAddress) { + setValidationStatus({ isValid: true }); + } else { + setValidationStatus({ + isValid: false, + message: "Resolving ENS name...", + }); + } + } else { + setValidationStatus({ + isValid: false, + message: "Invalid Ethereum address or ENS name", + }); + } + }, [value, ensAddress, ensError, required]); + + // Only validate when shouldValidate is true (after blur) + useEffect(() => { + if (shouldValidate) { + validateAddress(); + } + }, [shouldValidate, validateAddress]); + + // Notify parent component of validation changes + useEffect(() => { + onValidationChange?.(validationStatus); + }, [validationStatus, onValidationChange]); + useEffect(() => { if (!ensAddress) return; const ev = { @@ -81,14 +136,20 @@ export const FormAddressInput = ({ [onChange], ); + const handleBlur = useCallback(() => { + setShouldValidate(true); + }, []); + let modifier = ""; - if (Object.keys(errors).length > 0) { + if (Object.keys(errors).find((err) => err === registerKey)) { modifier = "border-error"; } else if (disabled) { modifier = "border-disabled"; } else if (readOnly) { modifier = "!border-gray-300 !focus-within:border-gray-300 focus-within:outline !outline-gray-300 cursor-not-allowed bg-transparent"; + } else if (!validationStatus.isValid && shouldValidate) { + modifier = "border-error"; } return ( @@ -113,28 +174,37 @@ export const FormAddressInput = ({ className={`form-control input input-info flex flex-row font-normal items-center ${modifier}`} > handleChange(e.target.value)} + onBlur={handleBlur} disabled={disabled ?? readOnly} readOnly={readOnly ?? disabled} required={required} value={value} /> {value && ( - // Don't want to use nextJS Image here (and adding remote patterns for the URL) // eslint-disable-next-line @next/next/no-img-element )}
+ {validationStatus.message && + !validationStatus.isValid && + shouldValidate && ( +

+ {validationStatus.message} +

+ )} ); }; From c6a63532503783c0b32109e475de2c47939bc452 Mon Sep 17 00:00:00 2001 From: Lucianosc Date: Mon, 4 Nov 2024 10:22:38 +0700 Subject: [PATCH 3/6] add onBlur input validation --- apps/web/components/Forms/ProposalForm.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/web/components/Forms/ProposalForm.tsx b/apps/web/components/Forms/ProposalForm.tsx index 1bdb0d96d..6e4000485 100644 --- a/apps/web/components/Forms/ProposalForm.tsx +++ b/apps/web/components/Forms/ProposalForm.tsx @@ -130,7 +130,7 @@ export const ProposalForm = ({ getValues, setValue, watch, - } = useForm(); + } = useForm({ mode: "onBlur" }); const { publish } = usePubSubContext(); @@ -356,7 +356,9 @@ export const ProposalForm = ({ subLabel={`Max ${formatNumber(spendingLimitString)} ${poolToken?.symbol} (${spendingLimitPct.toFixed(2)}% of Pool Funds)`} register={register} required - onChange={(e) => setRequestedAmount(e.target.value)} + onChange={(e) => { + setRequestedAmount(e.target.value); + }} registerOptions={{ max: { value: spendingLimitNumber, From 0090bc2555317cf381c2225a9307004e810dea47 Mon Sep 17 00:00:00 2001 From: Lucianosc Date: Mon, 4 Nov 2024 10:22:57 +0700 Subject: [PATCH 4/6] fix eth addr validation --- apps/web/utils/web3.ts | 61 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/apps/web/utils/web3.ts b/apps/web/utils/web3.ts index bb4a49fc5..43f578384 100644 --- a/apps/web/utils/web3.ts +++ b/apps/web/utils/web3.ts @@ -1,7 +1,14 @@ import { Chain } from "viem"; +import { isAddress } from "viem"; import * as chains from "viem/chains"; import { ChainId } from "@/types"; +/** + * Gets the Viem chain configuration for a given chain ID + * @param chainId The chain ID to look up + * @returns The Chain configuration object + * @throws Error if chain ID is not found + */ export function getViemChain(chainId: ChainId): Chain { for (const chain of Object.values(chains)) { if ("id" in chain) { @@ -14,4 +21,56 @@ export function getViemChain(chainId: ChainId): Chain { throw new Error(`Chain with id ${chainId} not found`); } -export const isENS = (address = "") => /.+\.eth$/i.test(address); +/** + * Validates if a string is a valid ENS name + * Follows ENS naming standards: + * - Must end with .eth + * - Min 3 characters, max 255 characters + * - Can contain lowercase letters, numbers, and hyphens + * - Cannot start or end with a hyphen + * - Cannot have consecutive hyphens + * @param address The string to validate + * @returns boolean indicating if the string is a valid ENS name + */ +export const isENS = (address = ""): boolean => { + // Basic ENS validation + const ensRegex = + /^(?:[a-z0-9][a-z0-9-]{0,61}[a-z0-9]\.)*[a-z0-9][a-z0-9-]{0,61}[a-z0-9]\.eth$/i; + + // Additional length validation + const withoutEth = address.replace(/\.eth$/i, ""); + const isValidLength = withoutEth.length >= 3 && withoutEth.length <= 255; + + return ensRegex.test(address) && isValidLength; +}; + +/** + * Validates if a string is either a valid Ethereum address or ENS name + * @param value The string to validate + * @returns boolean indicating if the string is a valid Ethereum address or ENS name + */ +export const isValidEthereumAddressOrENS = (value = ""): boolean => { + return isAddress(value) || isENS(value); +}; + +/** + * Checks if a string matches the format of an Ethereum address (0x followed by 40 hex characters) + * Note: This does not validate checksum. Use isAddress from viem for full validation + * @param value The string to validate + * @returns boolean indicating if the string matches Ethereum address format + */ +export const hasEthereumAddressFormat = (value = ""): boolean => { + return /^0x[a-fA-F0-9]{40}$/i.test(value); +}; + +/** + * Normalizes an Ethereum address or ENS name for consistent comparison + * @param value The address or ENS name to normalize + * @returns The normalized value (lowercase for ENS, unchanged for addresses) + */ +export const normalizeAddressOrENS = (value = ""): string => { + if (isENS(value)) { + return value.toLowerCase(); + } + return value; +}; From 3a680838c59aa86cbde0f6b34e08a339a78d35e4 Mon Sep 17 00:00:00 2001 From: Matias Date: Mon, 11 Nov 2024 15:54:04 -0300 Subject: [PATCH 5/6] upgrade description --- apps/web/components/Forms/ProposalForm.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/web/components/Forms/ProposalForm.tsx b/apps/web/components/Forms/ProposalForm.tsx index 6e4000485..c02d741b9 100644 --- a/apps/web/components/Forms/ProposalForm.tsx +++ b/apps/web/components/Forms/ProposalForm.tsx @@ -382,13 +382,15 @@ export const ProposalForm = ({ /> )} + {requestedAmount && thresholdPct !== 0 && thresholdPct < 100 && ( -

Conviction required to pass {thresholdPct}%

- )} - {requestedAmount && thresholdPct > 50 && thresholdPct < 100 && ( - This proposal needs over half of the pool's governance weight - in conviction and it may be difficult to pass. + The conviction required in order for the proposal to pass with the + requested amount is {thresholdPct}%.{" "} + {requestedAmount && + thresholdPct > 50 && + thresholdPct < 100 && + "It may be difficult to pass"} )} {proposalTypeName !== "signaling" && ( From f5a7fa2c3172e399495aa4643cda7b71688425d6 Mon Sep 17 00:00:00 2001 From: Matias Date: Mon, 11 Nov 2024 16:04:34 -0300 Subject: [PATCH 6/6] fix lint --- apps/web/components/PoolMetrics.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/components/PoolMetrics.tsx b/apps/web/components/PoolMetrics.tsx index b5fe0046c..3aab0aead 100644 --- a/apps/web/components/PoolMetrics.tsx +++ b/apps/web/components/PoolMetrics.tsx @@ -9,6 +9,7 @@ import { Allo } from "#/subgraph/.graphclient"; import { Button } from "./Button"; import { DisplayNumber } from "./DisplayNumber"; import { FormInput } from "./Forms"; +import { Skeleton } from "./Skeleton"; import { TransactionModal, TransactionProps } from "./TransactionModal"; import { usePubSubContext } from "@/contexts/pubsub.context"; import { useContractWriteWithConfirmations } from "@/hooks/useContractWriteWithConfirmations"; @@ -16,7 +17,6 @@ import { useDisableButtons } from "@/hooks/useDisableButtons"; import { useHandleAllowance } from "@/hooks/useHandleAllowance"; import { alloABI } from "@/src/generated"; import { getTxMessage } from "@/utils/transactionMessages"; -import { Skeleton } from "./Skeleton"; interface PoolMetricsProps { poolAmount: number;