diff --git a/.github/workflows/add-to-devrel.yml b/.github/workflows/add-to-devrel.yml new file mode 100644 index 000000000..91d324e0c --- /dev/null +++ b/.github/workflows/add-to-devrel.yml @@ -0,0 +1,22 @@ +name: 'Add to DevRel Project' + +on: + issues: + types: + - opened + - reopened + pull_request: + types: + - opened + - reopened + +jobs: + add-to-project: + name: Add issue/PR to project + runs-on: ubuntu-latest + steps: + - uses: actions/add-to-project@v1.0.0 + with: + # add to DevRel Project #117 + project-url: https://github.com/orgs/near/projects/117 + github-token: ${{ secrets.GH_TOKEN }} diff --git a/src/components/sidebar-navigation/styles.ts b/src/components/sidebar-navigation/styles.ts index 251505d6b..a6a9bd782 100644 --- a/src/components/sidebar-navigation/styles.ts +++ b/src/components/sidebar-navigation/styles.ts @@ -778,6 +778,7 @@ export const SmallScreenHeaderActions = styled.div<{ opacity: 1; transition: all var(--sidebar-expand-transition-speed); gap: ${(p) => p.$gap ?? 'unset'}; + margin-right: 16px; ${(p) => p.$hidden diff --git a/src/components/tools/DecentralizedOrganization/CreateDaoForm.tsx b/src/components/tools/DecentralizedOrganization/CreateDaoForm.tsx new file mode 100644 index 000000000..7b8403783 --- /dev/null +++ b/src/components/tools/DecentralizedOrganization/CreateDaoForm.tsx @@ -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 { + 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({ + 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 ( + <> + Create a Decentralized Autonomous Organization + This tool allows you to deploy your own Sputnik DAO smart contract (DAOs) + +
onSubmit(data))}> + + Public Information + + + + ( + + } + placeholder="Enter account name" + error={fieldState.error?.message} + {...field} + disabled={!signedAccountId} + assistive={field.value && `${field.value}.${FACTORY_CONTRACT} will be your DAO account`} + required + /> + )} + /> + + + + + + + Councils + + + {fields.map((field, index) => ( + + ( + + )} + /> +