diff --git a/packages/app/src/components/CommunityPool/CreatePool/GetStarted.tsx b/packages/app/src/components/CommunityPool/CreatePool/GetStarted.tsx
index 1ea39c06..3287488c 100644
--- a/packages/app/src/components/CommunityPool/CreatePool/GetStarted.tsx
+++ b/packages/app/src/components/CommunityPool/CreatePool/GetStarted.tsx
@@ -1,4 +1,4 @@
-import { Box, FormControl, Input, Text, TextArea, VStack, WarningOutlineIcon, Pressable } from 'native-base';
+import { Box, FormControl, Input, Link, Pressable, Text, TextArea, VStack, WarningOutlineIcon } from 'native-base';
import { useEffect, useState } from 'react';
import { useCreatePool } from '../../../hooks/useCreatePool/useCreatePool';
@@ -216,13 +216,13 @@ const GetStarted = ({}: {}) => {
Provide image URL for your cover photo (1200x400px recommended). Upload to{' '}
- window.open('https://ipfs.io/', '_blank')}>
+
IPFS
- {' '}
+ {' '}
(free tier) or{' '}
- window.open('https://cloudinary.com/', '_blank')}>
+
Cloudinary
- {' '}
+ {' '}
for hosting.
@@ -428,5 +428,6 @@ const styles = {
color: 'goodPurple.400',
textDecorationLine: 'underline',
fontWeight: '600',
+ cursor: 'pointer',
},
} as const;
diff --git a/packages/app/src/components/CommunityPool/CreatePool/PoolConfiguration.tsx b/packages/app/src/components/CommunityPool/CreatePool/PoolConfiguration.tsx
index d7f43bad..af10c80c 100644
--- a/packages/app/src/components/CommunityPool/CreatePool/PoolConfiguration.tsx
+++ b/packages/app/src/components/CommunityPool/CreatePool/PoolConfiguration.tsx
@@ -5,6 +5,7 @@ import { useCreatePool } from '../../../hooks/useCreatePool/useCreatePool';
import {
usePoolConfigurationValidation,
PoolConfigurationFormData,
+ validatePoolRecipients,
} from '../../../hooks/useCreatePool/usePoolConfigurationValidation';
import { useScreenSize } from '../../../theme/hooks';
import MembersSection from './pool-configs/MembersSection';
@@ -12,12 +13,17 @@ import PayoutSettingsSection from './pool-configs/PayoutSettingsSection';
import PoolManagerFeeSection from './pool-configs/PoolManagerFeeSection';
import NavigationButtons from '../NavigationButtons';
import ClaimFrequencySection from './pool-configs/ClaimFrequencySection';
+import { useEthersProvider } from '../../../hooks/useEthers';
+import { assessPoolMemberEligibility, formatSkippedMembersMessage } from '../../../lib/poolMemberEligibility';
+import { getMembersValidatorAddress, getUniquenessValidatorAddress } from '../../../models/constants';
const PoolConfiguration = () => {
const { form, nextStep, submitPartial, previousStep } = useCreatePool();
const { isDesktopView } = useScreenSize();
- const { address } = useAccount();
+ const { address, chain } = useAccount();
const { validate, errors } = usePoolConfigurationValidation();
+ const chainId = chain?.id ?? 42220;
+ const provider = useEthersProvider({ chainId });
const [poolManagerFeeType, setPoolManagerFeeType] = useState<'default' | 'custom'>(
form.poolManagerFeeType ?? 'default'
@@ -31,6 +37,8 @@ const PoolConfiguration = () => {
const [claimAmountPerWeek, setClaimAmountPerWeek] = useState(form.claimAmountPerWeek ?? 10);
const [expectedMembers, setExpectedMembers] = useState(form.expectedMembers ?? 1);
const [customClaimFrequency, setCustomClaimFrequency] = useState(form.customClaimFrequency ?? 1);
+ const [recipientEligibilityError, setRecipientEligibilityError] = useState();
+ const [isCheckingRecipients, setIsCheckingRecipients] = useState(false);
const { data: ensName } = useEnsName({ address: managerAddress as `0x${string}`, chainId: 1 });
useEffect(() => {
@@ -39,6 +47,10 @@ const PoolConfiguration = () => {
}
}, [maximumMembers, expectedMembers]);
+ useEffect(() => {
+ setRecipientEligibilityError(undefined);
+ }, [poolRecipients, maximumMembers]);
+
const handleValidate = () => {
const formData: PoolConfigurationFormData = {
poolRecipients,
@@ -54,8 +66,49 @@ const PoolConfiguration = () => {
return validate(formData);
};
- const submitForm = () => {
- if (handleValidate()) {
+ const validateRecipientEligibility = async (): Promise => {
+ const recipientsValidation = validatePoolRecipients(poolRecipients, maximumMembers);
+ if (!recipientsValidation.isValid || recipientsValidation.memberAddresses.length === 0) {
+ setRecipientEligibilityError(undefined);
+ return recipientsValidation.isValid;
+ }
+
+ if (!provider || !managerAddress) {
+ return true;
+ }
+
+ try {
+ setIsCheckingRecipients(true);
+ const { skippedAddresses, validAddresses } = await assessPoolMemberEligibility({
+ provider,
+ addresses: recipientsValidation.memberAddresses,
+ uniquenessValidator: getUniquenessValidatorAddress(chainId),
+ membersValidator: getMembersValidatorAddress(chainId),
+ operatorAddress: managerAddress.toLowerCase(),
+ });
+
+ if (validAddresses.length !== recipientsValidation.memberAddresses.length) {
+ const skippedSummary = formatSkippedMembersMessage(skippedAddresses);
+ setRecipientEligibilityError(
+ skippedSummary
+ ? `Some initial members are not eligible: ${skippedSummary}.`
+ : 'Some initial members cannot be added to this pool.'
+ );
+ return false;
+ }
+
+ setRecipientEligibilityError(undefined);
+ return true;
+ } finally {
+ setIsCheckingRecipients(false);
+ }
+ };
+
+ const submitForm = async () => {
+ const isValid = handleValidate();
+ const hasEligibleRecipients = await validateRecipientEligibility();
+
+ if (isValid && hasEligibleRecipients) {
submitPartial({
poolManagerFeeType,
claimFrequency: claimFrequency === 2 ? customClaimFrequency : claimFrequency,
@@ -113,9 +166,14 @@ const PoolConfiguration = () => {
setMaximumMembers={setMaximumMembers}
joinStatus={joinStatus}
setJoinStatus={setJoinStatus}
+ poolRecipients={poolRecipients}
+ setPoolRecipients={setPoolRecipients}
onValidate={handleValidate}
+ onValidateRecipients={validateRecipientEligibility}
+ isCheckingRecipients={isCheckingRecipients}
errors={{
maximumMembers: errors.maximumMembers,
+ poolRecipients: errors.poolRecipients ?? recipientEligibilityError,
}}
/>
diff --git a/packages/app/src/components/CommunityPool/CreatePool/ReviewLaunch.tsx b/packages/app/src/components/CommunityPool/CreatePool/ReviewLaunch.tsx
index 354155d4..896ae317 100644
--- a/packages/app/src/components/CommunityPool/CreatePool/ReviewLaunch.tsx
+++ b/packages/app/src/components/CommunityPool/CreatePool/ReviewLaunch.tsx
@@ -4,8 +4,12 @@ import { ReactNode, useCallback, useMemo, useState } from 'react';
import { AtIcon, DiscordIcon, EditIcon, InstagramIcon, PhoneImg, TwitterIcon, WebsiteIcon } from '../../../assets';
import { useCreatePool } from '../../../hooks/useCreatePool/useCreatePool';
import { printAndParseSupportError } from '../../../hooks/useContractCalls/util';
+import { isPoolMembersAddError } from '../../../hooks/useCreatePool/PoolMembersAddError';
+import useCrossNavigate from '../../../routes/useCrossNavigate';
import BaseModal from '../../modals/BaseModal';
import NavigationButtons from '../NavigationButtons';
+import { Linking } from 'react-native';
+import { formatSocialUrls } from '../../../lib/formatSocialUrls';
const SectionHeader = ({ title, onEdit }: { title: string; onEdit: () => void }) => (
@@ -56,9 +60,17 @@ const ReviewLaunch = () => {
const { form, startOver, previousStep, goToBasics, goToProjectDetails, goToPoolConfiguration, createPool } =
useCreatePool();
const { isDesktopView } = useScreenSize();
+ const { navigate } = useCrossNavigate();
const [approvePoolModalVisible, setApprovePoolModalVisible] = useState(false);
const [errorMessage, setErrorMessage] = useState(undefined);
+ // Holds the deployed pool address when the pool was created on-chain but the
+ // follow-up addPoolMembers transaction failed. Used to render a recovery
+ // modal that links to the manage page so the user can retry.
+ const [partialCreatePoolAddress, setPartialCreatePoolAddress] = useState(undefined);
+ const [partialCreateReason, setPartialCreateReason] = useState(undefined);
+ const [partialCreateMembers, setPartialCreateMembers] = useState([]);
+ const [partialCopySuccess, setPartialCopySuccess] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const socials = [
@@ -88,6 +100,10 @@ const ReviewLaunch = () => {
setIsCreating(true);
setApprovePoolModalVisible(true);
setErrorMessage(undefined);
+ setPartialCreatePoolAddress(undefined);
+ setPartialCreateReason(undefined);
+ setPartialCreateMembers([]);
+ setPartialCopySuccess(false);
try {
const pool = await createPool();
@@ -102,8 +118,17 @@ const ReviewLaunch = () => {
console.error('Pool creation error:', error);
setApprovePoolModalVisible(false);
- const message = printAndParseSupportError(error);
- setErrorMessage(message);
+ // Pool deployed on-chain but addPoolMembers tx failed - show a recovery
+ // modal pointing the user at the manage page rather than a generic error
+ // that would leave them with a stranded deployed pool.
+ if (isPoolMembersAddError(error)) {
+ setPartialCreatePoolAddress(error.poolAddress);
+ setPartialCreateReason(printAndParseSupportError(error.cause));
+ setPartialCreateMembers(error.memberAddresses);
+ } else {
+ const message = printAndParseSupportError(error);
+ setErrorMessage(message);
+ }
} finally {
setIsCreating(false);
}
@@ -111,6 +136,44 @@ const ReviewLaunch = () => {
const onCloseErrorModal = () => setErrorMessage(undefined);
+ const onClosePartialCreateModal = () => {
+ setPartialCreatePoolAddress(undefined);
+ setPartialCreateReason(undefined);
+ setPartialCreateMembers([]);
+ setPartialCopySuccess(false);
+ };
+
+ const onGoToManagePool = () => {
+ if (!partialCreatePoolAddress) return;
+ const address = partialCreatePoolAddress;
+ onClosePartialCreateModal();
+ navigate(`/collective/${address}/manage`);
+ };
+
+ const onCopyPartialMembers = useCallback(async () => {
+ if (partialCreateMembers.length === 0) return;
+ const text = partialCreateMembers.join('\n');
+ try {
+ if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
+ await navigator.clipboard.writeText(text);
+ } else {
+ // Fallback for environments without clipboard API (older webviews).
+ const textarea = document.createElement('textarea');
+ textarea.value = text;
+ textarea.style.position = 'fixed';
+ textarea.style.opacity = '0';
+ document.body.appendChild(textarea);
+ textarea.select();
+ document.execCommand('copy');
+ document.body.removeChild(textarea);
+ }
+ setPartialCopySuccess(true);
+ setTimeout(() => setPartialCopySuccess(false), 2000);
+ } catch (err) {
+ console.error('Failed to copy member addresses:', err);
+ }
+ }, [partialCreateMembers]);
+
// ===== Helpers to reduce repetition =====
const formatPoolType = useCallback((poolType?: string) => {
if (!poolType) return 'Community Funds';
@@ -198,16 +261,27 @@ const ReviewLaunch = () => {
{socials.map((social, index) => (
-
-
-
+ onPress={() => {
+ const url = form[social.name as keyof typeof form];
+ if (typeof url === 'string') {
+ const formattedUrl = formatSocialUrls[social.name as keyof typeof formatSocialUrls](url);
+ if (formattedUrl) {
+ Linking.openURL(formattedUrl);
+ }
+ }
+ }}>
+
+
+
+
))}
@@ -246,6 +320,12 @@ const ReviewLaunch = () => {
+ {form.poolRecipients && form.poolRecipients.trim() !== '' && (
+ s.trim() !== '').length}
+ />
+ )}
@@ -255,6 +335,7 @@ const ReviewLaunch = () => {
onBack={() => previousStep()}
onNext={handleCreatePool}
nextText={isCreating ? 'Creating...' : 'Launch Pool'}
+ nextDisabled={isCreating}
marginTop={6}
containerStyle={undefined}
buttonWidth="140px"
@@ -274,15 +355,69 @@ const ReviewLaunch = () => {
onConfirm={onCloseErrorModal}
/>
+ {/* Partial-create recovery modal: shown when the pool was deployed but
+ the second tx (adding initial members) failed. Surfaces the attempted
+ member list with a copy-to-clipboard so the user can paste it on the
+ manage page instead of typing it again. */}
+ 0 ? (
+
+
+
+ Members to add ({partialCreateMembers.length})
+
+
+
+ {partialCopySuccess ? 'Copied!' : 'Copy'}
+
+
+
+
+ {partialCreateMembers.map((address) => (
+
+ {address}
+
+ ))}
+
+
+ ) : undefined,
+ 'You can finish adding members from the pool management page.',
+ ]}
+ image={PhoneImg}
+ />
+
{/* Approval Modal */}
setApprovePoolModalVisible(false)}
title="APPROVE POOL CREATION"
- paragraphs={[
- 'To create your GoodCollective pool, sign with your wallet.',
- 'This will deploy your pool contract and make it available for members to join.',
- ]}
+ paragraphs={
+ form.poolRecipients && form.poolRecipients.trim() !== ''
+ ? [
+ 'To create your GoodCollective pool, sign with your wallet.',
+ 'Note: You will be asked to sign TWO transactions. The first creates the pool, and the second adds your initial members.',
+ ]
+ : [
+ 'To create your GoodCollective pool, sign with your wallet.',
+ 'This will deploy your pool contract and make it available for members to join.',
+ ]
+ }
image={PhoneImg}
/>
diff --git a/packages/app/src/components/CommunityPool/CreatePool/pool-configs/MembersSection.tsx b/packages/app/src/components/CommunityPool/CreatePool/pool-configs/MembersSection.tsx
index 38474e3b..e65cd712 100644
--- a/packages/app/src/components/CommunityPool/CreatePool/pool-configs/MembersSection.tsx
+++ b/packages/app/src/components/CommunityPool/CreatePool/pool-configs/MembersSection.tsx
@@ -1,13 +1,18 @@
-import { Box, FormControl, Input, Radio, Text, VStack, WarningOutlineIcon } from 'native-base';
+import { Box, FormControl, Input, Radio, Text, TextArea, VStack, WarningOutlineIcon } from 'native-base';
interface MembersSectionProps {
maximumMembers: number;
setMaximumMembers: (value: number) => void;
joinStatus: 'closed' | 'open';
setJoinStatus: (value: 'closed' | 'open') => void;
+ poolRecipients: string;
+ setPoolRecipients: (value: string) => void;
onValidate: () => void;
+ onValidateRecipients: () => Promise;
+ isCheckingRecipients: boolean;
errors: {
maximumMembers?: string;
+ poolRecipients?: string;
};
}
@@ -16,7 +21,11 @@ const MembersSection = ({
setMaximumMembers,
joinStatus,
setJoinStatus,
+ poolRecipients,
+ setPoolRecipients,
onValidate,
+ onValidateRecipients,
+ isCheckingRecipients,
errors,
}: MembersSectionProps) => {
return (
@@ -85,6 +94,42 @@ const MembersSection = ({
+
+ {/* Initial Members */}
+
+
+
+ Initial Members (optional)
+
+
+
+ Add wallet addresses separated by commas or new lines.
+
+
+
+
);
};
diff --git a/packages/app/src/components/CommunityPool/SelectCollectiveType.tsx b/packages/app/src/components/CommunityPool/SelectCollectiveType.tsx
index 64d51bb1..200dc909 100644
--- a/packages/app/src/components/CommunityPool/SelectCollectiveType.tsx
+++ b/packages/app/src/components/CommunityPool/SelectCollectiveType.tsx
@@ -1,4 +1,4 @@
-import { Box, Button, Checkbox, HStack, Pressable, Text, VStack } from 'native-base';
+import { Box, Button, Checkbox, HStack, Link, Pressable, Text, VStack } from 'native-base';
import { CommunityFundsIcon, ResultsBasedIcon, SegmentedAidIcon } from '../../assets';
import { PoolType, useCreatePool } from '../../hooks/useCreatePool/useCreatePool';
import { useScreenSize } from '../../theme/hooks';
@@ -13,7 +13,7 @@ const poolTypes = [
id: 'community-funds' as PoolType,
name: 'Community Funds',
icon: CommunityFundsIcon,
- description: 'Facilitate money distribution to members of existing community organisations',
+ description: 'Distribute funds to members of an existing group or organization.',
interested: false,
disabled: false,
},
@@ -21,8 +21,7 @@ const poolTypes = [
id: 'segmented-aid' as PoolType,
name: 'Segmented Aid',
icon: SegmentedAidIcon,
- description:
- 'Self-sovereign, user-managed and encrypted digital demographic information allows access to specific funds via GoodOffers',
+ description: 'Provide funds to people who qualify by verified attributes such as age or location.',
interested: true,
disabled: true,
},
@@ -30,7 +29,7 @@ const poolTypes = [
id: 'results-based' as PoolType,
name: 'Results-based direct payments',
icon: ResultsBasedIcon,
- description: 'Provides direct payments to stewards based on verified climate action',
+ description: 'Reward verified actions or measurable impact through data partners.',
interested: true,
disabled: true,
},
@@ -78,11 +77,15 @@ const SelectType = () => {
color: '#6933FF',
},
]}>
- About Various Pools
+ Create a Pool
- Lorem ipsum, dolor sit amet consectetur adipisicing elit. Dignissimos ipsa ab nemo fugiat expedita, facilis
- voluptatibus magni velit odio quis cumque quidem veniam fuga. Ea perferendis voluptas voluptatum in iste!
+ Choose how your pool distributes the funds. Only Community Funds are currently available. If you have
+ interest in the other pools types,{' '}
+
+ reach out here
+
+ .
@@ -178,6 +181,12 @@ const selectCollectiveTypeStyles = {
maxWidth: '80%',
fontWeight: '400',
},
+ linkText: {
+ color: 'goodPurple.400',
+ textDecorationLine: 'underline',
+ fontWeight: '600',
+ cursor: 'pointer',
+ },
card: {
borderRadius: 12,
borderWidth: 1,
diff --git a/packages/app/src/components/CommunityPool/Welcome.tsx b/packages/app/src/components/CommunityPool/Welcome.tsx
index f3f16650..16e21a7b 100644
--- a/packages/app/src/components/CommunityPool/Welcome.tsx
+++ b/packages/app/src/components/CommunityPool/Welcome.tsx
@@ -1,4 +1,4 @@
-import { Box, Checkbox, FormControl, HStack, Pressable, Radio, Text, VStack, WarningOutlineIcon } from 'native-base';
+import { Box, Checkbox, FormControl, HStack, Link, Pressable, Text, VStack, WarningOutlineIcon } from 'native-base';
import { useState } from 'react';
import { useCreatePool } from '../../hooks/useCreatePool/useCreatePool';
import { useScreenSize } from '../../theme/hooks';
@@ -7,12 +7,12 @@ import { CreateCollectiveLogo } from '../../assets';
import { InterRegular, InterSemiBold, InterSmall } from '../../utils/webFonts';
const Welcome = () => {
- const [value, setValue] = useState('one');
- const [acknowledged, setAcknowledged] = useState('');
+ const [acknowledged, setAcknowledged] = useState(false);
const [pressed, setPressed] = useState(false);
const { isDesktopView } = useScreenSize();
const { nextStep } = useCreatePool();
+ const termsUrl = 'https://www.gooddollar.org/terms-of-use';
const onSubmit = () => {
if (!acknowledged) {
@@ -40,8 +40,7 @@ const Welcome = () => {
color: '#6933FF',
},
]}
- textAlign="center"
- fontWeight="600">
+ textAlign="center">
Welcome to
@@ -62,49 +61,19 @@ const Welcome = () => {
style={[welcomeStyles.infoBlock, isDesktopView && desktopWelcomeStyles.infoBlock]}
backgroundColor="goodPurple.100"
borderColor="goodPurple.200">
-
- Lorem ipsum dolor sit amet consectetur adipisicing elit. Quam totam, tempore saepe beatae et quidem provident
- aperiam esse recusandae rem fugiat laboriosam est rerum enim at magni suscipit amet qui. Lorem ipsum dolor,
- sit amet consectetur adipisicing elit. Totam similique vel odio incidunt enim officiis, quo dignissimos
- quaerat officia omnis at dolorem itaque dolore pariatur tempora? Quo ratione sequi dolorem. Lorem ipsum dolor
- sit amet consectetur adipisicing elit. Repellendus eum similique culpa dolore quos doloremque. Nostrum quo rem
- deserunt, sit sint hic itaque? Cumque incidunt facilis repellendus vero magnam dolorem.
+
+ GoodCollective helps you turn funding into real, verifiable impact.
-
- Lorem ipsum dolor sit amet consectetur adipisicing elit. Ratione cupiditate, labore ducimus quae suscipit
- tempora minus non nihil inventore ipsa dignissimos ex corrupti adipisci impedit autem repudiandae
- reprehenderit eum in!
+
+ Create a pool, define who's eligible, and automate distributions with ease.
+
+
+ Whether you're supporting a community, rewarding actions, or reaching a specific group, everything is simple
+ and fully auditable on-chain.
-
-
- {/* Radio Options Block */}
-
- {
- setValue(v);
- console.log(v);
- }}
- flexDir="column">
-
-
-
- Lorem ipsum dolor sit, amet consectetur adipisicing elit. Animi, dignissimos fugit adipisci, ex libero
- laborum praesentium officiis
-
-
-
-
-
- Lorem ipsum dolor sit amet consectetur adipisicing elit. Voluptates maiores ab dicta vero veritatis omnis
- natus ration
-
-
-
{/* Checkbox Section */}
@@ -119,14 +88,18 @@ const Welcome = () => {
borderRadius={4}
borderColor="goodGrey.400"
_checked={{ bg: 'goodPurple.400', borderColor: 'goodPurple.400' }}
- value={String(acknowledged)}
- onChange={(v) => setAcknowledged(String(v))}
+ value="acknowledge"
+ isChecked={acknowledged}
+ onChange={(v) => setAcknowledged(v)}
accessibilityLabel="I understand"
size="md"
/>
-
- I understand Vitae morbi dolor tellus in tincidunt est ac cursus. Habitasse viverra lectus integer posuere
- fermentum.
+
+ All on-chain transactions are final and non-reversible. GoodCollective is a non-custodial interface
+ provided as-is.{'\n'}
+
+ Terms of Use
+
{
{/* CTA Button */}
Get Started
@@ -166,11 +144,13 @@ const welcomeStyles = {
},
welcomeText: {
fontSize: 48,
+ lineHeight: 56,
marginBottom: 4,
...InterSemiBold,
},
logoImage: {
- width: 365,
+ width: '100%',
+ maxWidth: 320,
height: 50,
resizeMode: 'contain',
},
@@ -188,11 +168,16 @@ const welcomeStyles = {
shadowRadius: 4,
elevation: 3,
},
- infoText: {
- fontSize: 14,
- lineHeight: 20,
- textAlign: 'justify',
- ...InterRegular,
+ infoTitle: {
+ textAlign: 'center',
+ marginBottom: 12,
+ ...InterSemiBold,
+ },
+ infoBody: {
+ textAlign: 'center',
+ },
+ infoBodySpacing: {
+ marginTop: 12,
},
radioBlock: {
borderRadius: 12,
@@ -250,6 +235,12 @@ const welcomeStyles = {
flex: 1,
...InterRegular,
},
+ linkText: {
+ color: 'goodPurple.400',
+ textDecorationLine: 'underline',
+ cursor: 'pointer',
+ ...InterSemiBold,
+ },
ctaButton: {
borderRadius: 12,
paddingVertical: 16,
@@ -278,43 +269,58 @@ const welcomeStyles = {
const desktopWelcomeStyles = {
container: {
+ maxWidth: 760,
+ width: '100%',
marginHorizontal: 'auto',
- paddingHorizontal: 32,
- paddingTop: 40,
- paddingBottom: 60,
+ paddingHorizontal: 24,
+ paddingTop: 32,
+ paddingBottom: 48,
},
welcomeSection: {
- marginBottom: 40,
+ marginBottom: 32,
},
welcomeText: {
- fontSize: 96,
- marginBottom: 2,
+ fontSize: 56,
+ lineHeight: 64,
+ marginBottom: 4,
...InterSemiBold,
},
logoImage: {
- width: 1088,
- height: 145,
+ width: '100%',
+ maxWidth: 520,
+ height: 64,
resizeMode: 'contain',
},
infoBlock: {
- padding: 32,
- marginBottom: 24,
- borderWidth: 1,
+ padding: 24,
+ marginBottom: 20,
+ maxWidth: 720,
+ alignSelf: 'center',
+ width: '100%',
+ },
+ infoTitle: {
+ marginBottom: 12,
},
radioBlock: {
- padding: 32,
- marginBottom: 24,
+ padding: 24,
+ marginBottom: 20,
},
checkboxSection: {
- padding: 32,
- marginBottom: 32,
+ padding: 24,
+ marginBottom: 24,
+ maxWidth: 720,
+ alignSelf: 'center',
+ width: '100%',
},
ctaButton: {
- paddingVertical: 20,
- paddingHorizontal: 32,
+ paddingVertical: 16,
+ paddingHorizontal: 24,
+ maxWidth: 720,
+ alignSelf: 'center',
+ width: '100%',
},
ctaButtonText: {
- fontSize: 18,
+ fontSize: 16,
...InterSemiBold,
},
};
diff --git a/packages/app/src/components/ViewCollective.tsx b/packages/app/src/components/ViewCollective.tsx
index b14642d9..81975240 100644
--- a/packages/app/src/components/ViewCollective.tsx
+++ b/packages/app/src/components/ViewCollective.tsx
@@ -236,8 +236,9 @@ function ViewCollective({ collective }: ViewCollectiveProps) {
const [refetchTrigger, setRefetchTrigger] = useState(0);
const [isMemberPoolLoading, setIsMemberPoolLoading] = useState(false);
+ const [hasResolvedMemberPoolState, setHasResolvedMemberPoolState] = useState(pooltype !== 'UBI');
- const { isManager } = usePoolManager({
+ const { isManager, checkingRole } = usePoolManager({
poolAddress,
pooltype: pooltype as 'UBI' | 'DIRECT' | undefined,
chainId,
@@ -250,14 +251,16 @@ function ViewCollective({ collective }: ViewCollectiveProps) {
setMemberPoolData(null);
setPoolOnlyMembers(undefined);
setIsMemberPoolLoading(false);
+ setHasResolvedMemberPoolState(pooltype !== 'UBI');
return;
}
try {
+ setHasResolvedMemberPoolState(false);
setIsMemberPoolLoading(true);
const network = SupportedNetworkNames[chainId as SupportedNetwork];
const sdk = new GoodCollectiveSDK(chainId.toString() as any, provider, { network });
- // Always fetch pool settings to get onlyMembers, even if user is not a member
+ // Always fetch pool settings to get onlyMembers, even if user is not a member.
const poolDetails = await sdk.getUBIPoolsDetails([poolAddress], address);
const currentPool = poolDetails.find((pool: any) => pool.contract.toLowerCase() === poolAddress.toLowerCase());
@@ -265,6 +268,7 @@ function ViewCollective({ collective }: ViewCollectiveProps) {
setMemberPoolData(null);
setPoolOnlyMembers(undefined);
setIsMemberPoolLoading(false);
+ setHasResolvedMemberPoolState(true);
return;
}
@@ -273,19 +277,18 @@ function ViewCollective({ collective }: ViewCollectiveProps) {
const claimPeriodDaysRaw = currentPool.ubiSettings?.claimPeriodDays;
const onlyMembersRaw = currentPool.ubiSettings?.onlyMembers;
- // Always store onlyMembers setting for pool open check
+ // Always store onlyMembers setting for pool open check.
setPoolOnlyMembers(onlyMembersRaw as boolean | undefined);
if (!address || !currentPool.isRegistered) {
setMemberPoolData(null);
setIsMemberPoolLoading(false);
+ setHasResolvedMemberPoolState(true);
return;
}
const eligibleAmount = BigInt(claimAmountStr || '0');
- // Check if user has actually claimed by calling hasClaimed(address) on the contract
- // This is the only reliable way to know if they've claimed (not just if nextClaimTime exists)
const networkName = env.REACT_APP_NETWORK || 'development-celo';
const UBI_POOL_ABI =
(GoodCollectiveContracts as any)[chainId.toString()]?.find((envs: any) => envs.name === networkName)?.contracts
@@ -295,30 +298,31 @@ function ViewCollective({ collective }: ViewCollectiveProps) {
if (UBI_POOL_ABI.length > 0) {
try {
const poolContract = new ethers.Contract(poolAddress, UBI_POOL_ABI, provider);
+ // Check if user has actually claimed by calling hasClaimed(address) on the contract.
+ // This is the reliable way to know if they have claimed, not just whether nextClaimTime exists.
hasClaimedToday = await poolContract.hasClaimed(address);
} catch (e) {
- // If contract call fails, fall back to false
+ // If the contract call fails, fall back to false and keep the rest of the UI usable.
console.warn('Failed to check hasClaimed:', e);
}
}
- // hasClaimed should only be true if the user has actually claimed today
- // The countdown should only show after a successful claim transaction
+ // hasClaimed should only be true if the user has actually claimed today.
setMemberPoolData({
eligibleAmount,
hasClaimed: hasClaimedToday,
nextClaimTime: nextClaimTimeStr ? Number(nextClaimTimeStr) : undefined,
claimPeriodDays: claimPeriodDaysRaw !== undefined ? Number(claimPeriodDaysRaw) : undefined,
- // `onlyMembers` from the SDK is typed as PromiseOrValue, but in practice is a boolean.
- // Cast to the expected boolean | undefined shape for local state.
onlyMembers: onlyMembersRaw as boolean | undefined,
});
setIsMemberPoolLoading(false);
+ setHasResolvedMemberPoolState(true);
} catch (e) {
- // If SDK call fails, gracefully fall back to null so UI can still render
+ // If the SDK call fails, fall back to null so the page can still render safely.
setMemberPoolData(null);
setPoolOnlyMembers(undefined);
setIsMemberPoolLoading(false);
+ setHasResolvedMemberPoolState(true);
}
}, [address, poolAddress, pooltype, provider, chainId]);
@@ -345,6 +349,13 @@ function ViewCollective({ collective }: ViewCollectiveProps) {
2
);
+ const isPoolActionStateLoading =
+ pooltype === 'UBI' && (checkingRole || isMemberPoolLoading || !hasResolvedMemberPoolState);
+ const shouldShowJoinPoolButton =
+ !isManager && !checkingRole && !poolOnlyMembers && !memberPoolData && Boolean(address) && pooltype === 'UBI';
+ const shouldShowClaimRewardButton =
+ memberPoolData !== null && memberPoolData.eligibleAmount > 0n && Boolean(address) && pooltype === 'UBI';
+
if (isDesktopView) {
return (
@@ -388,14 +399,14 @@ function ViewCollective({ collective }: ViewCollectiveProps) {
{maybeDonorCollective && maybeDonorCollective.flowRate !== '0' ? null : (
- {pooltype === 'UBI' && isMemberPoolLoading ? (
+ {isPoolActionStateLoading ? (
- Loading pool details
+ Loading pool actions
) : (
<>
- {!poolOnlyMembers && !memberPoolData && address && pooltype === 'UBI' && (
+ {shouldShowJoinPoolButton && (
)}
- {memberPoolData && memberPoolData.eligibleAmount > 0n && address && pooltype === 'UBI' && (
+ {shouldShowClaimRewardButton && (
{infoLabel}
- {pooltype === 'UBI' && isMemberPoolLoading ? (
+ {isPoolActionStateLoading ? (
- Loading pool details
+ Loading pool actions
) : (
<>
- {!poolOnlyMembers && !memberPoolData && address && pooltype === 'UBI' && (
+ {shouldShowJoinPoolButton && (
)}
- {memberPoolData && memberPoolData.eligibleAmount > 0n && address && pooltype === 'UBI' && (
+ {shouldShowClaimRewardButton && (
{
+export const useMemberManagement = ({ poolAddress, pooltype, chainId }: UseMemberManagementParams) => {
const provider = useEthersProvider({ chainId });
const signer = useEthersSigner({ chainId });
+ const sdk = useMemo(() => {
+ if (!provider || !chainId) return null;
+ const chainIdString = chainId.toString() as `${SupportedNetwork}`;
+ const network = SupportedNetworkNames[chainId as SupportedNetwork];
+ return new GoodCollectiveSDK(chainIdString, provider as any, { network });
+ }, [chainId, provider]);
+
const [memberInput, setMemberInput] = useState('');
const [memberError, setMemberError] = useState(null);
+ const [memberSuccess, setMemberSuccess] = useState(null);
const [isAddingMembers, setIsAddingMembers] = useState(false);
- const [isRemovingMember, setIsRemovingMember] = useState(false);
+ const [removingMemberAddress, setRemovingMemberAddress] = useState(null);
+
const [managedMembers, setManagedMembers] = useState([]);
const [totalMemberCount, setTotalMemberCount] = useState(null);
-
- useEffect(() => {
- if (!poolAddress || pooltype !== 'UBI') {
+ const [isLoadingMembers, setIsLoadingMembers] = useState(false);
+
+ // Membership is sourced from sdk.getUBIPoolMembers, which scans MEMBER_ROLE
+ // grant/revoke events on the pool contract. We can't use the subgraph here
+ // because the subgraph's StewardCollective entity only tracks claim history
+ // (see packages/subgraph/src/mappings/ubipool.ts handleUBIClaim) - members
+ // who haven't claimed yet aren't in it, and members who were removed but
+ // claimed in the past would stay in it. Membership truth lives on-chain.
+ //
+ // The SDK helper still has the ~9500-block scan window inherited from RPC
+ // limits, but that's a single canonical implementation in the SDK rather
+ // than a duplicated one here. Improving that window is the SDK's concern.
+ const loadMembersFromChain = useCallback(async (): Promise => {
+ if (!sdk || !poolAddress || !pooltype) {
+ setManagedMembers([]);
+ setTotalMemberCount(null);
return;
}
-
- // Use the pre-loaded member list from parent component
- if (initialMembers) {
- setManagedMembers(initialMembers);
- setTotalMemberCount(initialMembers.length);
+ // No SDK helper for DirectPayments pool membership today. The manage UI
+ // already restricts most editing to UBI (pooltype !== 'UBI' guards), so
+ // we just keep an empty/local-only list for DirectPayments and let the
+ // user's own add tx populate it optimistically.
+ if (pooltype !== UBI_POOL_TYPE) {
+ setManagedMembers([]);
+ setTotalMemberCount(null);
+ return;
}
- }, [poolAddress, pooltype, initialMembers]);
-
- const parsedMemberAddresses = useMemo(() => {
- if (!memberInput) return [];
- return Array.from(
- new Set(
- memberInput
- .split(',')
- .map((a) => a.trim())
- .filter((a) => a.length > 0)
- .map((a) => a.toLowerCase())
- )
- );
- }, [memberInput]);
- const validateMemberAddresses = (): string | null => {
- if (!parsedMemberAddresses.length) {
- return 'Please enter at least one wallet address.';
+ try {
+ setIsLoadingMembers(true);
+ const result = await sdk.getUBIPoolMembers(poolAddress);
+ setManagedMembers(result.members);
+ setTotalMemberCount(result.onChainCount ?? result.count);
+ } catch (error) {
+ console.error('Failed to load UBI pool members:', error);
+ setManagedMembers([]);
+ setTotalMemberCount(null);
+ } finally {
+ setIsLoadingMembers(false);
}
+ }, [sdk, poolAddress, pooltype]);
- const invalid = parsedMemberAddresses.find((addr) => !/^0x[a-fA-F0-9]{40}$/.test(addr));
- if (invalid) {
- return `Invalid wallet address: ${invalid}`;
+ useEffect(() => {
+ loadMembersFromChain();
+ }, [loadMembersFromChain]);
+
+ const parsedMemberAddresses = useMemo(() => parseMemberAddresses(memberInput), [memberInput]);
+
+ useEffect(() => {
+ if (memberInput.trim() !== '') {
+ setMemberSuccess(null);
+ setMemberError(null);
}
+ }, [memberInput]);
- return null;
+ const clearStatus = () => {
+ setMemberError(null);
+ setMemberSuccess(null);
};
const handleAddMembers = async () => {
- setMemberError(null);
- const error = validateMemberAddresses();
+ clearStatus();
+ const error = validateMemberAddresses(parsedMemberAddresses);
if (error) {
setMemberError(error);
return;
}
- if (!signer || !poolAddress || pooltype !== 'UBI' || !provider) {
- setMemberError('Member management is currently supported for UBI pools only.');
+ if (!signer || !poolAddress || !pooltype || !provider || !sdk) {
+ setMemberError('Pool management is not fully initialized.');
+ return;
+ }
+
+ if (pooltype !== UBI_POOL_TYPE && pooltype !== DIRECT_PAYMENTS_POOL_TYPE) {
+ setMemberError('Member management is currently supported for UBI and Direct Payments pools only.');
+ return;
+ }
+
+ const addressesToAdd = parsedMemberAddresses.filter(
+ (addr) => !managedMembers.some((m) => m.toLowerCase() === addr.toLowerCase())
+ );
+
+ if (addressesToAdd.length === 0) {
+ setMemberError('All entered addresses are already members of this pool.');
return;
}
try {
setIsAddingMembers(true);
+ const operatorAddress = (await signer.getAddress()).toLowerCase();
+ const pool = pooltype === UBI_POOL_TYPE ? sdk.ubipool.attach(poolAddress) : sdk.pool.attach(poolAddress);
+ const settings = (await (pool as any).settings()) as {
+ membersValidator?: string;
+ uniquenessValidator?: string;
+ };
+
+ const { validAddresses, skippedAddresses } = await assessPoolMemberEligibility({
+ provider,
+ addresses: addressesToAdd,
+ uniquenessValidator: settings.uniquenessValidator,
+ membersValidator: settings.membersValidator,
+ poolAddress,
+ operatorAddress,
+ existingMembers: managedMembers,
+ });
- const chainIdString = chainId.toString() as `${SupportedNetwork}`;
- const network = SupportedNetworkNames[chainId as SupportedNetwork];
-
- const sdk = new GoodCollectiveSDK(chainIdString, provider, { network });
+ if (validAddresses.length === 0) {
+ const skippedSummary = formatSkippedMembersMessage(skippedAddresses);
+ const fallbackReason =
+ pooltype === UBI_POOL_TYPE && !isZeroAddress(settings.uniquenessValidator)
+ ? 'For this pool, members must be verified by the pool uniqueness validator before they can be added.'
+ : 'None of the pasted addresses can be added to this pool.';
- // Use SDK method to add members
- for (const addr of parsedMemberAddresses) {
- const tx = await sdk.addUBIPoolMember(signer, poolAddress, addr);
- await tx.wait();
+ setMemberError(skippedSummary ? `No members were added. ${skippedSummary}` : fallbackReason);
+ setMemberSuccess(null);
+ return;
}
- // Optimistically bump the total on-chain member count
- setTotalMemberCount((prev) => (prev ?? 0) + parsedMemberAddresses.length);
+ const extraData = validAddresses.map(() => '0x');
+ const tx = await sdk.addPoolMembers(signer as any, poolAddress, validAddresses, extraData);
+ await tx.wait();
+ // Optimistically merge so the new rows appear immediately, then reconcile
+ // with chain state for UBI pools.
setManagedMembers((prev) => {
const next = new Set(prev.map((a) => a.toLowerCase()));
- parsedMemberAddresses.forEach((a) => next.add(a));
+ validAddresses.forEach((a) => next.add(a.toLowerCase()));
return Array.from(next);
});
+ if (pooltype === UBI_POOL_TYPE) {
+ await loadMembersFromChain();
+ } else {
+ setTotalMemberCount((prev) => (prev ?? managedMembers.length) + validAddresses.length);
+ }
+
+ const skippedSummary = formatSkippedMembersMessage(skippedAddresses);
setMemberInput('');
+ setMemberSuccess(
+ `Successfully added ${validAddresses.length} member${validAddresses.length !== 1 ? 's' : ''}.${
+ skippedSummary ? ` Skipped: ${skippedSummary}.` : ''
+ }`
+ );
} catch (e: any) {
setMemberError(e?.reason || e?.message || 'Failed to add members.');
+ setMemberSuccess(null);
} finally {
setIsAddingMembers(false);
}
};
const handleRemoveMember = async (member: string) => {
- if (!signer || !poolAddress || pooltype !== 'UBI' || !provider) {
- setMemberError('Member management is currently supported for UBI pools only.');
+ clearStatus();
+
+ if (!signer || !poolAddress || !provider || !sdk) {
+ setMemberError('Pool management is not fully initialized.');
return;
}
- try {
- setIsRemovingMember(true);
-
- const chainIdString = chainId.toString() as `${SupportedNetwork}`;
- const network = SupportedNetworkNames[chainId as SupportedNetwork];
+ if (pooltype !== UBI_POOL_TYPE) {
+ setMemberError('Member removal is currently supported for UBI pools only.');
+ return;
+ }
- const sdk = new GoodCollectiveSDK(chainIdString, provider, { network });
+ try {
+ setRemovingMemberAddress(member);
- // Use SDK method to remove member
- const tx = await sdk.removeUBIPoolMember(signer, poolAddress, member);
+ const tx = await sdk.removeUBIPoolMember(signer as any, poolAddress, member);
await tx.wait();
- // Optimistically decrease the total on-chain member count
- setTotalMemberCount((prev) => {
- if (prev === null) return prev;
- return prev > 0 ? prev - 1 : 0;
- });
+ // Optimistic local removal so the row disappears immediately, then
+ // reconcile with chain state.
+ const memberLower = member.toLowerCase();
+ setManagedMembers((prev) => prev.filter((m) => m.toLowerCase() !== memberLower));
+ await loadMembersFromChain();
- setManagedMembers((prev) => prev.filter((m) => m.toLowerCase() !== member.toLowerCase()));
+ setMemberSuccess('Successfully removed member.');
} catch (e: any) {
setMemberError(e?.reason || e?.message || 'Failed to remove member.');
+ setMemberSuccess(null);
} finally {
- setIsRemovingMember(false);
+ setRemovingMemberAddress(null);
}
};
@@ -138,11 +227,14 @@ export const useMemberManagement = ({ poolAddress, pooltype, chainId, initialMem
memberInput,
setMemberInput,
memberError,
+ memberSuccess,
isAddingMembers,
- isRemovingMember,
+ removingMemberAddress,
managedMembers,
totalMemberCount,
+ isLoadingMembers,
handleAddMembers,
handleRemoveMember,
+ parsedMemberAddresses,
};
};
diff --git a/packages/app/src/hooks/managePool/usePoolManager.ts b/packages/app/src/hooks/managePool/usePoolManager.ts
index ac34ec6b..4f55ce75 100644
--- a/packages/app/src/hooks/managePool/usePoolManager.ts
+++ b/packages/app/src/hooks/managePool/usePoolManager.ts
@@ -15,6 +15,15 @@ interface UsePoolManagerParams {
provider?: ethers.providers.Provider;
}
+/**
+ * Looks up whether the connected (or supplied) address holds MANAGER_ROLE on
+ * the given pool. `isManager` is internally tri-state - undefined means we
+ * haven't checked yet for the current inputs, so callers can distinguish
+ * "not a manager" from "still resolving" via the derived `checkingRole`.
+ *
+ * Returned shape stays boolean for callers, with `checkingRole === true`
+ * covering both the in-flight fetch and the pre-fetch unresolved state.
+ */
export const usePoolManager = ({
poolAddress,
pooltype,
@@ -26,24 +35,31 @@ export const usePoolManager = ({
const chainIdFromAccount = chain?.id;
const defaultProvider = useEthersProvider({ chainId: chainIdParam ?? chainIdFromAccount ?? 42220 });
- // Use provided values or fall back to account/context values
const address = addressParam ?? accountAddress;
const chainId = chainIdParam ?? chainIdFromAccount ?? 42220;
const provider = providerParam ?? defaultProvider;
+ const hasRoleInputs = Boolean(address && poolAddress && chainId && pooltype);
- const [isManager, setIsManager] = useState(false);
- const [checkingRole, setCheckingRole] = useState(false);
+ // undefined = "not yet resolved for the current inputs", which keeps the
+ // UI in a loading state instead of flashing "not manager" before the
+ // on-chain check completes.
+ const [isManager, setIsManager] = useState(undefined);
+ const [isFetching, setIsFetching] = useState(false);
useEffect(() => {
- const checkIsManager = async () => {
- if (!address || !provider || !poolAddress || !chainId || !pooltype) {
- setIsManager(false);
- setCheckingRole(false);
- return;
- }
+ // Inputs aren't ready - clear resolution state so a future change starts
+ // a fresh check and the UI stays in loading until then.
+ if (!hasRoleInputs || !provider) {
+ setIsManager(undefined);
+ setIsFetching(false);
+ return;
+ }
+ let cancelled = false;
+ const check = async () => {
try {
- setCheckingRole(true);
+ setIsManager(undefined);
+ setIsFetching(true);
const chainKey = chainId.toString();
const networkName = env.REACT_APP_NETWORK || 'development-celo';
@@ -55,23 +71,31 @@ export const usePoolManager = ({
(pooltype === 'UBI' ? contractsForChain?.UBIPool?.abi : contractsForChain?.DirectPaymentsPool?.abi) || [];
if (!poolAbi.length) {
- setIsManager(false);
+ if (!cancelled) setIsManager(false);
return;
}
const MANAGER_ROLE = ethers.utils.keccak256(ethers.utils.toUtf8Bytes('MANAGER_ROLE'));
- const contract = new ethers.Contract(poolAddress, poolAbi, provider);
+ const contract = new ethers.Contract(poolAddress as string, poolAbi, provider);
const hasRole = await contract.hasRole(MANAGER_ROLE, address);
- setIsManager(Boolean(hasRole));
+ if (!cancelled) setIsManager(Boolean(hasRole));
} catch {
- setIsManager(false);
+ if (!cancelled) setIsManager(false);
} finally {
- setCheckingRole(false);
+ if (!cancelled) setIsFetching(false);
}
};
- checkIsManager();
- }, [address, poolAddress, pooltype, provider, chainId]);
+ check();
+ return () => {
+ cancelled = true;
+ };
+ }, [address, chainId, hasRoleInputs, poolAddress, pooltype, provider]);
+
+ // `checkingRole` is true while a fetch is in flight, and also while inputs
+ // are valid but resolution hasn't completed yet (covers the brief render
+ // between a roleKey change and the effect running).
+ const checkingRole = isFetching || (hasRoleInputs && isManager === undefined);
- return { isManager, checkingRole };
+ return { isManager: Boolean(isManager), checkingRole };
};
diff --git a/packages/app/src/hooks/useCreatePool/CreatePoolContext.tsx b/packages/app/src/hooks/useCreatePool/CreatePoolContext.tsx
index 06ec2232..d19b2059 100644
--- a/packages/app/src/hooks/useCreatePool/CreatePoolContext.tsx
+++ b/packages/app/src/hooks/useCreatePool/CreatePoolContext.tsx
@@ -5,12 +5,21 @@ import { createContext, ReactNode, useState } from 'react';
import { useAccount } from 'wagmi';
import { v4 as uuidv4 } from 'uuid';
import { UBIPool } from '../../../../contracts/typechain-types/contracts/UBI/UBIPool';
-import { GDEnvTokens, SupportedNetwork, SupportedNetworkNames } from '../../models/constants';
+import {
+ GDEnvTokens,
+ SupportedNetwork,
+ SupportedNetworkNames,
+ getMembersValidatorAddress,
+ getUniquenessValidatorAddress,
+} from '../../models/constants';
import useCrossNavigate from '../../routes/useCrossNavigate';
import { validateConnection } from '../useContractCalls/util';
import { useEthersSigner } from '../useEthers';
import { formatSocialUrls } from '../../lib/formatSocialUrls';
+import { assessPoolMemberEligibility, formatSkippedMembersMessage } from '../../lib/poolMemberEligibility';
import { Form } from './useCreatePool';
+import { validatePoolRecipients } from './usePoolConfigurationValidation';
+import { PoolMembersAddError } from './PoolMembersAddError';
type CreatePoolContextType = {
step: number;
@@ -73,7 +82,7 @@ export const CreatePoolProvider = ({ children }: { children: ReactNode }) => {
const createPool = async () => {
const validation = validateConnection(maybeAddress, chain?.id, maybeSigner);
if (typeof validation === 'string') {
- return false;
+ throw new Error(validation);
}
const { chainId, signer } = validation;
const chainIdString = chainId.toString() as `${SupportedNetwork}`;
@@ -82,6 +91,7 @@ export const CreatePoolProvider = ({ children }: { children: ReactNode }) => {
const sdk = new GoodCollectiveSDK(chainIdString, signer.provider as ethers.providers.Provider, { network });
// Final form validation
+
if (!form.projectName || !form.projectDescription) {
console.error('Missing required project details');
return false;
@@ -100,7 +110,24 @@ export const CreatePoolProvider = ({ children }: { children: ReactNode }) => {
return false;
}
- const projectId = form.projectName.replace(' ', '/').toLowerCase() + '-' + uuidv4();
+ const recipientsValidation = validatePoolRecipients(form.poolRecipients, form.maximumMembers);
+ if (!recipientsValidation.isValid) {
+ throw new Error(recipientsValidation.error ?? 'Invalid member addresses.');
+ }
+ const { memberAddresses } = recipientsValidation;
+
+ // Defense in depth: never deploy a closed (onlyMembers=true) pool with zero
+ // initial members. The pool would be permanently inaccessible - no one
+ // could join or claim. The PoolConfiguration step blocks this in the UI,
+ // but we re-check here in case createPool is invoked in some other flow.
+ if (form.joinStatus === 'closed' && memberAddresses.length === 0) {
+ throw new Error(
+ 'Closed pools must have at least one initial member, otherwise the pool would have no way for members to join or claim.'
+ );
+ }
+
+ const projectIdBase = form.projectName.trim().toLowerCase().replace(/\s+/g, '/');
+ const projectId = `${projectIdBase}-${uuidv4()}`;
const poolAttributes = {
name: form.projectName,
@@ -123,20 +150,22 @@ export const CreatePoolProvider = ({ children }: { children: ReactNode }) => {
const poolSettings: UBIPoolSettings = {
manager: await signer.getAddress(),
- membersValidator: ethers.constants.AddressZero,
- uniquenessValidator: '0xC361A6E67822a0EDc17D899227dd9FC50BD62F42',
+ membersValidator: getMembersValidatorAddress(chainId),
+ uniquenessValidator: getUniquenessValidatorAddress(chainId),
rewardToken: rewardToken,
};
// Calculate cycle length based on claim frequency
const cycleLengthDays = form.claimFrequency && form.claimFrequency <= 7 ? 7 : form.claimFrequency || 1;
+ // Join status explicitly controls members-only behavior, even if the pool starts empty.
+ const onlyMembers = form.joinStatus === 'closed';
const ubiSettings: UBISettings = {
claimPeriodDays: ethers.BigNumber.from(form.claimFrequency || 1),
minActiveUsers: ethers.BigNumber.from(1),
maxClaimAmount: ethers.utils.parseEther(String(form.claimAmountPerWeek || 0)),
maxMembers: form.maximumMembers || form.expectedMembers || 100,
- onlyMembers: form.poolRecipients ? form.canNewMembersJoin ?? false : false,
+ onlyMembers,
cycleLengthDays: ethers.BigNumber.from(cycleLengthDays),
claimForEnabled: false,
};
@@ -147,6 +176,26 @@ export const CreatePoolProvider = ({ children }: { children: ReactNode }) => {
managerFeeBps: (form.managerFeePercentage || 0) * 100,
};
+ if (memberAddresses.length > 0) {
+ const operatorAddress = (await signer.getAddress()).toLowerCase();
+ const { validAddresses, skippedAddresses } = await assessPoolMemberEligibility({
+ provider: signer.provider as ethers.providers.Provider,
+ addresses: memberAddresses,
+ uniquenessValidator: poolSettings.uniquenessValidator as string,
+ membersValidator: poolSettings.membersValidator as string,
+ operatorAddress,
+ });
+
+ if (validAddresses.length !== memberAddresses.length) {
+ const skippedSummary = formatSkippedMembersMessage(skippedAddresses);
+ throw new Error(
+ skippedSummary
+ ? `Some initial members cannot be added to this pool: ${skippedSummary}. Please update the list before launching.`
+ : 'Some initial members cannot be added to this pool. Please update the list before launching.'
+ );
+ }
+ }
+
try {
console.log('Creating UBI pool with settings:', {
projectId,
@@ -165,8 +214,28 @@ export const CreatePoolProvider = ({ children }: { children: ReactNode }) => {
extendedUBISettings,
false // isBeacon should always be false as per requirements
);
- console.log('Pool created successfully:', pool.address);
+
+ // Pool deployment succeeded; persist the address up-front so that even if
+ // the follow-up addPoolMembers transaction fails, the recovery UI can
+ // link the user to the manage page for this pool.
submitPartial({ createdPoolAddress: pool.address });
+
+ if (memberAddresses.length > 0) {
+ try {
+ const extraData = memberAddresses.map(() => '0x');
+ const tx = await sdk.addPoolMembers(signer, pool.address, memberAddresses, extraData);
+ await tx.wait();
+ } catch (addMembersError) {
+ // Pool exists on-chain but the second tx failed (user rejected, gas,
+ // network drop, etc). Surface a recoverable error carrying the pool
+ // address so the UI can direct the user to the manage page to retry
+ // instead of leaving them with a stranded deployed pool.
+ console.error('Pool deployed but addPoolMembers failed:', addMembersError);
+ throw new PoolMembersAddError(pool.address, memberAddresses, addMembersError);
+ }
+ }
+
+ console.log('Pool created successfully:', pool.address);
setStep(6); // Move to success step
return pool;
} catch (error) {
diff --git a/packages/app/src/hooks/useCreatePool/PoolMembersAddError.ts b/packages/app/src/hooks/useCreatePool/PoolMembersAddError.ts
new file mode 100644
index 00000000..3e93a326
--- /dev/null
+++ b/packages/app/src/hooks/useCreatePool/PoolMembersAddError.ts
@@ -0,0 +1,28 @@
+/**
+ * Thrown when the pool was successfully deployed on-chain but the follow-up
+ * addPoolMembers transaction failed. The pool address and the attempted
+ * member list are preserved so the UI can direct the user to the manage page
+ * to retry adding members - with the list ready to copy - instead of leaving
+ * them stranded with a deployed-but-empty pool.
+ */
+export class PoolMembersAddError extends Error {
+ readonly poolAddress: string;
+ readonly memberAddresses: string[];
+ readonly cause: unknown;
+
+ constructor(poolAddress: string, memberAddresses: string[], cause: unknown) {
+ const reason =
+ (cause as { reason?: string; message?: string })?.reason ??
+ (cause as { message?: string })?.message ??
+ 'Unknown error';
+ super(`Pool deployed but adding initial members failed: ${reason}`);
+ this.name = 'PoolMembersAddError';
+ this.poolAddress = poolAddress;
+ this.memberAddresses = memberAddresses;
+ this.cause = cause;
+ }
+}
+
+export const isPoolMembersAddError = (error: unknown): error is PoolMembersAddError =>
+ error instanceof PoolMembersAddError ||
+ (typeof error === 'object' && error !== null && (error as { name?: string }).name === 'PoolMembersAddError');
diff --git a/packages/app/src/hooks/useCreatePool/usePoolConfigurationValidation.ts b/packages/app/src/hooks/useCreatePool/usePoolConfigurationValidation.ts
index 0cf95180..e355d3f5 100644
--- a/packages/app/src/hooks/useCreatePool/usePoolConfigurationValidation.ts
+++ b/packages/app/src/hooks/useCreatePool/usePoolConfigurationValidation.ts
@@ -1,4 +1,5 @@
import { useState } from 'react';
+import { parseMemberAddresses, validateMemberAddresses } from '../../lib/memberAddresses';
export type FormError = {
maximumMembers?: string;
@@ -8,6 +9,7 @@ export type FormError = {
customClaimFrequency?: string;
expectedMembers?: string;
managerFeePercentage?: string;
+ poolRecipients?: string;
};
export type PoolConfigurationFormData = {
@@ -22,6 +24,35 @@ export type PoolConfigurationFormData = {
joinStatus?: 'closed' | 'open';
};
+export type PoolRecipientsValidation = {
+ isValid: boolean;
+ memberAddresses: string[];
+ error?: string;
+};
+
+export const validatePoolRecipients = (poolRecipients?: string, maximumMembers?: number): PoolRecipientsValidation => {
+ const trimmedRecipients = poolRecipients?.trim() ?? '';
+ if (!trimmedRecipients) {
+ return { isValid: true, memberAddresses: [] };
+ }
+
+ const members = parseMemberAddresses(trimmedRecipients);
+ const memberError = validateMemberAddresses(members);
+ if (memberError) {
+ return { isValid: false, memberAddresses: members, error: memberError };
+ }
+
+ if (maximumMembers != null && members.length > maximumMembers) {
+ return {
+ isValid: false,
+ memberAddresses: members,
+ error: `You listed ${members.length} members but the maximum is ${maximumMembers}`,
+ };
+ }
+
+ return { isValid: true, memberAddresses: members };
+};
+
export const usePoolConfigurationValidation = () => {
const [errors, setErrors] = useState({});
@@ -30,6 +61,7 @@ export const usePoolConfigurationValidation = () => {
let pass = true;
const {
+ poolRecipients,
maximumMembers,
claimFrequency,
customClaimFrequency,
@@ -37,6 +69,7 @@ export const usePoolConfigurationValidation = () => {
expectedMembers,
poolManagerFeeType,
managerFeePercentage,
+ joinStatus,
} = formData;
// Validate maximum members
@@ -69,6 +102,20 @@ export const usePoolConfigurationValidation = () => {
pass = false;
}
+ // Validate initial member addresses (optional)
+ const recipientsValidation = validatePoolRecipients(poolRecipients, maximumMembers);
+ if (!recipientsValidation.isValid) {
+ currErrors.poolRecipients = recipientsValidation.error ?? 'Invalid member addresses.';
+ pass = false;
+ } else if (joinStatus === 'closed' && recipientsValidation.memberAddresses.length === 0) {
+ // A closed pool with no initial members would deploy with onlyMembers=true and
+ // zero members - nobody could ever join or claim. Force the user to either add
+ // initial members or switch the pool to open before proceeding.
+ currErrors.poolRecipients =
+ 'Closed pools must have at least one initial member. Add a member address or set the pool to Open.';
+ pass = false;
+ }
+
setErrors(currErrors);
return pass;
};
diff --git a/packages/app/src/lib/defaults.ts b/packages/app/src/lib/defaults.ts
index 579e2ed4..043582f1 100644
--- a/packages/app/src/lib/defaults.ts
+++ b/packages/app/src/lib/defaults.ts
@@ -2,7 +2,7 @@ export const defaults = {
REACT_APP_CELO_EXPLORER: 'https://celoscan.io',
REACT_APP_SUPERFLUID_EXPLORER: 'https://app.superfluid.finance/stream/celo',
REACT_APP_NETWORK: 'development-celo',
- REACT_APP_SUBGRAPH: 'https://api.studio.thegraph.com/query/59211/goodcollective/dev-v1.0.13/',
+ REACT_APP_SUBGRAPH: 'https://api.studio.thegraph.com/query/59211/goodcollective/version/latest/',
REACT_APP_FEE_DOCS_LINK:
'https://docs.gooddollar.org/wallet-and-products/goodcollective#what-are-the-fees-associated-with-starting-or-funding-a-goodcollective',
};
diff --git a/packages/app/src/lib/memberAddresses.ts b/packages/app/src/lib/memberAddresses.ts
new file mode 100644
index 00000000..411237bf
--- /dev/null
+++ b/packages/app/src/lib/memberAddresses.ts
@@ -0,0 +1,26 @@
+import { isAddress } from 'viem';
+
+export const parseMemberAddresses = (input?: string): string[] => {
+ if (!input) return [];
+
+ const normalized = input
+ .split(/[\n,]+/)
+ .map((address) => address.trim())
+ .filter((address) => address.length > 0)
+ .map((address) => address.toLowerCase());
+
+ return Array.from(new Set(normalized));
+};
+
+export const validateMemberAddresses = (members: string[]): string | null => {
+ if (!members.length) {
+ return 'Please enter at least one wallet address.';
+ }
+
+ const invalid = members.find((address) => !isAddress(address));
+ if (invalid) {
+ return `Invalid wallet address: ${invalid}`;
+ }
+
+ return null;
+};
diff --git a/packages/app/src/lib/poolMemberEligibility.ts b/packages/app/src/lib/poolMemberEligibility.ts
new file mode 100644
index 00000000..37d8072a
--- /dev/null
+++ b/packages/app/src/lib/poolMemberEligibility.ts
@@ -0,0 +1,126 @@
+import { ethers } from 'ethers';
+
+type EligibilityReason = 'already-member' | 'not-whitelisted' | 'validator-rejected';
+
+export type MemberEligibilityResult = {
+ validAddresses: string[];
+ skippedAddresses: Array<{
+ address: string;
+ reason: EligibilityReason;
+ }>;
+};
+
+type AssessPoolMembersParams = {
+ provider: ethers.providers.Provider;
+ addresses: string[];
+ uniquenessValidator?: string | null;
+ membersValidator?: string | null;
+ poolAddress?: string;
+ operatorAddress?: string;
+ existingMembers?: string[];
+};
+
+const identityAbi = ['function getWhitelistedRoot(address member) view returns (address)'];
+const membersValidatorAbi = [
+ 'function isMemberValid(address pool,address operator,address member,bytes extraData) returns (bool)',
+];
+
+const zeroAddress = ethers.constants.AddressZero.toLowerCase();
+
+export const isZeroAddress = (value?: string | null) => !value || value.toLowerCase() === zeroAddress;
+
+export const formatSkippedMembersMessage = (
+ skippedAddresses: Array<{ address: string; reason: EligibilityReason }>
+): string | null => {
+ if (skippedAddresses.length === 0) {
+ return null;
+ }
+
+ const reasonLabel = (reason: EligibilityReason) => {
+ switch (reason) {
+ case 'already-member':
+ return 'already a member';
+ case 'not-whitelisted':
+ return 'not verified by the pool uniqueness validator';
+ case 'validator-rejected':
+ return 'rejected by the pool members validator';
+ default:
+ return 'not eligible';
+ }
+ };
+
+ const preview = skippedAddresses
+ .slice(0, 3)
+ .map(({ address, reason }) => `${address} (${reasonLabel(reason)})`)
+ .join(', ');
+
+ if (skippedAddresses.length <= 3) {
+ return preview;
+ }
+
+ return `${preview}, and ${skippedAddresses.length - 3} more`;
+};
+
+export const assessPoolMemberEligibility = async ({
+ provider,
+ addresses,
+ uniquenessValidator,
+ membersValidator,
+ poolAddress,
+ operatorAddress,
+ existingMembers = [],
+}: AssessPoolMembersParams): Promise => {
+ const uniqueAddresses = Array.from(new Set(addresses.map((address) => address.toLowerCase())));
+ const existingMemberSet = new Set(existingMembers.map((address) => address.toLowerCase()));
+
+ const identityContract =
+ !isZeroAddress(uniquenessValidator) && uniquenessValidator
+ ? new ethers.Contract(uniquenessValidator, identityAbi, provider)
+ : null;
+
+ const validatorContract =
+ !isZeroAddress(membersValidator) && membersValidator && poolAddress && operatorAddress
+ ? new ethers.Contract(membersValidator, membersValidatorAbi, provider)
+ : null;
+
+ const checks = await Promise.all(
+ uniqueAddresses.map(async (address) => {
+ if (existingMemberSet.has(address)) {
+ return { address, reason: 'already-member' as const };
+ }
+
+ if (identityContract) {
+ const whitelistedRoot = (await identityContract.getWhitelistedRoot(address)) as string;
+ if (!whitelistedRoot || whitelistedRoot.toLowerCase() === zeroAddress) {
+ return { address, reason: 'not-whitelisted' as const };
+ }
+ }
+
+ if (validatorContract) {
+ try {
+ const isValid = (await validatorContract.callStatic.isMemberValid(
+ poolAddress,
+ operatorAddress,
+ address,
+ '0x'
+ )) as boolean;
+
+ if (!isValid) {
+ return { address, reason: 'validator-rejected' as const };
+ }
+ } catch {
+ return { address, reason: 'validator-rejected' as const };
+ }
+ }
+
+ return { address, reason: null };
+ })
+ );
+
+ return {
+ validAddresses: checks.filter((result) => result.reason === null).map((result) => result.address),
+ skippedAddresses: checks
+ .filter((result): result is { address: string; reason: EligibilityReason } => result.reason !== null)
+ .map(({ address, reason }) => ({ address, reason })),
+ };
+};
diff --git a/packages/app/src/models/constants.ts b/packages/app/src/models/constants.ts
index 91699adf..d9fd712b 100644
--- a/packages/app/src/models/constants.ts
+++ b/packages/app/src/models/constants.ts
@@ -1,6 +1,7 @@
import { Token } from '@uniswap/sdk-core';
import GdContracts from '@gooddollar/goodprotocol/releases/deployment.json';
import GoodCollectiveContracts from '../../../contracts/releases/deployment.json';
+import { ethers } from 'ethers';
import env from '../lib/env';
@@ -53,6 +54,63 @@ export const defaultInfoLabel = 'Please see the smart contract for information r
export const SUBGRAPH_POLL_INTERVAL = parseInt(process.env.IS_DONATING_POLL_INTERVAL ?? '30000', 10);
+/**
+ * Map of chainId -> the goodprotocol deployment name whose IdentityV2 contract
+ * is the canonical uniqueness validator for pools on that chain.
+ *
+ * IdentityV2 is a chain-level singleton: even though goodprotocol ships
+ * multiple GoodDollar deployments per chain (production / staging / dev /
+ * pre-production for Celo mainnet), they all read whitelist state off the
+ * production IdentityV2 in practice. Pools created against any of the
+ * GoodCollective factory variants are gated on the production registry, so
+ * the validator we embed in pool settings should be the production one.
+ */
+const IDENTITY_DEPLOYMENT_NAME_BY_CHAIN: Record = {
+ 42220: 'production-celo',
+ 44787: 'alfajores',
+};
+
+/**
+ * Returns the IdentityV2 (uniqueness validator) address for the given chainId,
+ * read from the @gooddollar/goodprotocol deployment manifest. Replaces the
+ * hardcoded literal that used to live at the call sites.
+ *
+ * Returns ethers.constants.AddressZero when no deployment is found - callers
+ * treat AddressZero as "no uniqueness check" (see isZeroAddress in
+ * poolMemberEligibility).
+ */
+export function getUniquenessValidatorAddress(chainId?: number): string {
+ if (!chainId) return ethers.constants.AddressZero;
+
+ const deploymentName = IDENTITY_DEPLOYMENT_NAME_BY_CHAIN[chainId];
+ if (!deploymentName) return ethers.constants.AddressZero;
+
+ const deployments = GdContracts as unknown as Record;
+ const address = deployments[deploymentName]?.Identity;
+ if (address && address !== ethers.constants.AddressZero) {
+ return address;
+ }
+
+ return ethers.constants.AddressZero;
+}
+
+/**
+ * Returns the optional IMembersValidator address for the given chainId.
+ *
+ * Pools treat address(0) as "no extra membership rule" - the contracts only
+ * call isMemberValid(...) when a non-zero validator is configured. The MVP
+ * does not expose a custom validator, so this returns AddressZero today.
+ * The intent is to swap this out for a form field on the create-pool flow
+ * later, falling back to AddressZero when the user leaves it blank.
+ *
+ * Keeping the indirection in one helper means the eligibility preview in
+ * PoolConfiguration and the deploy-time settings in CreatePoolContext share
+ * a single source instead of repeating the literal at both call sites.
+ */
+export function getMembersValidatorAddress(_chainId?: number): string {
+ return ethers.constants.AddressZero;
+}
+
/**
* Returns the ProvableNFT contract address for the given network name.
* @param networkName - The network name
diff --git a/packages/app/src/pages/ManageCollectivePage.tsx b/packages/app/src/pages/ManageCollectivePage.tsx
index ebd54a81..68d93de7 100644
--- a/packages/app/src/pages/ManageCollectivePage.tsx
+++ b/packages/app/src/pages/ManageCollectivePage.tsx
@@ -1,4 +1,4 @@
-import { HStack, Input, ScrollView, Spinner, Switch, Text, VStack } from 'native-base';
+import { HStack, ScrollView, Spinner, Switch, Text, TextArea, VStack } from 'native-base';
import { useMemo, useState } from 'react';
import { useParams } from 'react-router-native';
import { useAccount } from 'wagmi';
@@ -70,15 +70,10 @@ const ManageCollectivePage = () => {
chainId,
});
- const memberList = useMemo(() => {
- return collective?.stewardCollectives.map((steward) => steward.steward) || [];
- }, [collective?.stewardCollectives]);
-
const memberManagement = useMemberManagement({
poolAddress,
pooltype,
chainId,
- initialMembers: memberList,
});
if (!collective) {
@@ -89,7 +84,36 @@ const ManageCollectivePage = () => {
);
}
- if (!address || (!isManager && !checkingRole)) {
+ if (!address) {
+ return (
+
+
+
+ Pool Admin Panel
+
+ You must be connected with a pool manager wallet to access these settings.
+
+
+ );
+ }
+
+ if (checkingRole) {
+ return (
+
+
+
+ Pool Admin Panel
+
+
+
+ Checking manager access...
+
+
+
+ );
+ }
+
+ if (!isManager) {
return (
@@ -417,30 +441,40 @@ const ManageCollectivePage = () => {
) : (
- {/* Add New Member Section */}
-
+ {/* Bulk Add Members Section */}
+
+ Paste wallet addresses separated by commas or new lines.
- New Member Wallet Address
-
-
-
-
+ Wallet Addresses
+
+
+ {memberManagement.parsedMemberAddresses.length > 0
+ ? `Parsed ${memberManagement.parsedMemberAddresses.length} unique address${
+ memberManagement.parsedMemberAddresses.length !== 1 ? 'es' : ''
+ }.`
+ : 'Enter addresses separated by commas or new lines.'}
+
+
+
@@ -484,9 +518,11 @@ const ManageCollectivePage = () => {
memberManagement.handleRemoveMember(member)}
- isLoading={memberManagement.isRemovingMember}
- isDisabled={memberManagement.isRemovingMember || memberManagement.isAddingMembers}
- text={memberManagement.isRemovingMember ? 'Removing Member...' : 'Remove Member'}
+ isLoading={memberManagement.removingMemberAddress === member}
+ isDisabled={
+ memberManagement.removingMemberAddress !== null || memberManagement.isAddingMembers
+ }
+ text={memberManagement.removingMemberAddress === member ? 'Removing...' : 'Remove Member'}
bg="red.500"
textColor="white"
borderRadius={12}