From de18a319b815cec40465de5ac59a8a8ad1fe0965 Mon Sep 17 00:00:00 2001 From: Serkan Reis Date: Mon, 10 Oct 2022 12:37:20 +0300 Subject: [PATCH] Multiple token airdrop to a single address (with shuffle) --- components/AirdropUpload.tsx | 57 +++++++++++++++++++++++ components/collections/actions/Action.tsx | 34 +++++++++++--- utils/csvToArray.ts | 31 ++++++++++++ utils/isValidAccountsFile.ts | 57 +++++++++++++++++++++++ 4 files changed, 173 insertions(+), 6 deletions(-) create mode 100644 components/AirdropUpload.tsx create mode 100644 utils/csvToArray.ts create mode 100644 utils/isValidAccountsFile.ts diff --git a/components/AirdropUpload.tsx b/components/AirdropUpload.tsx new file mode 100644 index 0000000..1c6ae22 --- /dev/null +++ b/components/AirdropUpload.tsx @@ -0,0 +1,57 @@ +import clsx from 'clsx' +import React from 'react' +import { toast } from 'react-hot-toast' +import { csvToArray } from 'utils/csvToArray' +import type { AirdropAllocation } from 'utils/isValidAccountsFile' +import { isValidAccountsFile } from 'utils/isValidAccountsFile' + +interface AirdropUploadProps { + onChange: (data: AirdropAllocation[]) => void +} + +export const AirdropUpload = ({ onChange }: AirdropUploadProps) => { + const onFileChange = (event: React.ChangeEvent) => { + if (!event.target.files) return toast.error('Error opening file') + if (!event.target.files[0].name.endsWith('.csv')) { + toast.error('Please select a .csv file!') + return onChange([]) + } + const reader = new FileReader() + reader.onload = (e: ProgressEvent) => { + try { + if (!e.target?.result) return toast.error('Error parsing file.') + // eslint-disable-next-line @typescript-eslint/no-base-to-string + const accountsData = csvToArray(e.target.result.toString()) + if (!isValidAccountsFile(accountsData)) { + event.target.value = '' + return onChange([]) + } + return onChange(accountsData) + } catch (error: any) { + toast.error(error.message) + } + } + reader.readAsText(event.target.files[0]) + } + + return ( +
+ +
+ ) +} diff --git a/components/collections/actions/Action.tsx b/components/collections/actions/Action.tsx index 80aa634..e6c607b 100644 --- a/components/collections/actions/Action.tsx +++ b/components/collections/actions/Action.tsx @@ -1,3 +1,4 @@ +import { AirdropUpload } from 'components/AirdropUpload' import { Button } from 'components/Button' import type { DispatchExecuteArgs } from 'components/collections/actions/actions' import { dispatchExecute, isEitherType, previewExecutePayload } from 'components/collections/actions/actions' @@ -11,15 +12,15 @@ import { useInputState, useNumberInputState } from 'components/forms/FormInput.h import { InputDateTime } from 'components/InputDateTime' import { JsonPreview } from 'components/JsonPreview' import { TransactionHash } from 'components/TransactionHash' -import { WhitelistUpload } from 'components/WhitelistUpload' import { useWallet } from 'contexts/wallet' import type { MinterInstance } from 'contracts/minter' import type { SG721Instance } from 'contracts/sg721' import type { FormEvent } from 'react' -import { useState } from 'react' +import { useEffect, useState } from 'react' import { toast } from 'react-hot-toast' import { FaArrowRight } from 'react-icons/fa' import { useMutation } from 'react-query' +import type { AirdropAllocation } from 'utils/isValidAccountsFile' import { TextInput } from '../../forms/FormInput' @@ -40,6 +41,7 @@ export const CollectionActions = ({ const [lastTx, setLastTx] = useState('') const [timestamp, setTimestamp] = useState(undefined) + const [airdropAllocationArray, setAirdropAllocationArray] = useState([]) const [airdropArray, setAirdropArray] = useState([]) const actionComboboxState = useActionsComboboxState() @@ -113,6 +115,22 @@ export const CollectionActions = ({ txSigner: wallet.address, type, } + + useEffect(() => { + const addresses: string[] = [] + airdropAllocationArray.forEach((allocation) => { + for (let i = 0; i < Number(allocation.amount); i++) { + addresses.push(allocation.address) + } + }) + //shuffle the addresses array + for (let i = addresses.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)) + ;[addresses[i], addresses[j]] = [addresses[j], addresses[i]] + } + setAirdropArray(addresses) + }, [airdropAllocationArray]) + const { isLoading, mutate } = useMutation( async (event: FormEvent) => { event.preventDefault() @@ -138,8 +156,9 @@ export const CollectionActions = ({ }, ) - const airdropFileOnChange = (data: string[]) => { - setAirdropArray(data) + const airdropFileOnChange = (data: AirdropAllocation[]) => { + setAirdropAllocationArray(data) + console.log(data) } return ( @@ -154,8 +173,11 @@ export const CollectionActions = ({ {showTokenIdListField && } {showNumberOfTokensField && } {showAirdropFileField && ( - - + + )} diff --git a/utils/csvToArray.ts b/utils/csvToArray.ts new file mode 100644 index 0000000..eb3e44c --- /dev/null +++ b/utils/csvToArray.ts @@ -0,0 +1,31 @@ +import type { AirdropAllocation } from './isValidAccountsFile' + +export const csvToArray = (str: string, delimiter = ',') => { + let newline = '\n' + if (str.includes('\r')) newline = '\r' + if (str.includes('\r\n')) newline = '\r\n' + + const headers = str.slice(0, str.indexOf(newline)).split(delimiter) + if (headers.length !== 2) { + throw new Error('Invalid accounts file') + } + if (headers[0] !== 'address' || headers[1] !== 'amount') { + throw new Error('Invalid accounts file') + } + + const rows = str.slice(str.indexOf('\n') + 1).split(newline) + + const arr = rows + .filter((row) => row !== '') + .map((row) => { + const values = row.split(delimiter) + const el = headers.reduce((object, header, index) => { + // @ts-expect-error assume object as Record + object[header] = values[index] + return object + }, {}) + return el + }) + + return arr as AirdropAllocation[] +} diff --git a/utils/isValidAccountsFile.ts b/utils/isValidAccountsFile.ts new file mode 100644 index 0000000..a0b0baf --- /dev/null +++ b/utils/isValidAccountsFile.ts @@ -0,0 +1,57 @@ +import { toast } from 'react-hot-toast' + +import { isValidAddress } from './isValidAddress' + +export interface AirdropAllocation { + address: string + amount: string +} + +export const isValidAccountsFile = (file: AirdropAllocation[]) => { + let sumOfAmounts = 0 + file.forEach((allocation) => { + sumOfAmounts += Number(allocation.amount) + }) + if (sumOfAmounts > 10000) { + toast.error(`Accounts file must have less than 10000 accounts`) + return false + } + + const checks = file.map((account) => { + // Check if address is valid bech32 address + if (!isValidAddress(account.address)) { + return { address: false } + } + // Check if address start with stars + if (!account.address.startsWith('stars')) { + return { address: false } + } + // Check if amount is valid + if (!Number.isInteger(Number(account.amount)) || !(Number(account.amount) > 0)) { + return { amount: false } + } + return null + }) + + const isStargazeAddresses = file.every((account) => account.address.startsWith('stars')) + if (!isStargazeAddresses) { + toast.error('All accounts must be on the same network') + return false + } + + if (checks.filter((check) => check?.address === false).length > 0) { + toast.error('Invalid address in file') + return false + } + if (checks.filter((check) => check?.amount === false).length > 0) { + toast.error('Invalid amount in file. Amount must be a positive integer.') + return false + } + + // if (duplicateCheck.length > 0) { + // toast.error('The file contains duplicate addresses.') + // return false + // } + + return true +}