|
| 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 | +} |
0 commit comments