Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature(unlock-app): Airdrops blastoff #15564

Merged
merged 7 commits into from
Feb 26, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading