Skip to content

Commit 8832e63

Browse files
committed
cli: contract based airdrop using disperse.app
This is much faster as we can bundle hundreds of transfers in a single transaction. Add support for concurrency using p-queue and NonceManager.
1 parent 662d012 commit 8832e63

File tree

6 files changed

+838
-3
lines changed

6 files changed

+838
-3
lines changed

cli/cli.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { protocolCommand } from './commands/protocol'
1010
import { contractsCommand } from './commands/contracts'
1111
import { transferTeamTokensCommand } from './commands/transferTeamTokens'
1212
import { simulationCommand } from './commands/simulations'
13+
import { airdropCommand } from './commands/airdrop'
1314

1415
import { cliOpts } from './constants'
1516

@@ -29,5 +30,6 @@ yargs
2930
.command(contractsCommand)
3031
.command(transferTeamTokensCommand)
3132
.command(simulationCommand)
33+
.command(airdropCommand)
3234
.demandCommand(1, 'Choose a command from the above list')
3335
.help().argv

cli/commands/airdrop.ts

Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
import consola from 'consola'
2+
import inquirer from 'inquirer'
3+
import fs from 'fs'
4+
import PQueue from 'p-queue'
5+
import yargs, { Argv } from 'yargs'
6+
import { parseGRT, formatGRT } from '@graphprotocol/common-ts'
7+
8+
import { utils, BigNumber, Contract } from 'ethers'
9+
import { NonceManager } from '@ethersproject/experimental'
10+
11+
import { sendTransaction } from '../network'
12+
import { loadEnv, CLIArgs, CLIEnvironment } from '../env'
13+
14+
const logger = consola.create({})
15+
16+
const { getAddress } = utils
17+
18+
const DISPERSE_CONTRACT_ADDRESS = {
19+
1: '0xD152f549545093347A162Dce210e7293f1452150',
20+
4: '0xD152f549545093347A162Dce210e7293f1452150',
21+
1337: '0xD152f549545093347A162Dce210e7293f1452150',
22+
}
23+
24+
const DISPERSE_CONTRACT_ABI = [
25+
{
26+
constant: false,
27+
inputs: [
28+
{ name: 'token', type: 'address' },
29+
{ name: 'recipients', type: 'address[]' },
30+
{ name: 'values', type: 'uint256[]' },
31+
],
32+
name: 'disperseTokenSimple',
33+
outputs: [],
34+
payable: false,
35+
stateMutability: 'nonpayable',
36+
type: 'function',
37+
},
38+
{
39+
constant: false,
40+
inputs: [
41+
{ name: 'token', type: 'address' },
42+
{ name: 'recipients', type: 'address[]' },
43+
{ name: 'values', type: 'uint256[]' },
44+
],
45+
name: 'disperseToken',
46+
outputs: [],
47+
payable: false,
48+
stateMutability: 'nonpayable',
49+
type: 'function',
50+
},
51+
{
52+
constant: false,
53+
inputs: [
54+
{ name: 'recipients', type: 'address[]' },
55+
{ name: 'values', type: 'uint256[]' },
56+
],
57+
name: 'disperseEther',
58+
outputs: [],
59+
payable: true,
60+
stateMutability: 'payable',
61+
type: 'function',
62+
},
63+
]
64+
65+
interface AirdropRecipient {
66+
address: string
67+
amount: BigNumber
68+
txHash?: string
69+
}
70+
71+
const sure = async (message = 'Are you sure?'): Promise<boolean> => {
72+
// Warn about changing ownership
73+
const res = await inquirer.prompt({
74+
name: 'confirm',
75+
type: 'confirm',
76+
message,
77+
})
78+
if (!res.confirm) {
79+
consola.success('Cancelled')
80+
return false
81+
}
82+
return true
83+
}
84+
85+
const getDisperseContract = (chainID: number, provider) => {
86+
return new Contract(DISPERSE_CONTRACT_ADDRESS[chainID], DISPERSE_CONTRACT_ABI, provider)
87+
}
88+
89+
const loadRecipients = (path: string): Array<AirdropRecipient> => {
90+
const data = fs.readFileSync(path, 'utf8')
91+
const lines = data.split('\n').map((e) => e.trim())
92+
93+
const results: Array<AirdropRecipient> = []
94+
for (const line of lines) {
95+
const [address, amount, txHash] = line.split(',').map((e) => e.trim())
96+
97+
// Skip any empty value
98+
if (!address) continue
99+
100+
// Test for zero amount and fail
101+
const weiAmount = parseGRT(amount)
102+
if (weiAmount.eq(0)) {
103+
logger.fatal(`Error loading address "${address}" - amount is zero`)
104+
process.exit(0)
105+
}
106+
107+
// Validate address format
108+
try {
109+
getAddress(address)
110+
} catch (err) {
111+
// Full stop on error
112+
logger.fatal(`Error loading address "${address}" please review the input file`)
113+
process.exit(1)
114+
}
115+
results.push({ address, amount: weiAmount, txHash })
116+
}
117+
return results
118+
}
119+
120+
const sumTotalAmount = (recipients: Array<AirdropRecipient>): BigNumber => {
121+
let total = BigNumber.from(0)
122+
for (const recipient of recipients) {
123+
total = total.add(recipient.amount)
124+
}
125+
return total
126+
}
127+
128+
const saveResumeList = (path: string, txHash: string, recipients: Array<AirdropRecipient>) => {
129+
for (const recipient of recipients) {
130+
const line = [recipient.address, formatGRT(recipient.amount), txHash].join(',') + '\n'
131+
fs.writeFileSync(path, line, {
132+
flag: 'a+',
133+
})
134+
}
135+
}
136+
137+
const loadResumeList = (path: string): Array<AirdropRecipient> => {
138+
try {
139+
return loadRecipients(path)
140+
} catch (err) {
141+
if (err.code === 'ENOENT') {
142+
logger.warn('No existing resumefile, one will be created')
143+
return []
144+
} else {
145+
throw err
146+
}
147+
}
148+
}
149+
150+
const createBatches = (
151+
items: Array<AirdropRecipient>,
152+
batchSize = 10,
153+
): Array<Array<AirdropRecipient>> => {
154+
const remainingItems = Object.assign([], items)
155+
const batches = []
156+
while (remainingItems.length > 0) {
157+
const batchItems = remainingItems.splice(0, batchSize)
158+
batches.push(batchItems)
159+
if (batchItems.length < batchSize) {
160+
break
161+
}
162+
}
163+
return batches
164+
}
165+
166+
export const airdrop = async (cli: CLIEnvironment, cliArgs: CLIArgs): Promise<void> => {
167+
const graphToken = cli.contracts.GraphToken
168+
169+
// Load data
170+
const resumeList = loadResumeList(cliArgs.resumefile).map((r) => r.address)
171+
const recipients = loadRecipients(cliArgs.recipients).filter(
172+
(r) => !resumeList.includes(r.address),
173+
)
174+
const totalAmount = sumTotalAmount(recipients)
175+
176+
// Summary
177+
logger.log(`# Batch Size: ${cliArgs.batchSize}`)
178+
logger.log(`# Concurrency: ${cliArgs.concurrency}`)
179+
logger.log(`> Token: ${graphToken.address}`)
180+
logger.log(`> Distributing: ${formatGRT(totalAmount)} tokens (${totalAmount} wei)`)
181+
logger.log(`> Resumelist: ${resumeList.length} addresses`)
182+
logger.log(`> Recipients: ${recipients.length} addresses\n`)
183+
184+
// Validity check
185+
if (totalAmount.eq(0)) {
186+
logger.fatal('Cannot proceed with a distribution of zero tokens')
187+
process.exit(1)
188+
}
189+
190+
// Load airdrop contract
191+
const disperseContract = getDisperseContract(cli.chainId, cli.wallet.provider)
192+
if (!disperseContract.address) {
193+
logger.fatal('Disperse contract not found. Please review your network settings.')
194+
process.exit(1)
195+
}
196+
197+
// Confirmation
198+
logger.log('Are you sure you want to proceed with the distribution?')
199+
if (!(await sure())) {
200+
process.exit(1)
201+
}
202+
203+
// Approve
204+
logger.info('## Token approval')
205+
const allowance = (
206+
await graphToken.functions['allowance'](cli.wallet.address, disperseContract.address)
207+
)[0]
208+
if (allowance.gte(totalAmount)) {
209+
logger.log('Already have enough allowance, no need to approve more...')
210+
} else {
211+
logger.log(
212+
`Approve disperse:${disperseContract.address} for ${formatGRT(
213+
totalAmount,
214+
)} tokens (${totalAmount} wei)`,
215+
)
216+
await sendTransaction(
217+
cli.wallet,
218+
graphToken,
219+
'approve',
220+
...[disperseContract.address, totalAmount],
221+
)
222+
}
223+
224+
// Distribute
225+
logger.info('## Distribution')
226+
const queue = new PQueue({ concurrency: cliArgs.concurrency })
227+
const recipientsBatches = createBatches(recipients, cliArgs.batchSize)
228+
const nonceManager = new NonceManager(cli.wallet) // Use NonceManager to send concurrent txs
229+
230+
let batchNum = 0
231+
let recipientsCount = 0
232+
for (const batch of recipientsBatches) {
233+
queue.add(async () => {
234+
const addressList = batch.map((r) => r.address)
235+
const amountList = batch.map((r) => r.amount)
236+
237+
recipientsCount += addressList.length
238+
batchNum++
239+
logger.info(`Sending batch #${batchNum} : ${recipientsCount}/${recipients.length}`)
240+
for (const recipient of batch) {
241+
logger.log(
242+
` > Transferring ${recipient.address} => ${formatGRT(recipient.amount)} (${
243+
recipient.amount
244+
} wei)`,
245+
)
246+
}
247+
try {
248+
const receipt = await sendTransaction(
249+
nonceManager,
250+
disperseContract,
251+
'disperseToken',
252+
...[graphToken.address, addressList, amountList],
253+
)
254+
saveResumeList(cliArgs.resumefile, receipt.transactionHash, batch)
255+
} catch (err) {
256+
logger.error(`Failed to send #${batchNum}`, err)
257+
}
258+
})
259+
}
260+
await queue.onIdle()
261+
}
262+
263+
export const airdropCommand = {
264+
command: 'airdrop',
265+
describe: 'Airdrop tokens',
266+
builder: (yargs: Argv): yargs.Argv => {
267+
return yargs
268+
.option('recipients', {
269+
description: 'Path to the file with information for the airdrop. CSV file address,amount',
270+
type: 'string',
271+
requiresArg: true,
272+
demandOption: true,
273+
})
274+
.option('resumefile', {
275+
description:
276+
'Path to the file used for resuming. Stores results with CSV format: address,amount,txHash',
277+
type: 'string',
278+
requiresArg: true,
279+
demandOption: true,
280+
})
281+
.option('batch-size', {
282+
description: 'Number of addresses to send in a single transaction',
283+
type: 'number',
284+
requiresArg: true,
285+
demandOption: true,
286+
default: 100,
287+
})
288+
.option('concurrency', {
289+
description: 'Number of simultaneous transfers',
290+
type: 'number',
291+
requiresArg: true,
292+
demandOption: false,
293+
default: 1,
294+
})
295+
},
296+
handler: async (argv: CLIArgs): Promise<void> => {
297+
return airdrop(await loadEnv(argv), argv)
298+
},
299+
}

