This commit is contained in:
Noah Zinsmeister
2020-09-13 18:02:33 -04:00
parent 1af68f0937
commit 0d478d722d
6 changed files with 109 additions and 86 deletions

View File

@@ -1,7 +1,7 @@
# @uniswap/airdrop-token-distributor
# @uniswap/merkle-distributor
[![Tests](https://github.com/Uniswap/airdrop-token-distributor/workflows/Tests/badge.svg)](https://github.com/Uniswap/airdrop-token-distributor/actions?query=workflow%3ATests)
[![Lint](https://github.com/Uniswap/airdrop-token-distributor/workflows/Lint/badge.svg)](https://github.com/Uniswap/airdrop-token-distributor/actions?query=workflow%3ALint)
[![Tests](https://github.com/Uniswap/merkle-distributor/workflows/Tests/badge.svg)](https://github.com/Uniswap/merkle-distributor/actions?query=workflow%3ATests)
[![Lint](https://github.com/Uniswap/merkle-distributor/workflows/Lint/badge.svg)](https://github.com/Uniswap/merkle-distributor/actions?query=workflow%3ALint)
# Local Development

View File

@@ -3,9 +3,9 @@ pragma solidity =0.6.11;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/cryptography/MerkleProof.sol";
import "./interfaces/IAirdropTokenDistributor.sol";
import "./interfaces/IMerkleDistributor.sol";
contract AirdropTokenDistributor is IAirdropTokenDistributor {
contract MerkleDistributor is IMerkleDistributor {
address public immutable override token;
bytes32 public immutable override merkleRoot;
@@ -32,15 +32,15 @@ contract AirdropTokenDistributor is IAirdropTokenDistributor {
}
function claim(uint256 index, address account, uint256 amount, bytes32[] calldata merkleProof) external override {
require(!isClaimed(index), 'AirdropTokenDistributor: Drop already claimed.');
require(!isClaimed(index), 'MerkleDistributor: Drop already claimed.');
// Verify the merkle proof.
bytes32 node = keccak256(abi.encodePacked(index, account, amount));
require(MerkleProof.verify(merkleProof, merkleRoot, node), 'AirdropTokenDistributor: Invalid proof.');
require(MerkleProof.verify(merkleProof, merkleRoot, node), 'MerkleDistributor: Invalid proof.');
// Mark it claimed and send the token.
_setClaimed(index);
require(IERC20(token).transfer(account, amount), 'AirdropTokenDistributor: Transfer failed.');
require(IERC20(token).transfer(account, amount), 'MerkleDistributor: Transfer failed.');
emit Claimed(index, account, amount);
}

View File

@@ -2,7 +2,7 @@
pragma solidity >=0.5.0;
// Allows anyone to claim a token if they exist in a merkle root.
interface IAirdropTokenDistributor {
interface IMerkleDistributor {
// Returns the address of the token distributed by this contract.
function token() external view returns (address);
// Returns the merkle root of the merkle tree containing account balances available to claim.

View File

@@ -1,5 +1,5 @@
{
"name": "@uniswap/airdrop-token-distributor",
"name": "@uniswap/merkle-distributor",
"author": {
"name": "Moody Salem"
},
@@ -8,12 +8,11 @@
"homepage": "https://uniswap.org",
"keywords": [
"uniswap",
"erc20",
"airdrop"
"erc20"
],
"repository": {
"type": "git",
"url": "https://github.com/Uniswap/airdrop-token-distributor"
"url": "https://github.com/Uniswap/merkle-distributor"
},
"files": [
"build"

View File

@@ -7,7 +7,7 @@ const { isAddress, getAddress } = utils
// It is completely sufficient for recreating the entire merkle tree.
// Anyone can verify that all air drops are included in the tree,
// and the tree has no additional distributions.
interface AirdropInfo {
interface MerkleDistributorInfo {
merkleRoot: string
tokenTotal: string
claims: {
@@ -19,7 +19,7 @@ interface AirdropInfo {
}
}
export function parseBalanceMap(balanceMap: { [account: string]: string }): AirdropInfo {
export function parseBalanceMap(balanceMap: { [account: string]: string }): MerkleDistributorInfo {
const mapped = Object.keys(balanceMap).reduce<{ [address: string]: BigNumber }>((memo, account) => {
if (!isAddress(account)) {
throw new Error(`Found invalid address: ${account}`)

View File

@@ -3,7 +3,7 @@ import { solidity, MockProvider, deployContract } from 'ethereum-waffle'
import { Contract, BigNumber, constants } from 'ethers'
import BalanceTree from '../src/balance-tree'
import Airdrop from '../build/AirdropTokenDistributor.json'
import Distributor from '../build/MerkleDistributor.json'
import TestERC20 from '../build/TestERC20.json'
import { parseBalanceMap } from '../src/parse-balance-map'
@@ -15,7 +15,7 @@ const overrides = {
const ZERO_BYTES32 = '0x0000000000000000000000000000000000000000000000000000000000000000'
describe('AirdropTokenDistributor', () => {
describe('MerkleDistributor', () => {
const provider = new MockProvider({
ganacheOptions: {
hardfork: 'istanbul',
@@ -34,129 +34,153 @@ describe('AirdropTokenDistributor', () => {
describe('#token', () => {
it('returns the token address', async () => {
const airdrop = await deployContract(wallet0, Airdrop, [token.address, ZERO_BYTES32], overrides)
expect(await airdrop.token()).to.eq(token.address)
const distributor = await deployContract(wallet0, Distributor, [token.address, ZERO_BYTES32], overrides)
expect(await distributor.token()).to.eq(token.address)
})
})
describe('#merkleRoot', () => {
it('returns the zero merkle root', async () => {
const airdrop = await deployContract(wallet0, Airdrop, [token.address, ZERO_BYTES32], overrides)
expect(await airdrop.merkleRoot()).to.eq(ZERO_BYTES32)
const distributor = await deployContract(wallet0, Distributor, [token.address, ZERO_BYTES32], overrides)
expect(await distributor.merkleRoot()).to.eq(ZERO_BYTES32)
})
})
describe('#claim', () => {
it('fails for empty proof', async () => {
const airdrop = await deployContract(wallet0, Airdrop, [token.address, ZERO_BYTES32], overrides)
await expect(airdrop.claim(0, wallet0.address, 10, [])).to.be.revertedWith(
'AirdropTokenDistributor: Invalid proof.'
const distributor = await deployContract(wallet0, Distributor, [token.address, ZERO_BYTES32], overrides)
await expect(distributor.claim(0, wallet0.address, 10, [])).to.be.revertedWith(
'MerkleDistributor: Invalid proof.'
)
})
it('fails for invalid index', async () => {
const airdrop = await deployContract(wallet0, Airdrop, [token.address, ZERO_BYTES32], overrides)
await expect(airdrop.claim(0, wallet0.address, 10, [])).to.be.revertedWith(
'AirdropTokenDistributor: Invalid proof.'
const distributor = await deployContract(wallet0, Distributor, [token.address, ZERO_BYTES32], overrides)
await expect(distributor.claim(0, wallet0.address, 10, [])).to.be.revertedWith(
'MerkleDistributor: Invalid proof.'
)
})
describe('two account tree', () => {
let airdrop: Contract
let distributor: Contract
let tree: BalanceTree
beforeEach('deploy', async () => {
tree = new BalanceTree([
{ account: wallet0.address, amount: BigNumber.from(100) },
{ account: wallet1.address, amount: BigNumber.from(101) },
])
airdrop = await deployContract(wallet0, Airdrop, [token.address, tree.getHexRoot()], overrides)
await token.setBalance(airdrop.address, 201)
distributor = await deployContract(wallet0, Distributor, [token.address, tree.getHexRoot()], overrides)
await token.setBalance(distributor.address, 201)
})
it('successful claim', async () => {
const proof0 = tree.getProof(0, wallet0.address, BigNumber.from(100))
await expect(airdrop.claim(0, wallet0.address, 100, proof0, overrides))
.to.emit(airdrop, 'Claimed')
await expect(distributor.claim(0, wallet0.address, 100, proof0, overrides))
.to.emit(distributor, 'Claimed')
.withArgs(0, wallet0.address, 100)
const proof1 = tree.getProof(1, wallet1.address, BigNumber.from(101))
await expect(airdrop.claim(1, wallet1.address, 101, proof1, overrides))
.to.emit(airdrop, 'Claimed')
await expect(distributor.claim(1, wallet1.address, 101, proof1, overrides))
.to.emit(distributor, 'Claimed')
.withArgs(1, wallet1.address, 101)
})
it('transfers the token', async () => {
const proof0 = tree.getProof(0, wallet0.address, BigNumber.from(100))
expect(await token.balanceOf(wallet0.address)).to.eq(0)
await airdrop.claim(0, wallet0.address, 100, proof0, overrides)
await distributor.claim(0, wallet0.address, 100, proof0, overrides)
expect(await token.balanceOf(wallet0.address)).to.eq(100)
})
it('must have enough to transfer', async () => {
const proof0 = tree.getProof(0, wallet0.address, BigNumber.from(100))
await token.setBalance(airdrop.address, 99)
await expect(airdrop.claim(0, wallet0.address, 100, proof0, overrides)).to.be.revertedWith(
await token.setBalance(distributor.address, 99)
await expect(distributor.claim(0, wallet0.address, 100, proof0, overrides)).to.be.revertedWith(
'ERC20: transfer amount exceeds balance'
)
})
it('sets #isClaimed', async () => {
const proof0 = tree.getProof(0, wallet0.address, BigNumber.from(100))
expect(await airdrop.isClaimed(0)).to.eq(false)
expect(await airdrop.isClaimed(1)).to.eq(false)
await airdrop.claim(0, wallet0.address, 100, proof0, overrides)
expect(await airdrop.isClaimed(0)).to.eq(true)
expect(await airdrop.isClaimed(1)).to.eq(false)
expect(await distributor.isClaimed(0)).to.eq(false)
expect(await distributor.isClaimed(1)).to.eq(false)
await distributor.claim(0, wallet0.address, 100, proof0, overrides)
expect(await distributor.isClaimed(0)).to.eq(true)
expect(await distributor.isClaimed(1)).to.eq(false)
})
it('cannot allow two claims', async () => {
const proof0 = tree.getProof(0, wallet0.address, BigNumber.from(100))
await airdrop.claim(0, wallet0.address, 100, proof0, overrides)
await expect(airdrop.claim(0, wallet0.address, 100, proof0, overrides)).to.be.revertedWith(
'AirdropTokenDistributor: Drop already claimed.'
await distributor.claim(0, wallet0.address, 100, proof0, overrides)
await expect(distributor.claim(0, wallet0.address, 100, proof0, overrides)).to.be.revertedWith(
'MerkleDistributor: Drop already claimed.'
)
})
it('cannot claim more than once: 0 and then 1', async () => {
await airdrop.claim(0, wallet0.address, 100, tree.getProof(0, wallet0.address, BigNumber.from(100)), overrides)
await airdrop.claim(1, wallet1.address, 101, tree.getProof(1, wallet1.address, BigNumber.from(101)), overrides)
await distributor.claim(
0,
wallet0.address,
100,
tree.getProof(0, wallet0.address, BigNumber.from(100)),
overrides
)
await distributor.claim(
1,
wallet1.address,
101,
tree.getProof(1, wallet1.address, BigNumber.from(101)),
overrides
)
await expect(
airdrop.claim(0, wallet0.address, 100, tree.getProof(0, wallet0.address, BigNumber.from(100)), overrides)
).to.be.revertedWith('AirdropTokenDistributor: Drop already claimed.')
distributor.claim(0, wallet0.address, 100, tree.getProof(0, wallet0.address, BigNumber.from(100)), overrides)
).to.be.revertedWith('MerkleDistributor: Drop already claimed.')
})
it('cannot claim more than once: 1 and then 0', async () => {
await airdrop.claim(1, wallet1.address, 101, tree.getProof(1, wallet1.address, BigNumber.from(101)), overrides)
await airdrop.claim(0, wallet0.address, 100, tree.getProof(0, wallet0.address, BigNumber.from(100)), overrides)
await distributor.claim(
1,
wallet1.address,
101,
tree.getProof(1, wallet1.address, BigNumber.from(101)),
overrides
)
await distributor.claim(
0,
wallet0.address,
100,
tree.getProof(0, wallet0.address, BigNumber.from(100)),
overrides
)
await expect(
airdrop.claim(1, wallet1.address, 101, tree.getProof(1, wallet1.address, BigNumber.from(101)), overrides)
).to.be.revertedWith('AirdropTokenDistributor: Drop already claimed.')
distributor.claim(1, wallet1.address, 101, tree.getProof(1, wallet1.address, BigNumber.from(101)), overrides)
).to.be.revertedWith('MerkleDistributor: Drop already claimed.')
})
it('cannot claim for address other than proof', async () => {
const proof0 = tree.getProof(0, wallet0.address, BigNumber.from(100))
await expect(airdrop.claim(1, wallet1.address, 101, proof0, overrides)).to.be.revertedWith(
'AirdropTokenDistributor: Invalid proof.'
await expect(distributor.claim(1, wallet1.address, 101, proof0, overrides)).to.be.revertedWith(
'MerkleDistributor: Invalid proof.'
)
})
it('cannot claim more than proof', async () => {
const proof0 = tree.getProof(0, wallet0.address, BigNumber.from(100))
await expect(airdrop.claim(0, wallet0.address, 101, proof0, overrides)).to.be.revertedWith(
'AirdropTokenDistributor: Invalid proof.'
await expect(distributor.claim(0, wallet0.address, 101, proof0, overrides)).to.be.revertedWith(
'MerkleDistributor: Invalid proof.'
)
})
it('gas', async () => {
const proof = tree.getProof(0, wallet0.address, BigNumber.from(100))
const tx = await airdrop.claim(0, wallet0.address, 100, proof, overrides)
const tx = await distributor.claim(0, wallet0.address, 100, proof, overrides)
const receipt = await tx.wait()
expect(receipt.gasUsed).to.eq(78466)
})
})
describe('larger tree', () => {
let airdrop: Contract
let distributor: Contract
let tree: BalanceTree
beforeEach('deploy', async () => {
tree = new BalanceTree(
@@ -164,40 +188,40 @@ describe('AirdropTokenDistributor', () => {
return { account: wallet.address, amount: BigNumber.from(ix + 1) }
})
)
airdrop = await deployContract(wallet0, Airdrop, [token.address, tree.getHexRoot()], overrides)
await token.setBalance(airdrop.address, 201)
distributor = await deployContract(wallet0, Distributor, [token.address, tree.getHexRoot()], overrides)
await token.setBalance(distributor.address, 201)
})
it('claim index 4', async () => {
const proof = tree.getProof(4, wallets[4].address, BigNumber.from(5))
await expect(airdrop.claim(4, wallets[4].address, 5, proof, overrides))
.to.emit(airdrop, 'Claimed')
await expect(distributor.claim(4, wallets[4].address, 5, proof, overrides))
.to.emit(distributor, 'Claimed')
.withArgs(4, wallets[4].address, 5)
})
it('claim index 9', async () => {
const proof = tree.getProof(9, wallets[9].address, BigNumber.from(10))
await expect(airdrop.claim(9, wallets[9].address, 10, proof, overrides))
.to.emit(airdrop, 'Claimed')
await expect(distributor.claim(9, wallets[9].address, 10, proof, overrides))
.to.emit(distributor, 'Claimed')
.withArgs(9, wallets[9].address, 10)
})
it('gas', async () => {
const proof = tree.getProof(9, wallets[9].address, BigNumber.from(10))
const tx = await airdrop.claim(9, wallets[9].address, 10, proof, overrides)
const tx = await distributor.claim(9, wallets[9].address, 10, proof, overrides)
const receipt = await tx.wait()
expect(receipt.gasUsed).to.eq(80960)
})
it('gas second down about 15k', async () => {
await airdrop.claim(
await distributor.claim(
0,
wallets[0].address,
1,
tree.getProof(0, wallets[0].address, BigNumber.from(1)),
overrides
)
const tx = await airdrop.claim(
const tx = await distributor.claim(
1,
wallets[1].address,
2,
@@ -210,7 +234,7 @@ describe('AirdropTokenDistributor', () => {
})
describe('realistic size tree', () => {
let airdrop: Contract
let distributor: Contract
let tree: BalanceTree
const NUM_LEAVES = 100_000
const NUM_SAMPLES = 25
@@ -233,19 +257,19 @@ describe('AirdropTokenDistributor', () => {
})
beforeEach('deploy', async () => {
airdrop = await deployContract(wallet0, Airdrop, [token.address, tree.getHexRoot()], overrides)
await token.setBalance(airdrop.address, constants.MaxUint256)
distributor = await deployContract(wallet0, Distributor, [token.address, tree.getHexRoot()], overrides)
await token.setBalance(distributor.address, constants.MaxUint256)
})
it('gas', async () => {
const proof = tree.getProof(50000, wallet0.address, BigNumber.from(100))
const tx = await airdrop.claim(50000, wallet0.address, 100, proof, overrides)
const tx = await distributor.claim(50000, wallet0.address, 100, proof, overrides)
const receipt = await tx.wait()
expect(receipt.gasUsed).to.eq(91650)
})
it('gas deeper node', async () => {
const proof = tree.getProof(90000, wallet0.address, BigNumber.from(100))
const tx = await airdrop.claim(90000, wallet0.address, 100, proof, overrides)
const tx = await distributor.claim(90000, wallet0.address, 100, proof, overrides)
const receipt = await tx.wait()
expect(receipt.gasUsed).to.eq(91586)
})
@@ -254,7 +278,7 @@ describe('AirdropTokenDistributor', () => {
let count: number = 0
for (let i = 0; i < NUM_LEAVES; i += NUM_LEAVES / NUM_SAMPLES) {
const proof = tree.getProof(i, wallet0.address, BigNumber.from(100))
const tx = await airdrop.claim(i, wallet0.address, 100, proof, overrides)
const tx = await distributor.claim(i, wallet0.address, 100, proof, overrides)
const receipt = await tx.wait()
total = total.add(receipt.gasUsed)
count++
@@ -268,7 +292,7 @@ describe('AirdropTokenDistributor', () => {
let count: number = 0
for (let i = 0; i < 25; i++) {
const proof = tree.getProof(i, wallet0.address, BigNumber.from(100))
const tx = await airdrop.claim(i, wallet0.address, 100, proof, overrides)
const tx = await distributor.claim(i, wallet0.address, 100, proof, overrides)
const receipt = await tx.wait()
total = total.add(receipt.gasUsed)
count++
@@ -280,9 +304,9 @@ describe('AirdropTokenDistributor', () => {
it('no double claims in random distribution', async () => {
for (let i = 0; i < 25; i += Math.floor(Math.random() * (NUM_LEAVES / NUM_SAMPLES))) {
const proof = tree.getProof(i, wallet0.address, BigNumber.from(100))
await airdrop.claim(i, wallet0.address, 100, proof, overrides)
await expect(airdrop.claim(i, wallet0.address, 100, proof, overrides)).to.be.revertedWith(
'AirdropTokenDistributor: Drop already claimed.'
await distributor.claim(i, wallet0.address, 100, proof, overrides)
await expect(distributor.claim(i, wallet0.address, 100, proof, overrides)).to.be.revertedWith(
'MerkleDistributor: Drop already claimed.'
)
}
})
@@ -290,7 +314,7 @@ describe('AirdropTokenDistributor', () => {
})
describe('parseBalanceMap', () => {
let airdrop: Contract
let distributor: Contract
let claims: {
[account: string]: {
index: number
@@ -306,8 +330,8 @@ describe('AirdropTokenDistributor', () => {
})
expect(tokenTotal).to.eq('0x02ee') // 750
claims = innerClaims
airdrop = await deployContract(wallet0, Airdrop, [token.address, merkleRoot], overrides)
await token.setBalance(airdrop.address, tokenTotal)
distributor = await deployContract(wallet0, Distributor, [token.address, merkleRoot], overrides)
await token.setBalance(distributor.address, tokenTotal)
})
it('check the proofs is as expected', () => {
@@ -339,14 +363,14 @@ describe('AirdropTokenDistributor', () => {
it('all claims work exactly once', async () => {
for (let account in claims) {
const claim = claims[account]
await expect(airdrop.claim(claim.index, account, claim.amount, claim.proof, overrides))
.to.emit(airdrop, 'Claimed')
await expect(distributor.claim(claim.index, account, claim.amount, claim.proof, overrides))
.to.emit(distributor, 'Claimed')
.withArgs(claim.index, account, claim.amount)
await expect(airdrop.claim(claim.index, account, claim.amount, claim.proof, overrides)).to.be.revertedWith(
'AirdropTokenDistributor: Drop already claimed.'
await expect(distributor.claim(claim.index, account, claim.amount, claim.proof, overrides)).to.be.revertedWith(
'MerkleDistributor: Drop already claimed.'
)
}
expect(await token.balanceOf(airdrop.address)).to.eq(0)
expect(await token.balanceOf(distributor.address)).to.eq(0)
})
})
})