Skip to content

Commit

Permalink
feat(unlock-app): Allow processing of large csvs (#14055)
Browse files Browse the repository at this point in the history
* initial commit

* fixes broken csv upload

* sign transactions at the same time

* better loading state

---------

Co-authored-by: Julien Genestoux <julien.genestoux@gmail.com>
  • Loading branch information
iMac7 and julien51 committed Jun 19, 2024
1 parent 7b158f0 commit d27cb02
Show file tree
Hide file tree
Showing 2 changed files with 156 additions and 118 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,14 @@ import { useAuth } from '~/contexts/AuthenticationContext'
import { KeyManager } from '@unlock-protocol/unlock-js'
import { useConfig } from '~/utils/withConfig'

const MAX_SIZE = 50
export const MAX_SIZE = 50

interface Props {
lock: Lock
onConfirm(members: AirdropMember[]): void | Promise<void>
onConfirm(
members: AirdropMember[],
setIsConfirming: (a: boolean) => void
): void | Promise<void>
emailRequired?: boolean
}

Expand Down Expand Up @@ -106,8 +109,8 @@ export function AirdropBulkForm({ lock, onConfirm, emailRequired }: Props) {
})
)

const filteredMembers = members
.reduce<AirdropMember[]>((filtered, member) => {
const filteredMembers = members.reduce<AirdropMember[]>(
(filtered, member) => {
// filter null or undefined values
if (!member) {
return filtered
Expand Down Expand Up @@ -143,8 +146,9 @@ export function AirdropBulkForm({ lock, onConfirm, emailRequired }: Props) {
// push the item to array if new unique member found
filtered.push(member)
return filtered
}, [])
.slice(0, MAX_SIZE)
},
[]
)

// Notify how many loaded and discarded.
ToastHelper.success(
Expand Down Expand Up @@ -215,9 +219,10 @@ export function AirdropBulkForm({ lock, onConfirm, emailRequired }: Props) {
</p>
<p>
Due to block size limit, you can only airdrop at most{' '}
{MAX_SIZE} NFT at once, but you can re-upload the same file
multiple times and the duplicates will automatically
discarded.
{MAX_SIZE} NFT at once, so if your list is longer than that,
you will be prompted to approve multiple transactions. You can
also easily restart the process at any point, as addresses who
already have one NFT will be discarded.
</p>
<p>
If you don&apos;t have wallet address of the user, leave the
Expand All @@ -235,22 +240,27 @@ export function AirdropBulkForm({ lock, onConfirm, emailRequired }: Props) {
</a>
</div>
</div>

<div
className="flex flex-col items-center justify-center bg-white border rounded cursor-pointer group aspect-1 group-hover:border-gray-300"
{...getRootProps()}
>
<input {...getInputProps()} />
<div className="max-w-xs space-y-2 text-center">
<h3 className="text-lg font-medium">
Drop your CSV file here
</h3>
<p className="text-sm text-gray-600">
Download the template file and fill out the values in the
format.
</p>
{isConfirming ? (
<Button loading={isConfirming} disabled>
Processing your transactions...
</Button>
) : (
<div
className="flex flex-col items-center justify-center bg-white border rounded cursor-pointer group aspect-1 group-hover:border-gray-300"
{...getRootProps()}
>
<input {...getInputProps()} />
<div className="max-w-xs space-y-2 text-center">
<h3 className="text-lg font-medium">
Drop your CSV file here
</h3>
<p className="text-sm text-gray-600">
Download the template file and fill out the values in the
format.
</p>
</div>
</div>
</div>
)}
</div>
)}
</div>
Expand Down Expand Up @@ -288,14 +298,13 @@ export function AirdropBulkForm({ lock, onConfirm, emailRequired }: Props) {
event.preventDefault()
setIsConfirming(true)
try {
await onConfirm(list)
onConfirm(list, setIsConfirming)
clear()
} catch (error) {
if (error instanceof Error) {
ToastHelper.error(error.message)
}
}
setIsConfirming(false)
}}
>
Confirm aidrop
Expand Down
213 changes: 121 additions & 92 deletions unlock-app/src/components/interface/members/airdrop/AirdropDrawer.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Drawer, Placeholder } from '@unlock-protocol/ui'
import { Tab } from '@headlessui/react'
import { AirdropManualForm } from './AirdropManualForm'
import { AirdropBulkForm } from './AirdropBulkForm'
import { AirdropBulkForm, MAX_SIZE } from './AirdropBulkForm'
import { AirdropMember } from './AirdropElements'
import { useAuth } from '~/contexts/AuthenticationContext'
import { MAX_UINT } from '~/constants'
Expand Down Expand Up @@ -71,110 +71,139 @@ export const AirdropFormForLock = ({ lock }: { lock: Lock }) => {
const { account, getWalletService } = useAuth()
const { mutateAsync: updateUsersMetadata } = useUpdateUsersMetadata()

const handleConfirm = async (items: AirdropMember[]) => {
// Create metadata
const users = items.map(({ wallet: userAddress, ...rest }) => {
const data = omit(rest, [
'manager',
'neverExpire',
'count',
'expiration',
'balance',
'line',
])
const metadata = Object.entries(data).reduce<Metadata>(
(result, [key, value]) => {
const [name, designation] = key.split('.')

if (designation !== 'public') {
// @ts-expect-error
result.protected[name] = value
} else {
// @ts-expect-error
result.public[name] = value
}

return result
},
{
protected: {},
public: {},
}
)
const handleConfirm = async (
items: AirdropMember[],
setIsConfirming?: (a: boolean) => void
) => {
const numberOfTransactions = Math.ceil(items.length / MAX_SIZE)
const transactions: AirdropMember[][] = []
const promises = []

const user = {
userAddress,
lockAddress: lock.address,
metadata,
network: lock.network,
} as const
for (let i = 0; i < numberOfTransactions; i++) {
transactions.push(items.slice(i * MAX_SIZE, i * MAX_SIZE + MAX_SIZE))
}

return user
})
async function sendTransaction(newItems: AirdropMember[]): Promise<void> {
// Create metadata
const users = newItems.map(({ wallet: userAddress, ...rest }) => {
const data = omit(rest, [
'manager',
'neverExpire',
'count',
'expiration',
'balance',
'line',
])
const metadata = Object.entries(data).reduce<Metadata>(
(result, [key, value]) => {
const [name, designation] = key.split('.')

if (designation !== 'public') {
// @ts-expect-error
result.protected[name] = value
} else {
// @ts-expect-error
result.public[name] = value
}

return result
},
{
protected: {},
public: {},
}
)

// Save metadata for users
await updateUsersMetadata(users)
const user = {
userAddress,
lockAddress: lock.address,
metadata,
network: lock.network,
} as const

const initialValue: Record<
'recipients' | 'keyManagers' | 'expirations',
string[]
> = {
recipients: [],
keyManagers: [],
expirations: [],
}
return user
})

// Create options to pass to grant keys from the members
const options = items.reduce((prop, item) => {
let expiration

if (item.expiration) {
expiration = Math.floor(
new Date(item.expiration).getTime() / 1000
).toString()
} else if (item.neverExpire) {
expiration = MAX_UINT
} else if (lock!.expirationDuration == -1) {
expiration = MAX_UINT
} else {
expiration = Math.floor(
new Date(formatDate(lock!.expirationDuration)).getTime() / 1000
).toString()
}
// Save metadata for users
await updateUsersMetadata(users)

for (const _ of Array.from({ length: item.count })) {
prop.recipients.push(item.wallet)
prop.expirations.push(expiration!)
prop.keyManagers.push(item.manager || account!)
const initialValue: Record<
'recipients' | 'keyManagers' | 'expirations',
string[]
> = {
recipients: [],
keyManagers: [],
expirations: [],
}

return prop
}, initialValue)
// Create options to pass to grant keys from the members
const options = newItems.reduce((prop, item) => {
let expiration

if (item.expiration) {
expiration = Math.floor(
new Date(item.expiration).getTime() / 1000
).toString()
} else if (item.neverExpire) {
expiration = MAX_UINT
} else if (lock!.expirationDuration == -1) {
expiration = MAX_UINT
} else {
expiration = Math.floor(
new Date(formatDate(lock!.expirationDuration)).getTime() / 1000
).toString()
}

const walletService = await getWalletService(lock.network)
for (const _ of Array.from({ length: item.count })) {
prop.recipients.push(item.wallet)
prop.expirations.push(expiration!)
prop.keyManagers.push(item.manager || account!)
}

// Grant keys
await walletService
.grantKeys(
{
...options,
lockAddress: lock.address,
},
{},
(error) => {
if (error) {
throw error
return prop
}, initialValue)

const walletService = await getWalletService(lock.network)

// Grant keys
walletService
.grantKeys(
{
...options,
lockAddress: lock.address,
},
{},
(error) => {
if (error) {
throw error
}
}
}
)
.catch((error: any) => {
console.error(error)
throw new Error('We were unable to airdrop these memberships.')
)
.then(() => {
ToastHelper.success(
`Successfully granted ${options.recipients.length} keys to ${newItems.length} recipients`
)
})
.catch((error: any) => {
console.error(error)
throw new Error('We were unable to airdrop these memberships.')
})
.finally(() => {
setIsConfirming && setIsConfirming(false)
})
}

for (const newItems of transactions) {
const promise = new Promise((resolve) => {
resolve(sendTransaction(newItems))
})
promises.push(promise)
}

ToastHelper.success(
`Successfully granted ${options.recipients.length} keys to ${items.length} recipients`
)
Promise.allSettled(promises).catch((error: any) => {
console.error(error)
ToastHelper.error('We were unable to airdrop some memberships.')
})
}

return (
Expand Down

0 comments on commit d27cb02

Please sign in to comment.