cli/mockData/team-airdrop.csv

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
0x8Df7727e3B5aCC57Be09578dc54CDD53a9dA156C,100
2+
0x0E322262016E94EBE6b17e8396C00D04538b3aED,100
3+
0x04604EcE79E9E0eeE7B1C262223614e02047B74f,100
4+
0xA8B2B5c22E5c13E9F789284b067736D906A5AFa9,100
5+
0x85FAC3f0734Ac360712caED2C0b9782133ed37Da,100
6+
0xbEb1Faa6E7e39c7d9BdaB03a7a362fE9d73D7C61,100
7+
0x1b037167C4b0584Ca5Ef6534648C38F496757FA5,100
8+
0xeF38F892E4722152fD8eDb50cD84a96344FD47Ce,100
9+
0x93606b27cB5e4c780883eC4F6b7Bed5f6572d1dd,100
10+
0xD31bC1e2a214066Bb2258dac5f43Ce75e5542Ab9,100
11+
0x055BCF2c2BC965Ac8EEAe3f95922D65EE45d3366,100
12+
0xc02624Affa30299fA164e7176A37610835A923A7,100
13+
0x9e0a3BD68620abD81226047cA0e1e0c63A2BE36B,100
14+
0x2EF7cD7a130a4bbbD637312a3B4A8E5365296e69,100
15+
0x2ACa45C45580C17E60E80673E2a2595d11009552,100

cli/network.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,11 +73,11 @@ export const sendTransaction = async (
7373
// Send transaction
7474
let tx: ContractTransaction
7575
try {
76-
tx = await contract.functions[fn](...params)
76+
tx = await contract.connect(sender).functions[fn](...params)
7777
} catch (e) {
7878
if (e.code == 'UNPREDICTABLE_GAS_LIMIT') {
7979
logger.warn(`Gas could not be estimated - trying defaultOverrides`)
80-
tx = await contract.functions[fn](...params, defaultOverrides())
80+
tx = await contract.connect(sender).functions[fn](...params, defaultOverrides())
8181
} else {
8282
throw e
8383
}
@@ -87,7 +87,8 @@ export const sendTransaction = async (
8787
`It appears the function does not exist on this contract, or you have the wrong contract address`,
8888
)
8989
}
90-
logger.log(`> Sent transaction ${fn}: ${params}, txHash: ${tx.hash}`)
90+
logger.log(`> Sent transaction ${fn}: [${params}] txHash: ${tx.hash}`)
91+
9192
// Wait for transaction to be mined
9293
const receipt = await sender.provider.waitForTransaction(tx.hash)
9394
const networkName = (await sender.provider.getNetwork()).name

0 commit comments

Comments
 (0)