-
Notifications
You must be signed in to change notification settings - Fork 74
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #1336 from near/add_tool_for_dao_creation
feature: add brand new tool for creating DAOs & Multisigs
- Loading branch information
Showing
9 changed files
with
930 additions
and
6 deletions.
There are no files selected for viewing
398 changes: 398 additions & 0 deletions
398
src/components/tools/DecentralizedOrganization/CreateDaoForm.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,398 @@ | ||
/* eslint-disable @typescript-eslint/no-non-null-assertion */ | ||
import { Button, FileInput, Flex, Form, Grid, Input, openToast, Text } from '@near-pagoda/ui'; | ||
import { formatNearAmount, parseNearAmount } from 'near-api-js/lib/utils/format'; | ||
import React, { useCallback, useContext, useEffect, useMemo } from 'react'; | ||
import { Controller, useFieldArray, useForm } from 'react-hook-form'; | ||
|
||
import { NearContext } from '@/components/wallet-selector/WalletSelector'; | ||
import { network } from '@/config'; | ||
|
||
import LabelWithTooltip from '../Shared/LabelWithTooltip'; | ||
|
||
type FormData = { | ||
display_name: string; | ||
account_prefix: string; | ||
description: string; | ||
councils: string[]; | ||
logo: FileList; | ||
cover: FileList; | ||
}; | ||
|
||
const KILOBYTE = 1024; | ||
const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp']; | ||
|
||
const DEFAULT_LOGO_CID = 'bafkreiad5c4r3ngmnm7q6v52joaz4yti7kgsgo6ls5pfbsjzclljpvorsu'; | ||
const DEFAULT_COVER_CID = 'bafkreicd7wmjfizslx72ycmnsmo7m7mnvfsyrw6wghsaseq45ybslbejvy'; | ||
|
||
const ACCOUNT_ID_REGEX = /^(([a-z\d]+[-_])*[a-z\d]+\.)*([a-z\d]+[-_])*[a-z\d]+$/; | ||
|
||
/** | ||
* Validates the Account ID according to the NEAR protocol | ||
* [Account ID rules](https://nomicon.io/DataStructures/Account#account-id-rules). | ||
* | ||
* @param accountId - The Account ID string you want to validate. | ||
*/ | ||
export function validateAccountId(accountId: string): boolean { | ||
return accountId.length >= 2 && accountId.length <= 64 && ACCOUNT_ID_REGEX.test(accountId); | ||
} | ||
|
||
function objectToBase64(obj: any): string { | ||
return btoa(JSON.stringify(obj)); | ||
} | ||
|
||
/** | ||
* | ||
* @param file File | ||
* @returns IPFS CID | ||
*/ | ||
async function uploadFileToIpfs(file: File): Promise<string> { | ||
const res = await fetch('https://ipfs.near.social/add', { | ||
method: 'POST', | ||
headers: { Accept: 'application/json' }, | ||
body: file, | ||
}); | ||
const fileData: { cid: string } = await res.json(); | ||
return fileData.cid; | ||
} | ||
|
||
const FACTORY_CONTRACT = network.daoContract; | ||
|
||
type Props = { | ||
reload: (delay: number) => void; | ||
}; | ||
|
||
const CreateDaoForm = ({ reload }: Props) => { | ||
const { | ||
control, | ||
register, | ||
handleSubmit, | ||
reset, | ||
watch, | ||
formState: { errors, isSubmitting, isValid }, | ||
} = useForm<FormData>({ | ||
mode: 'all', | ||
defaultValues: { | ||
display_name: '', | ||
description: '', | ||
account_prefix: '', | ||
councils: [], | ||
}, | ||
}); | ||
const { fields, append, remove, prepend } = useFieldArray({ | ||
// @ts-expect-error don't know error | ||
name: 'councils', | ||
control: control, | ||
}); | ||
|
||
const { wallet, signedAccountId } = useContext(NearContext); | ||
|
||
const data = watch(); | ||
|
||
const requiredDeposit = useMemo((): string => { | ||
const minDeposit = BigInt(parseNearAmount('4.7')!); // deposit for contract code storage | ||
const storageDeposit = BigInt(parseNearAmount('0.1')!); // deposit for data storage | ||
|
||
const displayNameSymbols = data.display_name.length; | ||
const descriptionSymbols = data.description.length; | ||
const prefixSymbols = data.account_prefix.length; | ||
const councilsSymbols = data.councils.reduce((previous, current) => previous + current.length, 0); | ||
|
||
const symbols = displayNameSymbols + descriptionSymbols + prefixSymbols + councilsSymbols; | ||
|
||
const pricePerSymbol = parseNearAmount('0.00001')!; // 10^19 yocto Near | ||
const totalSymbols = BigInt(symbols) * BigInt(pricePerSymbol); | ||
|
||
const total = minDeposit + storageDeposit + totalSymbols; | ||
|
||
return total.toString(); | ||
}, [data]); | ||
|
||
const isAccountPrefixAvailable = useCallback( | ||
async (account_prefix: string) => { | ||
// we use regex explicitly here as one symbol account_prefix is allowed | ||
const isValidAccountPrefix = ACCOUNT_ID_REGEX.test(account_prefix); | ||
if (!isValidAccountPrefix) return 'Sub-account name contains unsupported symbols'; | ||
|
||
const doesAccountPrefixIncludeDots = account_prefix.includes('.'); | ||
if (doesAccountPrefixIncludeDots) return 'Sub-account name must be without dots'; | ||
|
||
const accountId = `${account_prefix}.${FACTORY_CONTRACT}`; | ||
const isValidAccount = validateAccountId(accountId); | ||
if (!isValidAccount) return `Account name is too long`; | ||
|
||
try { | ||
await wallet?.getBalance(accountId); | ||
return `${accountId} already exists`; | ||
} catch { | ||
return true; | ||
} | ||
}, | ||
[wallet], | ||
); | ||
|
||
const isCouncilAccountNameValid = useCallback((accountId: string) => { | ||
if (validateAccountId(accountId)) return true; | ||
|
||
return `Account name is invalid`; | ||
}, []); | ||
|
||
const addCouncil = useCallback(() => { | ||
append(''); | ||
}, [append]); | ||
|
||
const removeCouncilAtIndex = useCallback( | ||
(index: number) => { | ||
remove(index); | ||
}, | ||
[remove], | ||
); | ||
|
||
const isImageFileValid = useCallback((files: FileList, maxFileSize: number, allowedFileTypes: string[]) => { | ||
// image is non-required | ||
if (!files || files.length === 0) return true; | ||
|
||
const file = files[0]; | ||
if (file.size > maxFileSize) return 'Image is too big'; | ||
if (!allowedFileTypes.includes(file.type)) return 'Not a valid image format'; | ||
|
||
return true; | ||
}, []); | ||
|
||
const onSubmit = useCallback( | ||
async (data: FormData) => { | ||
if (!isValid) return; | ||
|
||
if (!signedAccountId || !wallet) return; | ||
|
||
const logoFile = data.logo?.[0]; | ||
const logoCid = logoFile ? await uploadFileToIpfs(logoFile) : DEFAULT_LOGO_CID; | ||
|
||
const coverFile = data.cover?.[0]; | ||
const coverCid = coverFile ? await uploadFileToIpfs(coverFile) : DEFAULT_COVER_CID; | ||
|
||
const metadataBase64 = objectToBase64({ | ||
displayName: data.display_name, | ||
flagLogo: `https://ipfs.near.social/ipfs/${logoCid}`, | ||
flagCover: `https://ipfs.near.social/ipfs/${coverCid}`, | ||
}); | ||
const argsBase64 = objectToBase64({ | ||
config: { | ||
name: data.account_prefix, | ||
purpose: data.description, | ||
metadata: metadataBase64, | ||
}, | ||
policy: Array.from(new Set(data.councils)), | ||
}); | ||
|
||
const args = { | ||
name: data.account_prefix, | ||
// base64-encoded args to be passed in "new" function | ||
args: argsBase64, | ||
}; | ||
|
||
let result = false; | ||
|
||
try { | ||
result = await wallet?.callMethod({ | ||
contractId: FACTORY_CONTRACT, | ||
method: 'create', | ||
args, | ||
gas: '300000000000000', | ||
deposit: requiredDeposit, | ||
}); | ||
} catch (error) {} | ||
|
||
if (result) { | ||
// clean form data | ||
reset(); | ||
|
||
openToast({ | ||
type: 'success', | ||
title: 'DAO Created', | ||
description: `DAO ${data.display_name} was created successfully`, | ||
duration: 5000, | ||
}); | ||
reload(2000); // in 2 seconds | ||
} else { | ||
openToast({ | ||
type: 'error', | ||
title: 'Error', | ||
description: 'Failed to create DAO', | ||
duration: 5000, | ||
}); | ||
} | ||
}, | ||
[isValid, signedAccountId, wallet, requiredDeposit, reset, reload], | ||
); | ||
|
||
// adds current user as a council by default | ||
useEffect(() => { | ||
if (!signedAccountId) return; | ||
|
||
prepend(signedAccountId); | ||
// eslint-disable-next-line react-hooks/exhaustive-deps | ||
}, [signedAccountId]); | ||
|
||
return ( | ||
<> | ||
<Text size="text-l"> Create a Decentralized Autonomous Organization </Text> | ||
<Text size="text-s">This tool allows you to deploy your own Sputnik DAO smart contract (DAOs)</Text> | ||
|
||
<Form onSubmit={handleSubmit((data) => onSubmit(data))}> | ||
<Flex stack gap="s" style={{ border: '1px solid var(--violet3)', padding: '1rem', borderRadius: '10px' }}> | ||
<Text size="text-base"> Public Information </Text> | ||
<Flex stack gap="m" style={{ border: '1px solid var(--violet3)', padding: '1rem', borderRadius: '10px' }}> | ||
<Grid columns="1fr 1fr" columnsTablet="1fr" columnsPhone="1fr"> | ||
<Input | ||
label="Organization Name" | ||
placeholder="Enter Public Name" | ||
error={errors.display_name?.message} | ||
{...register('display_name', { required: 'Display name is required' })} | ||
disabled={!signedAccountId} | ||
required | ||
/> | ||
<Controller | ||
control={control} | ||
name="account_prefix" | ||
rules={{ | ||
required: 'Sub-account name is required', | ||
validate: isAccountPrefixAvailable, | ||
}} | ||
render={({ field, fieldState }) => ( | ||
<Input | ||
// @ts-expect-error it expects string, not ReactElement | ||
label={ | ||
<LabelWithTooltip | ||
label="Account ID" | ||
tooltip="Name of the sub-account to which contract will be deployed" | ||
/> | ||
} | ||
placeholder="Enter account name" | ||
error={fieldState.error?.message} | ||
{...field} | ||
disabled={!signedAccountId} | ||
assistive={field.value && `${field.value}.${FACTORY_CONTRACT} will be your DAO account`} | ||
required | ||
/> | ||
)} | ||
/> | ||
</Grid> | ||
|
||
<Input | ||
label="Description" | ||
placeholder="Enter description" | ||
error={errors.description?.message} | ||
{...register('description', { required: 'Description is required' })} | ||
disabled={!signedAccountId} | ||
/> | ||
</Flex> | ||
|
||
<Text size="text-base" style={{ marginTop: '1rem' }}> | ||
Councils | ||
</Text> | ||
<Flex stack gap="m" style={{ border: '1px solid var(--violet3)', padding: '1rem', borderRadius: '10px' }}> | ||
{fields.map((field, index) => ( | ||
<Grid key={field.id} columns="6fr 1fr"> | ||
<Controller | ||
control={control} | ||
name={`councils.${index}`} | ||
rules={{ | ||
required: 'Account name is required', | ||
validate: isCouncilAccountNameValid, | ||
}} | ||
render={({ field, fieldState }) => ( | ||
<Input | ||
placeholder="Enter account name" | ||
error={fieldState.error?.message} | ||
{...field} | ||
disabled={!signedAccountId} | ||
required={true} | ||
/> | ||
)} | ||
/> | ||
<Button | ||
label="Remove" | ||
onClick={() => removeCouncilAtIndex(index)} | ||
disabled={!signedAccountId || (index === 0 && fields.length === 1)} | ||
/> | ||
</Grid> | ||
))} | ||
<Button label="Add" onClick={addCouncil} disabled={!signedAccountId} /> | ||
</Flex> | ||
|
||
<Text size="text-base" style={{ marginTop: '1rem' }}> | ||
Design | ||
</Text> | ||
<Flex stack gap="m" style={{ border: '1px solid var(--violet3)', padding: '1rem', borderRadius: '10px' }}> | ||
<Grid columns="1fr 1fr" justify="space-between"> | ||
<Controller | ||
control={control} | ||
name="logo" | ||
rules={{ | ||
required: false, | ||
validate: (files) => isImageFileValid(files, 500 * KILOBYTE, ALLOWED_IMAGE_TYPES), | ||
}} | ||
render={({ field, fieldState }) => ( | ||
<div> | ||
<FileInput | ||
label="Logo" | ||
accept={'image/jpeg,image/png,image/webp'} | ||
error={fieldState.error?.message} | ||
{...field} | ||
value={field.value ? Array.from(field.value) : []} | ||
onChange={(value: File[] | null) => { | ||
const files = value; | ||
field.onChange(files); | ||
}} | ||
disabled={!signedAccountId} | ||
/> | ||
<span style={{ fontSize: '0.8rem', color: 'gray' }}> | ||
Accepted Formats: PNG, JPEG, WebP | Ideal dimension: 1:1 | Max size: 500kb | ||
</span> | ||
</div> | ||
)} | ||
/> | ||
<Controller | ||
control={control} | ||
name="cover" | ||
rules={{ | ||
required: false, | ||
validate: (files) => isImageFileValid(files, 3_000 * KILOBYTE, ALLOWED_IMAGE_TYPES), | ||
}} | ||
render={({ field, fieldState }) => ( | ||
<div> | ||
<FileInput | ||
label="Cover" | ||
accept={'image/jpeg,image/png,image/webp'} | ||
error={fieldState.error?.message} | ||
{...field} | ||
value={field.value ? Array.from(field.value) : []} | ||
onChange={(value: File[] | null) => { | ||
const files = value; | ||
field.onChange(files); | ||
}} | ||
disabled={!signedAccountId} | ||
/> | ||
<span style={{ fontSize: '0.8rem', color: 'gray' }}> | ||
Accepted Formats: PNG, JPEG, WebP | Ideal dimension: 2:1 | Max size: 3mb | ||
</span> | ||
</div> | ||
)} | ||
/> | ||
</Grid> | ||
</Flex> | ||
|
||
<Button | ||
label={signedAccountId ? `Create DAO - Cost: ${formatNearAmount(requiredDeposit, 2)} N` : 'Please login'} | ||
variant="affirmative" | ||
type="submit" | ||
loading={isSubmitting} | ||
disabled={!signedAccountId} | ||
/> | ||
</Flex> | ||
</Form> | ||
</> | ||
); | ||
}; | ||
|
||
export default CreateDaoForm; |
Oops, something went wrong.