Multiple token airdrop to a single address (with shuffle)

This commit is contained in:
Serkan Reis 2022-10-10 12:37:20 +03:00
parent 0541c3f046
commit de18a319b8
4 changed files with 173 additions and 6 deletions

View File

@ -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<HTMLInputElement>) => {
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<FileReader>) => {
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 (
<div
className={clsx(
'flex relative justify-center items-center mt-2 space-y-4 w-full h-32',
'rounded border-2 border-white/20 border-dashed',
)}
>
<input
accept=".csv"
className={clsx(
'file:py-2 file:px-4 file:mr-4 file:bg-plumbus-light file:rounded file:border-0 cursor-pointer',
'before:absolute before:inset-0 before:hover:bg-white/5 before:transition',
)}
id="airdrop-file"
multiple
onChange={onFileChange}
type="file"
/>
</div>
)
}

View File

@ -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<Date | undefined>(undefined)
const [airdropAllocationArray, setAirdropAllocationArray] = useState<AirdropAllocation[]>([])
const [airdropArray, setAirdropArray] = useState<string[]>([])
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 && <TextInput {...tokenIdListState} />}
{showNumberOfTokensField && <NumberInput {...batchNumberState} />}
{showAirdropFileField && (
<FormGroup subtitle="TXT file that contains the airdrop addresses" title="Airdrop File">
<WhitelistUpload onChange={airdropFileOnChange} />
<FormGroup
subtitle="CSV file that contains the airdrop addresses and the amount of tokens allocated for each address."
title="Airdrop File"
>
<AirdropUpload onChange={airdropFileOnChange} />
</FormGroup>
)}
<Conditional test={showDateField}>

31
utils/csvToArray.ts Normal file
View File

@ -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<string, unknown>
object[header] = values[index]
return object
}, {})
return el
})
return arr as AirdropAllocation[]
}

View File

@ -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
}