Skip to content

Commit

Permalink
feature(unlock-app): Airdrops blastoff (#15564)
Browse files Browse the repository at this point in the history
* wip

* more work

* adding campaign

* typescript

* fixed typescript
  • Loading branch information
julien51 authored Feb 26, 2025
1 parent 051bbc2 commit 6bf0e0b
Show file tree
Hide file tree
Showing 9 changed files with 392 additions and 70 deletions.
6 changes: 3 additions & 3 deletions airdrops/app/campaigns/[campaign]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,15 @@ export async function generateMetadata({
}

return {
title: `${campaign.title} | Airdrops`,
title: `${campaign.name} | Airdrops`,
description: campaign.description,
openGraph: {
title: `${campaign.title} | Airdrops`,
title: `${campaign.name} | Airdrops`,
description: campaign.description,
},
twitter: {
card: 'summary',
title: `${campaign.title} | Airdrops`,
title: `${campaign.name} | Airdrops`,
description: campaign.description,
},
}
Expand Down
15 changes: 12 additions & 3 deletions airdrops/components/CampaignCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,15 @@ interface CampaignCardProps {
}

const CampaignCardInternal = ({
airdrop: { title, description, eligible },
airdrop: { name, description, eligible, contractAddress, url },
authenticated,
}: CampaignCardProps) => {
return (
<div className="space-y-4 md:min-w-96 block min-h-48 p-6 border min-w-[24rem] sm:min-w-[28rem] rounded-xl transition-all duration-200">
<h3 className="text-xl font-medium">{title}</h3>
<h3 className="text-xl font-medium">{name}</h3>
<p className="text-gray-600 line-clamp-3">{description}</p>
<div className="flex items-center justify-between">
{authenticated && (
{authenticated && contractAddress && (
<>
<Button disabled={!authenticated || !eligible}>
{eligible > 0 ? 'Claim Rewards' : 'Not Eligible'}
Expand All @@ -30,6 +30,15 @@ const CampaignCardInternal = ({
</div>
</>
)}
{url && (
<Link
className="text-brand-ui-primary underline"
href={url}
target="_blank"
>
More info
</Link>
)}
</div>
</div>
)
Expand Down
99 changes: 70 additions & 29 deletions airdrops/components/CampaignDetailContent.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
'use client'
import { StandardMerkleTree } from '@openzeppelin/merkle-tree'
import { ethers } from 'ethers'
import { usePrivy, useWallets } from '@privy-io/react-auth'
import { Container } from './layout/Container'
Expand All @@ -9,13 +10,35 @@ import { BsArrowLeft as ArrowBackIcon } from 'react-icons/bs'
import { ConnectButton } from './auth/ConnectButton'
import { isEligible } from '../src/utils/eligibility'
import { AirdropData } from './Campaigns'
import ReactMarkdown from 'react-markdown'
import { terms } from '../src/utils/terms'
import { UPAirdrops } from '@unlock-protocol/contracts'

interface CampaignDetailContentProps {
airdrop: AirdropData
}

const timestamp = new Date().getTime()

const getContract = async (address: string, network: number) => {
const provider = new ethers.JsonRpcProvider(
`https://rpc.unlock-protocol.com/${network}`
)
return new ethers.Contract(address, UPAirdrops.abi, provider)
}

const getProof = async (address: string, airdrop: AirdropData) => {
const request = await fetch(airdrop.recipientsFile)
const tree = StandardMerkleTree.load(await request.json())
for (const [i, leaf] of tree.entries()) {
if (leaf[0].toLowerCase() === address.toLowerCase()) {
const proof = tree.getProof(i)
return { leaf, proof }
}
}
return { leaf: null, proof: null }
}

export default function CampaignDetailContent({
airdrop,
}: CampaignDetailContentProps) {
Expand All @@ -25,11 +48,10 @@ export default function CampaignDetailContent({

useEffect(() => {
const run = async () => {
const amount = await isEligible(
wallets[0].address,
airdrop.recipientsFile
)
airdrop.eligible = amount || 0
if (wallets[0]) {
const amount = await isEligible(wallets[0].address, airdrop)
airdrop.eligible = amount || 0
}
}
run()
}, [authenticated, wallets, airdrop])
Expand All @@ -40,13 +62,17 @@ export default function CampaignDetailContent({
const ethersProvider = new ethers.BrowserProvider(provider)
const signer = await ethersProvider.getSigner()

await wallets[0].switchChain(8453)
await wallets[0].switchChain(airdrop.chainId)
const contract = await getContract(
airdrop.contractAddress,
airdrop.chainId
)

const domain = {
name: 'Airdrops', // await airdrops.EIP712Name(),
version: '1', // await airdrops.EIP712Version(),
chainId: 8453,
verifyingContract: '0x4200000000000000000000000000000000000011', // replace me
name: await contract.EIP712Name(),
version: await contract.EIP712Version(),
chainId: airdrop.chainId,
verifyingContract: airdrop.contractAddress,
}

const types = {
Expand All @@ -59,7 +85,7 @@ export default function CampaignDetailContent({

const value = {
signer: signer.address,
campaignName: airdrop.title,
campaignName: airdrop.name,
timestamp,
}

Expand All @@ -70,6 +96,34 @@ export default function CampaignDetailContent({
}
}

const onClaim = async () => {
const provider = await wallets[0].getEthereumProvider()
const ethersProvider = new ethers.BrowserProvider(provider)
const signer = await ethersProvider.getSigner()

const airdropContract = await getContract(
airdrop.contractAddress,
airdrop.chainId
)

// Get the proof!
const { proof } = await getProof(wallets[0].address, airdrop)
console.log(proof)

const tx = await airdropContract
.connect(signer)
// @ts-expect-error Property 'claim' does not exist on type 'BaseContract'.ts(2339)
.claim(
airdrop.name,
timestamp,
wallets[0].address,
airdrop.eligible,
proof,
termsOfServiceSignature
)
await tx.wait()
}

return (
<Container>
<Button variant="borderless" aria-label="arrow back" className="my-5">
Expand All @@ -80,23 +134,15 @@ export default function CampaignDetailContent({

{/* Full-width title and description */}
<div className="max-w-6xl space-y-4 mb-8">
<h1 className="text-4xl font-bold">{airdrop.title}</h1>
<h1 className="text-4xl font-bold">{airdrop.name}</h1>
<p className="text-xl text-gray-600">{airdrop.description}</p>
</div>

{/* Two-column layout for remaining content */}
<div className="grid max-w-6xl grid-cols-1 gap-8 pb-12 md:grid-cols-2">
{/* Left Column */}
<div className="space-y-8">
<div className="p-4 border rounded-lg bg-gray-50">
<h2 className="text-xl font-semibold mb-3">Terms of Service</h2>
<p className="text-sm text-gray-600">
Lorem ipsum, dolor sit amet consectetur adipisicing elit. Earum
excepturi id explicabo, ad iste, autem placeat expedita aliquid,
commodi qui nam fuga asperiores ab fugit ducimus ipsam. Libero,
pariatur. Possimus?
</p>
</div>
<div className="p-4 border rounded-lg bg-gray-50 text-sm h-80 overflow-y-auto prose lg:prose-xl">
<ReactMarkdown children={terms} />
</div>

{/* Right Column - Claim Section */}
Expand All @@ -113,17 +159,12 @@ export default function CampaignDetailContent({
</div>

<Checkbox
label="I have read and agree to the Terms of Service"
label="I have read and agree to the Airdrop Terms and Conditions"
checked={!!termsOfServiceSignature}
onChange={onBoxChecked}
/>

<Button
disabled={!termsOfServiceSignature}
onClick={() => {
console.log('Claiming tokens for', airdrop.contractAddress)
}}
>
<Button disabled={!termsOfServiceSignature} onClick={onClaim}>
Claim Tokens
</Button>
</div>
Expand Down
18 changes: 10 additions & 8 deletions airdrops/components/Campaigns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,18 @@ import { CampaignCard } from './CampaignCard'

export interface AirdropData {
id: string
title: string
name: string
description: string
contractAddress?: string
tokenAmount: string
tokenSymbol: string
recipientsFile: string
token?: {
address: string
symbol: string
decimals: number
}
recipientsFile?: string
eligible?: number
url?: string
chainId?: number
}

const CampaignsContent = () => {
Expand Down Expand Up @@ -53,10 +58,7 @@ const CampaignsContent = () => {

await Promise.all(
(airdrops as AirdropData[]).map(async (drop) => {
const amount = await isEligible(
user.wallet.address,
drop.recipientsFile
)
const amount = await isEligible(user.wallet.address, drop)
drop.eligible = amount || 0
})
)
Expand Down
3 changes: 3 additions & 0 deletions airdrops/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@
},
"dependencies": {
"@headlessui/react": "2.1.9",
"@openzeppelin/merkle-tree": "1.0.8",
"@privy-io/react-auth": "2.2.1",
"@sentry/nextjs": "8.54.0",
"@tanstack/react-query": "5.59.19",
"@tw-classed/react": "1.7.0",
"@unlock-protocol/contracts": "workspace:*",
"@unlock-protocol/core": "workspace:./packages/core",
"@unlock-protocol/crypto-icon": "workspace:./packages/crypto-icon",
"@unlock-protocol/eslint-config": "workspace:./packages/eslint-config",
Expand All @@ -26,6 +28,7 @@
"ethers": "6.13.5",
"next": "14.2.21",
"react-hot-toast": "2.4.1",
"react-markdown": "10.0.0",
"tailwind-merge": "3.0.1",
"typescript": "5.6.3"
},
Expand Down
27 changes: 17 additions & 10 deletions airdrops/src/airdrops.json
Original file line number Diff line number Diff line change
@@ -1,19 +1,26 @@
[
{
"id": "1",
"title": "UP Token Swap",
"name": "UP Token Swap",
"description": "The Unlock DAO migration to Base is complete, and the UP token swap reward airdrop, totaling 1.061 million UP tokens, is now live for all eligible participants.",
"tokenAmount": "1061000",
"tokenSymbol": "UP",
"recipientsFile": "https://example.com/airdrop1-recipients.json"
"url": "https://unlock-protocol.com/blog/up-token-swap-reward-airdrop-now-live-"
},
{
"id": "2",
"title": "Airdrop #2",
"description": "More to come",
"contractAddress": "0x1234567890123456789012345678901234567890",
"tokenAmount": "10000",
"tokenSymbol": "UP",
"recipientsFile": "https://example.com/airdrop2-recipients.json"
"name": "Blastoff Airdrop",
"description": "The Unlock Protocol Foundation is launching a next airdrop to Unlock Protocol community members, distributing over 7 million $UP tokens on Base to over 10,000 members of the community!",
"contractAddress": "0x3b26D06Ea8252a73742d2125D1ACEb594ECEE5c6",
"recipientsFile": "https://merkle-trees.unlock-protocol.com/0xe238effc14b43022c9ce132e22f0baa73cdd8696f4b435150a4c9341c83abfbf.json",
"token": {
"address": "0x3b26D06Ea8252a73742d2125D1ACEb594ECEE5c6",
"symbol": "UP",
"decimals": 18
},
"chainId": 8453
},
{
"id": "3",
"name": "Trading Volume",
"description": "More details to be announced soon!"
}
]
30 changes: 17 additions & 13 deletions airdrops/src/utils/eligibility.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,25 @@
import { ethers } from 'ethers'
import { AirdropData } from '../../components/Campaigns'

/**
* Checks if an address is eligible for an airdrop and returns the token amount
* This is a temporary implementation that randomly determines eligibility
* To be replaced with actual implementation that checks against the recipients file
*/
export const isEligible = async (
_address: string,
_recipientsFile: string
address: string,
airdrop: AirdropData
): Promise<number> => {
// Temporary implementation: randomly determine eligibility
// const random = Math.random()

// // 40% chance of being eligible
// if (random < 0.4) {
// // Random amount between 100 and 1000 tokens
// const amount = Math.floor(Math.random() * 900) + 100
// return amount
// }

return 1337
if (!airdrop.recipientsFile || !address) {
return 0
}
const request = await fetch(airdrop.recipientsFile)
const recipients = await request.json()
const recipient = recipients.values.find((recipient: any) => {
return recipient.value[0] === address
})
if (!recipient) {
return 0
}
return Number(ethers.formatUnits(recipient.value[1], airdrop.token.decimals))
}
Loading

0 comments on commit 6bf0e0b

Please sign in to comment.