From 1f9a29498950d5d3939b7dd8a26b936df5337f44 Mon Sep 17 00:00:00 2001 From: denbite Date: Fri, 18 Oct 2024 11:18:04 +0200 Subject: [PATCH 1/4] feat: add brand new tool for creating DAOs --- src/components/tools/DAO/CreateDaoForm.tsx | 359 +++++++++++++++++++++ src/components/tools/DAO/index.tsx | 10 + src/config.ts | 2 + src/pages/tools.tsx | 11 +- src/utils/types.ts | 1 + 5 files changed, 382 insertions(+), 1 deletion(-) create mode 100644 src/components/tools/DAO/CreateDaoForm.tsx create mode 100644 src/components/tools/DAO/index.tsx diff --git a/src/components/tools/DAO/CreateDaoForm.tsx b/src/components/tools/DAO/CreateDaoForm.tsx new file mode 100644 index 000000000..c0caa2f1e --- /dev/null +++ b/src/components/tools/DAO/CreateDaoForm.tsx @@ -0,0 +1,359 @@ +import { Button, FileInput, Flex, Form, Grid, Input, openToast, Text } from '@near-pagoda/ui'; +import { parseNearAmount } from 'near-api-js/lib/utils/format'; +import React, { useCallback, useContext, useEffect } from 'react'; +import { Controller, useFieldArray, useForm } from 'react-hook-form'; + +import { NearContext } from '@/components/wallet-selector/WalletSelector'; +import { network } from '@/config'; + +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 { + const res = await fetch('https://ipfs.near.social/add', { + method: 'POST', + headers: { Accept: 'application/json' }, + body: file, + }); + console.log('res', res.ok); + const fileData: { cid: string } = await res.json(); + console.log('res json', fileData); + return fileData.cid; +} + +const FACTORY_CONTRACT = network.daoContract; +const REQUIRED_DEPOSIT = '6'; // 6 Near + +const CreateDaoForm = () => { + const { + control, + register, + handleSubmit, + formState: { errors, isSubmitting, isValid }, + } = useForm({ + mode: 'all', + }); + const { fields, append, remove, prepend } = useFieldArray({ + // @ts-expect-error don't know error + name: 'councils', + control: control, + }); + + const { wallet, signedAccountId } = useContext(NearContext); + + 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 'Account prefix contains unsupported symbols'; + + const doesAccountPrefixIncludeDots = account_prefix.includes('.'); + if (doesAccountPrefixIncludeDots) return 'Account prefix must be without dots'; + + const accountId = `${account_prefix}.${FACTORY_CONTRACT}`; + const isValidAccount = validateAccountId(accountId); + if (!isValidAccount) return `Prefix 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 deposit = parseNearAmount(REQUIRED_DEPOSIT) as string; + + 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: deposit, + }); + } catch (error) {} + + if (result) { + openToast({ + type: 'success', + title: 'DAO Created', + description: `DAO ${data.display_name} was created successfully`, + duration: 5000, + }); + } else { + openToast({ + type: 'error', + title: 'Error', + description: 'Failed to create DAO', + duration: 5000, + }); + } + }, + [wallet, signedAccountId, isValid], + ); + + // adds current user as a council by default + useEffect(() => { + if (!signedAccountId) return; + + prepend(signedAccountId); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [signedAccountId]); + + return ( + <> + Create a Decentralized Autonomous Organization + + This tool allows you to deploy your own Sputnik DAO smart contract (DAOs) + + +
onSubmit(data))}> + + Public Information + + + + ( + + )} + /> + + + + + + + Councils + + + {fields.map((field, index) => ( + + ( + + )} + /> +