Multiple token airdrop to a single address (with shuffle)
This commit is contained in:
parent
0541c3f046
commit
de18a319b8
57
components/AirdropUpload.tsx
Normal file
57
components/AirdropUpload.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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
31
utils/csvToArray.ts
Normal 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[]
|
||||
}
|
57
utils/isValidAccountsFile.ts
Normal file
57
utils/isValidAccountsFile.ts
Normal 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
|
||||
}
|
Loading…
Reference in New Issue
Block a user