diff --git a/.gitignore b/.gitignore index 93f1361..6abe36f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,7 @@ node_modules npm-debug.log + +.ssh +*.log + +package-lock.json \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index fe504b2..0177afd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,6 +3,5 @@ FROM node:7-onbuild ENV PORT 8080 EXPOSE 8080 -ENV NAME autobounty -ENV STANDARD_BOUNTY 0.001 -ENV WEBHOOK_SECRET test +# Set this variable to the name of your production config file (without the extension) +ENV NODE_ENV development diff --git a/bot/github.js b/bot/github.js new file mode 100644 index 0000000..ee29eac --- /dev/null +++ b/bot/github.js @@ -0,0 +1,55 @@ +'use strict' + +const https = require('https'); +const config = require('../config'); + +// Returns the url for getting the labels of a request (Github API v3) +// req has req.issue.labels_url +const getLabelsURL = function (req) { + let url = req.body.issue.labels_url; + // Make the URL generic removing the name of the label + return url.replace('{/name}', ''); +} + +// Returns the bounty labelNames of the request, only for testing motives +const getLabelsMock = function (req) { + return new Promise((resolve, reject) => { resolve(req.body.issue.labels) }); +} + +// Returns all the bounty labelNames of a given issue (Github API v3) +const getLabels = function (req) { + if (config.debug) { + return getLabelsMock(req); + } else { + let path = getLabelsURL(req).replace('https://api.github.com', ''); + const options = { + hostname: 'api.github.com', + path: path, + headers: { 'User-Agent': 'kafkasl' } + }; + return new Promise((resolve, reject) => { + const request = https.get(options, (response) => { + // handle http errors + if (response.statusCode < 200 || response.statusCode > 299) { + bot.error(response, 'Failed to load page, status code: ' + response.statusCode); + reject(new Error('Failed to load page, status code: ' + response.statusCode)); + } + // temporary data holder + const body = []; + // on every content chunk, push it to the data array + response.on('data', (chunk) => body.push(chunk)); + // we are done, resolve promise with those joined chunks + response.on('end', () => { + let labels = JSON.parse(body.join('')).map(labelObj => labelObj.name); + resolve(labels); + }); + }); + // handle connection errors of the request + request.on('error', (err) => reject(err)) + }); + } +} + +module.exports = { + getLabels: getLabels +} diff --git a/bot/index.js b/bot/index.js new file mode 100644 index 0000000..43c8f02 --- /dev/null +++ b/bot/index.js @@ -0,0 +1,133 @@ +const winston = require('winston'); +const prices = require('./prices'); +const config = require('../config'); +const github = require('./github'); + + +const logger = winston.createLogger({ + level: 'info', + format: winston.format.json(), + transports: [ + new winston.transports.File({ filename: config.logPath + 'error.log', level: 'error' }), + new winston.transports.File({ filename: config.logPath + 'info.log', level: 'info' }), + new winston.transports.File({ filename: 'combined.log' }) + ] +}); + + +const needsFunding = function (req) { + if (req.body.action !== 'created' || !req.body.hasOwnProperty('comment')) { + return false + } else if (req.body.comment.user.login !== config.githubUsername) { + return false + } else if (!hasAddress(req)) { + return false; + } + return true +} + +const hasAddress = function (req) { + let commentBody = req.body.comment.body; + if (commentBody.search('Contract address:') === -1) { + return false; + } else { + return true; + } +} + +const getAddress = function (req) { + let commentBody = req.body.comment.body; + return commentBody.substring(commentBody.search("Contract address:") + 18, commentBody.search("Contract address:") + 60) +} + +const getLabelMock = function (req) { + return new Promise((resolve, reject) => { + github.getLabels(req) + .then(labels => { + let bountyLabels = labels.filter(name => config.bountyLabels.hasOwnProperty(name)); + if (bountyLabels.length === 1) { + resolve(bountyLabels[0]); + } else { + reject('Error getting bounty labels: ' + bountyLabels); + } + }) + .catch((err) => { + reject(err) + }); + }); +} + +const getLabel = function (req) { + if (config.debug) { + return getLabelMock(req); + } + return new Promise((resolve, reject) => { + github.getLabels(req) + .then(labels => { + let bountyLabels = labels.filter(name => config.bountyLabels.hasOwnProperty(name)); + if (bountyLabels.length === 1) { + resolve(bountyLabels[0]); + } else { + reject('Error getting bounty labels'); + } + }).catch(err => { + reject(err); + }); + }); +} + +const getAmountMock = function (req) { + return new Promise((resolve, reject) => { + resolve(10); + }); + +} + +const getAmount = function (req) { + return new Promise((resolve, reject) => { + let labelPromise = getLabel(req); + let tokenPricePromise = prices.getTokenPrice(config.token); + + Promise.all([labelPromise, tokenPricePromise]) + .then(function (values) { + let label = values[0]; + let tokenPrice = values[1]; + let amountToPayDollar = config.priceHour * config.bountyLabels[label]; + resolve(amountToPayDollar / tokenPrice); + }) + .catch((err) => { + reject(err); + }); + }); +} + + +// Logging functions + +const logTransaction = function (txId, from, to, amount, gasPrice) { + logger.info("[OK] Succesfully funded bounty with transaction " + txId); + logger.info(" * From: " + from); + logger.info(" * To: " + to); + logger.info(" * Amount: " + amount); + logger.info(" * Gas Price: " + gasPrice); + logger.info("===================================================="); +} + +const log = function (msg) { + logger.info(msg); +} + +const error = function (errorMessage) { + logger.error("[ERROR] Request processing failed: " + errorMessage); +} + + +module.exports = { + needsFunding: needsFunding, + getAddress: getAddress, + getAmount: getAmount, + getGasPrice: prices.getGasPrice, + log: log, + logTransaction: logTransaction, + error: error +} diff --git a/bot/prices.js b/bot/prices.js new file mode 100644 index 0000000..7215730 --- /dev/null +++ b/bot/prices.js @@ -0,0 +1,73 @@ +"use strict" + +const https = require("https"); +const config = require("../config"); + + +const getGasPrice = function () { + const url = 'https://ethgasstation.info/json/ethgasAPI.json'; + // return new pending promise + return new Promise((resolve, reject) => { + // select http or https module, depending on reqested url + const lib = url.startsWith('https') ? require('https') : require('http'); + const request = lib.get(url, (response) => { + // handle http errors + if (response.statusCode < 200 || response.statusCode > 299) { + reject('Failed to load page, status code: ' + response.statusCode); + } + // temporary data holder + const body = []; + // on every content chunk, push it to the data array + response.on('data', (chunk) => body.push(chunk)); + // we are done, resolve promise with those joined chunks + response.on('end', () => { + // safeLowWait returns GWei (10^10 Wei). + let jsonBody = JSON.parse(body.join('')); + let gasPriceWei = parseInt(jsonBody['safeLowWait']) * Math.pow(10, 10); + resolve(gasPriceWei); + }); + }); + // handle connection errors of the request + request.on('error', (err) => reject(err)); + }) +}; + +const getTokenPriceMock = function () { + return new Promise((resolve, reject) => resolve(0.35)); +} + +const getTokenPrice = function (token) { + if (config.debug) { + return getTokenPriceMock(); + } + const currency = 'USD' + const url = 'https://min-api.cryptocompare.com/data/price?fsym=' + token + '&tsyms=' + currency; + // return new pending promise + return new Promise((resolve, reject) => { + // select http or https module, depending on reqested url + const lib = url.startsWith('https') ? require('https') : require('http'); + const request = lib.get(url, (response) => { + // handle http errors + if (response.statusCode < 200 || response.statusCode > 299) { + reject('Failed to load page, status code: ' + response.statusCode); + } + // temporary data holder + const body = []; + // on every content chunk, push it to the data array + response.on('data', (chunk) => body.push(chunk)); + // we are done, resolve promise with those joined chunks + response.on('end', () => { + let jsonBody = JSON.parse(body.join('')); + let tokenPrice = parseFloat(jsonBody[currency]); + resolve(tokenPrice); + }); + }); + // handle connection errors of the request + request.on('error', (err) => reject(err)) + }) +} + +module.exports = { + getGasPrice: getGasPrice, + getTokenPrice: getTokenPrice +} diff --git a/config/default.js b/config/default.js new file mode 100644 index 0000000..3960609 --- /dev/null +++ b/config/default.js @@ -0,0 +1,39 @@ + + +module.exports = { + // Debug mode for testing the bot + debug: true, + + // URL where the bot is listening (e.g. '/funding') + urlEndpoint: '', + + // Path for the log files inside the docker image (e.g. './log/'), remember to create the folder inside the docker workspace if you change it (the folde will be copied to the docker image during the build) + logPath: '', + + // URL for the signer (e.g. 'https://ropsten.infura.io') + signerPath: '', + + // Address with the funding for the bounties + sourceAddress: '', + + // Token of the currency for fetching real time prices (e.g. 'SNT') + token: '', + + // Limit for the gas used in a transaction (e.g. 92000) + gasLimit: 0, + + // Price per hour you will pay in dolars (e.g. 35) + priceHour: 0, + + // Delay before funding a bounty (e.g. 3600000) + delayInMiliSeconds: 0, + + // Bounty Labels for the issues and the correspondent houres (e.g. {'bounty-xs': 3}) + bountyLabels: {}, + + // username for the bot which has to comment for starting the process (e.g. status-bounty-) + githubUsername: '', + + // Activate real transactions + realTransaction: false +} diff --git a/config/development.js b/config/development.js new file mode 100644 index 0000000..051344c --- /dev/null +++ b/config/development.js @@ -0,0 +1,21 @@ +const BOUNTY_LABELS = { + 'bounty-xs': 1, + 'bounty-s': 10, + 'bounty-m': 100, + 'bounty-l': 1000, + 'bounty-xl': 10000 +} + +module.exports = { + debug: true, + urlEndpoint: '/', + logPath: './log/', + signerPath: 'https://ropsten.infura.io', + sourceAddress: 'XXXXX', + token: 'SNT', + gasLimit: 92000, + priceHour: 35, + delayInMiliSeconds: 10000, + bountyLabels: BOUNTY_LABELS, + githubUsername: 'jomsdev' +} diff --git a/config/index.js b/config/index.js new file mode 100644 index 0000000..d66236f --- /dev/null +++ b/config/index.js @@ -0,0 +1,5 @@ +var _ = require("lodash"); +var defaults = require("./default.js"); +var config = require("./" + (process.env.NODE_ENV || "development") + ".js"); +module.exports = _.merge({}, defaults, config); + diff --git a/index.js b/index.js index caf6979..c7c24a6 100644 --- a/index.js +++ b/index.js @@ -1,90 +1,107 @@ /* - * Bot that receives a POST request (from a GitHub issue comment webhook) - * and in case it's a comment that has "@autobounty " - * awards that bounty to the address posted earlier in the thread (by the - * commiteth bot). - * TODO tests - * REVIEW parsing, non-persisting storage of addresses, hardcoded string length. - * Depends on commiteth version as of 2017-06-10. - */ +* Bot that receives a POST request (from a GitHub issue comment webhook) +* and in case it's a comment that has "@autobounty " +* awards that bounty to the address posted earlier in the thread (by the +* commiteth bot). +* TODO tests +* REVIEW parsing, non-persisting storage of addresses, hardcoded string length. +* Depends on commiteth version as of 2017-06-10. +*/ -const SignerProvider = require('ethjs-provider-signer'); -const sign = require('ethjs-signer').sign; -const Eth = require('ethjs-query'); - -const address = process.env.ADDRESS; -const name = process.env.NAME; -const webhook_secret = process.env.WEBHOOK_SECRET; - -const provider = new SignerProvider(process.env.NODE, { - signTransaction: (rawTx, cb) => cb(null, sign(rawTx, process.env.KEY)), - accounts: (cb) => cb(null, [address]), -}); -const eth = new Eth(provider); +const config = require('./config'); +const bot = require('./bot'); var express = require('express'), cors = require('cors'), + helmet = require('helmet'), app = express(), bodyParser = require('body-parser'), jsonParser = bodyParser.json(); app.use(cors()); +app.use(helmet()); -// Store issue ids and their bounty addresses -var issueData = {}; - -// Receive a POST request at the address specified by an env. var. -app.post(`/comment/${webhook_secret}`, jsonParser, function(req, res, next){ - if (!req.body) - return res.sendStatus(400); - var commentBody = req.body.comment.body; - var issueId = req.body.issue.id; - var namePosition = commentBody.search("@" + name); - // Store toAddress from commiteth - if (namePosition == -1 ) { - if (req.body.comment.user.login == 'commiteth') { // TODO no existence check - issueData[issueId] = {"toAddress": commentBody.substring(commentBody.search("Contract address:") + 18, commentBody.search("Contract address:") + 60)} - console.log(issueData); - return res.status(204); +// Receive a POST request at the url specified by an env. var. +app.post(`${config.urlEndpoint}`, jsonParser, function (req, res, next) { + if (!req.body || !req.body.action) { + return res.sendStatus(400); + } else if (!bot.needsFunding(req)) { + return res.sendStatus(204); } - } - else { - var postNameWords = commentBody.substring(namePosition + 1 + name.length + 1).trim().split(' '); - var amount = 0; - if (postNameWords.length > 0) { - if(postNameWords[0] == "standard") { - amount = process.env.STANDARD_BOUNTY; - } - else { - amount = parseFloat(postNameWords[0]); - } - } - console.log("Trying to give " + amount + " ETH to " + issueData[issueId].toAddress + " for issue " + issueId); - issueData[issueId].amount = amount; + setTimeout(() => { + processRequest(req) + .then(() => { + bot.info('issue well funded: ' + res.body.issue.url); + }) + .catch((err) => { + bot.error('Error funding issue: ' + req.body.issue.url); + bot.error('error: ' + err); + bot.error('dump: ' + req); + }); + }, config.delayInMiliSeconds); - // Conduct the transaction - eth.getTransactionCount(address, (err, nonce) => { - eth.sendTransaction({ - from: address, // Specified in webhook, secret - to: issueData[issueId].toAddress, // Address from earlier in the thread - gas: 100000, - value: issueData[issueId].amount, - nonce, - }, (err, txID) => { - if (err) { - console.log('Request failed', err) - return res.status(500).json(err) - } - else { - console.log('Successful request:', txID) - res.json({ txID }) - } - }); - }); - } + return res.sendStatus(200); }); +const processRequest = function (req) { + const eth = bot.eth; + const from = config.sourceAddress; + const to = bot.getAddress(req); + + // Asynchronous requests for Gas Price and Amount + const amountPromise = bot.getAmount(req); + const gasPricePromise = bot.getGasPrice(); + return new Promise((resolve, reject) => { + Promise.all([amountPromise, gasPricePromise]) + .then(function (results) { + let amount = results[0]; + let gasPrice = results[1]; + let transaction = sendTransaction(eth, from, to, amount, gasPrice); + + transaction + .then(function () { + resolve(); + }) + .catch(function (err) { + reject(err); + }); + + }) + .catch(function (err) { + reject(err); + }); + }); +} + +const sendTransaction = function (eth, from, to, amount, gasPrice) { + return new Promise((resolve, reject) => { + if (!config.realTransaction) { + let txID = -1; + bot.logTransaction(txID, from, to, amount, gasPrice); + resolve(); + } else { + eth.getTransactionCount(from, (err, nonce) => { + eth.sendTransaction({ + from: from, + to: to, + gas: gas, + gasPrice: gasPrice, + value: amount, + nonce, + }, (err, txID) => { + if (!err) { + bot.logTransaction(txID, from, to, amount, gasPrice); + resolve(); + } else { + reject(err); + } + }); + }); + } + }); +} + const port = process.env.PORT || 8181 -app.listen(port, function(){ - console.log('Autobounty listening on port', port); +app.listen(port, function () { + bot.log('Autobounty listening on port', port); }); diff --git a/package.json b/package.json index c1ba9e8..6863563 100644 --- a/package.json +++ b/package.json @@ -5,17 +5,23 @@ "main": "index.js", "scripts": { "start": "node index.js", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "./node_modules/mocha/bin/mocha" }, "author": "", "license": "ISC", "dependencies": { "body-parser": "^1.17.2", + "chai": "^4.1.2", "cors": "^2.8.1", + "eslint": "^4.15.0", "ethjs-provider-signer": "^0.1.4", "ethjs-query": "^0.2.4", "ethjs-signer": "^0.1.1", "express": "^4.15.2", - "web3": "^0.18.2" + "helmet": "^3.9.0", + "lodash": "^4.17.4", + "mocha": "^5.0.0", + "web3": "^0.18.2", + "winston": "^3.0.0-rc1" } } diff --git a/readme.md b/readme.md index 6b7e6fc..7e40c97 100644 --- a/readme.md +++ b/readme.md @@ -13,7 +13,90 @@ All issues tagged with **[bounty](https://github.com/status-im/status-react/issu #### The process -- An **[issue](https://github.com/status-im/status-react/issues)** is created at the repo -- Issue is labeled with **[bounty](https://github.com/status-im/status-react/issues?utf8=%E2%9C%93&q=is%3Aissue+is%3Aopen+label%3Abounty)** -- [Status OpenBounty](https://openbounty.status.im/) bot adds a bounty to the issue and it is displayed in the issue's comments -- This autobounty bot automatically funds that issue with a set amount of Ether based on another label decribing the size of the bounty based roughly on how many hours the team feels it will take to complete * the rate per hour they are willing to pay. When a successful Pull Request is merged, the contributor is paid that amount for their work autonomously, transparently and programmatically by the smart contract itself - no middle men involved at all. +1. An **[issue](https://github.com/status-im/status-react/issues)** is created at the repo +2. Issue is labeled with **[bounty](https://github.com/status-im/status-react/issues?utf8=%E2%9C%93&q=is%3Aissue+is%3Aopen+label%3Abounty)** and **bounty-{xs,s,m,l,xl}** +3. [Status OpenBounty](https://openbounty.status.im/) bot adds a bounty to the issue and it is displayed in the issue's comments +4. Webhook is triggered for issue comments. +5. Autobounty checks if the request needs funding (by default: if the user status-open-bounty posts a new comment). If it needs funding proceed to 6 otherwise do nothing. +6. Bot waits for X minutes (configurable parameter) to allow label corrections before proceeding. +7. Address to fund is obtained from status-open-bounty comment. +8. Amount to fund is computed as the hours of work corresponding to the given label times the configured price per hour divided by the token price obtained from etherscan.io (amount_of_work * price_per_hour / token_price). +9. The gas price for the transaction is retrieved from ethgasstation and the maximum gas used is a config param. +10. With all the information the bot funds the bounty from config param *sourceAddress*. + + +#### Configuration + +##### Bot config + +Autobounty is build using docker. Before building the image, you need to set up a configuration as follows: + +The [config]() folder contains the files for configuring the bot. The description for the variables can be found in *default.js*. Create a production config file (e.g. *production.js*) uing the {default,development}.js as template to override the default ones. **Remeber** to set the environment variable *NODE_ENV* in the dockerfile (e.g. `ENV NODE_ENV production`). + + +```javascript + // Debug mode for testing the bot + debug: true, + + // URL where the bot is listening (e.g. '/funding') + urlEndpoint: '', + + // Path for the log files inside the docker image (e.g. './log/'), + remember to create the folder inside the docker workspace if you change it + (the folde will be copied to the docker image during the build) + logPath: '', + + // URL for the signer (e.g. 'https://ropsten.infura.io') + signerPath: '', + + // Address with the funding for the bounties + sourceAddress: '', + + // Token of the currency for fetching real time prices (e.g. 'SNT') + token: '', + + // Limit for the gas used in a transaction (e.g. 92000) + gasLimit: 0, + + // Price per hour you will pay in dolars (e.g. 35) + priceHour: 0, + + // Delay before funding a bounty (e.g. 3600000) + delayInMiliSeconds: 0, + + // Bounty Labels for the issues and the correspondent houres (e.g. {'bounty-xs': 3}) + bountyLabels: {}, + + // username for the bot which has to comment for starting the process (e.g. status-bounty-) + githubUsername: '', + + // Activate real transactions + realTransaction: false +``` + +##### Github Webhook + +Create a github webhook with the following information: + +* Payoload URL: IP_HOST/URL_ENDPOINT +* Content Type: application/json +* Secret: blank +* Configure the webhook to be triggered by comments in issues selecting the Issue Comment box in 'Let me select individual events' + +Where *IP_HOST* is the ip of the machine running the docker image and *URL_ENDPOINT* is the configuration variable with the same name in your custom config file. + +#### Build + +To build and run the docker image issue the following commands: + +```bash +docker build -t autobounty . +docker run -p 8080:8080 autobounty +``` + +#### Important Notes + +* Bot always **aborts on error** and logs the cause of the error on the file *${LOG_PATH}/error.log*. The aborted transaction must then be manually funded. +* **Only one token** can be specified on the configuration file variable *token*. +* Autobounty bot assumes that the status-open-bounty will only post a single message. In case two messages are posted the issue would be **funded two times**. +The ongoing requests are not recorded in any persistent data storage. If the machine crashes during a request processing the request will be lost. diff --git a/restart.sh b/restart.sh new file mode 100755 index 0000000..20112a3 --- /dev/null +++ b/restart.sh @@ -0,0 +1,6 @@ + +CONTAINER_ID="$(docker ps -lq)" +docker stop ${CONTAINER_ID} +docker build -t autobounty . +sudo docker run -d -p 8080:8080 autobounty & + diff --git a/test/test_bot.js b/test/test_bot.js new file mode 100644 index 0000000..8d39b27 --- /dev/null +++ b/test/test_bot.js @@ -0,0 +1,63 @@ +const chai = require('chai'); +const expect = require('chai').expect; +const assert = require('chai').assert; +const should = require('chai').should; +const config = require('../config') +const bot = require('../bot') + + +// status-open-bounty comment from https://github.com/status-im/autobounty/issues/1 +let sob_comment = 'Current balance: 0.000000 ETH\nTokens: SNT: 2500.00 ANT: 25.00\nContract address: 0x3645fe42b1a744ad98cc032c22472388806f86f9\nNetwork: Mainnet\n To claim this bounty sign up at https://openbounty.status.im and make sure to update your Ethereum address in My Payment Details so that the bounty is correctly allocated.\nTo fund it, send ETH or ERC20/ERC223 tokens to the contract address.' + +// Fake requests +let requests = [ + { body: { action: 'created', comment: { body: 'Creating my first comment', user: { login: 'randomUser' } } } }, + { body: { action: 'edited', comment: { body: 'Editing my comment', user: { login: 'RandomUser' } } } }, + { body: { action: 'edited', comment: { body: sob_comment, user: { login: 'status-open-bounty' } } } }, + { body: { action: 'created', issue: { labels: ['bounty', 'bounty-s'] }, comment: { body: sob_comment, user: { login: 'jomsdev' } } } } +]; + +describe('Bot behavior', function () { + describe('#needsFunding()', function () { + it('should return false because the comment is not from status-open-bounty', function () { + assert.isFalse(bot.needsFunding(requests[0])); + }); + it('should return false because a user is editing a comment', function () { + assert.isFalse(bot.needsFunding(requests[1])); + }); + it('should return false because status-open-bounty edited a comment', function () { + assert.isFalse(bot.needsFunding(requests[2])); + }); + it('should return true, it is all right and we should fund', function () { + assert.isTrue(bot.needsFunding(requests[3])); + }); + }); + + describe('#getAddress', function () { + it('should return the address from a status-open-bounty bot comment', function () { + assert.equal(bot.getAddress(requests[3]), '0x3645fe42b1a744ad98cc032c22472388806f86f9'); + }); + }); + + // TODO: test getAmount which involves calls to github and bounty tags + describe('#getAmount', function () { + it('should fail and log that there is no bounty label for this issue', function () { + // Code + }); + it('should fail and log that there are more than one bounty labels for this issue', function () { + // Code + }); + it('should return the amount for the issue given the price per hour and the bounty label for this issue', (done) => { + bot.getAmount(requests[3]) + .then(function (amount) { + let label = 'bounty-s'; + let tokenPrice = 0.35; + let priceInDollars = config.priceHour * config.bountyLabels[label]; + expected_amount = priceInDollars / tokenPrice; + assert.equal(amount, expected_amount); + done(); + }) + .catch(() => { console.log('error'), done() }); + }); + }); +});