commit
a75ec287f0
|
@ -1,2 +1,7 @@
|
||||||
node_modules
|
node_modules
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
|
|
||||||
|
.ssh
|
||||||
|
*.log
|
||||||
|
|
||||||
|
package-lock.json
|
|
@ -3,6 +3,5 @@ FROM node:7-onbuild
|
||||||
ENV PORT 8080
|
ENV PORT 8080
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
ENV NAME autobounty
|
# Set this variable to the name of your production config file (without the extension)
|
||||||
ENV STANDARD_BOUNTY 0.001
|
ENV NODE_ENV development
|
||||||
ENV WEBHOOK_SECRET test
|
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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'
|
||||||
|
}
|
|
@ -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);
|
||||||
|
|
163
index.js
163
index.js
|
@ -1,90 +1,107 @@
|
||||||
/*
|
/*
|
||||||
* Bot that receives a POST request (from a GitHub issue comment webhook)
|
* Bot that receives a POST request (from a GitHub issue comment webhook)
|
||||||
* and in case it's a comment that has "@autobounty <decimal> <currency>"
|
* and in case it's a comment that has "@autobounty <decimal> <currency>"
|
||||||
* awards that bounty to the address posted earlier in the thread (by the
|
* awards that bounty to the address posted earlier in the thread (by the
|
||||||
* commiteth bot).
|
* commiteth bot).
|
||||||
* TODO tests
|
* TODO tests
|
||||||
* REVIEW parsing, non-persisting storage of addresses, hardcoded string length.
|
* REVIEW parsing, non-persisting storage of addresses, hardcoded string length.
|
||||||
* Depends on commiteth version as of 2017-06-10.
|
* Depends on commiteth version as of 2017-06-10.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const SignerProvider = require('ethjs-provider-signer');
|
const config = require('./config');
|
||||||
const sign = require('ethjs-signer').sign;
|
const bot = require('./bot');
|
||||||
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);
|
|
||||||
|
|
||||||
var express = require('express'),
|
var express = require('express'),
|
||||||
cors = require('cors'),
|
cors = require('cors'),
|
||||||
|
helmet = require('helmet'),
|
||||||
app = express(),
|
app = express(),
|
||||||
bodyParser = require('body-parser'),
|
bodyParser = require('body-parser'),
|
||||||
jsonParser = bodyParser.json();
|
jsonParser = bodyParser.json();
|
||||||
|
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
|
app.use(helmet());
|
||||||
|
|
||||||
// Store issue ids and their bounty addresses
|
// Receive a POST request at the url specified by an env. var.
|
||||||
var issueData = {};
|
app.post(`${config.urlEndpoint}`, jsonParser, function (req, res, next) {
|
||||||
|
if (!req.body || !req.body.action) {
|
||||||
// Receive a POST request at the address specified by an env. var.
|
return res.sendStatus(400);
|
||||||
app.post(`/comment/${webhook_secret}`, jsonParser, function(req, res, next){
|
} else if (!bot.needsFunding(req)) {
|
||||||
if (!req.body)
|
return res.sendStatus(204);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
setTimeout(() => {
|
||||||
else {
|
processRequest(req)
|
||||||
var postNameWords = commentBody.substring(namePosition + 1 + name.length + 1).trim().split(' ');
|
.then(() => {
|
||||||
var amount = 0;
|
bot.info('issue well funded: ' + res.body.issue.url);
|
||||||
if (postNameWords.length > 0) {
|
})
|
||||||
if(postNameWords[0] == "standard") {
|
.catch((err) => {
|
||||||
amount = process.env.STANDARD_BOUNTY;
|
bot.error('Error funding issue: ' + req.body.issue.url);
|
||||||
}
|
bot.error('error: ' + err);
|
||||||
else {
|
bot.error('dump: ' + req);
|
||||||
amount = parseFloat(postNameWords[0]);
|
});
|
||||||
}
|
}, config.delayInMiliSeconds);
|
||||||
}
|
|
||||||
console.log("Trying to give " + amount + " ETH to " + issueData[issueId].toAddress + " for issue " + issueId);
|
|
||||||
issueData[issueId].amount = amount;
|
|
||||||
|
|
||||||
// Conduct the transaction
|
return res.sendStatus(200);
|
||||||
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 })
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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
|
const port = process.env.PORT || 8181
|
||||||
app.listen(port, function(){
|
app.listen(port, function () {
|
||||||
console.log('Autobounty listening on port', port);
|
bot.log('Autobounty listening on port', port);
|
||||||
});
|
});
|
||||||
|
|
10
package.json
10
package.json
|
@ -5,17 +5,23 @@
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node index.js",
|
"start": "node index.js",
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "./node_modules/mocha/bin/mocha"
|
||||||
},
|
},
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"body-parser": "^1.17.2",
|
"body-parser": "^1.17.2",
|
||||||
|
"chai": "^4.1.2",
|
||||||
"cors": "^2.8.1",
|
"cors": "^2.8.1",
|
||||||
|
"eslint": "^4.15.0",
|
||||||
"ethjs-provider-signer": "^0.1.4",
|
"ethjs-provider-signer": "^0.1.4",
|
||||||
"ethjs-query": "^0.2.4",
|
"ethjs-query": "^0.2.4",
|
||||||
"ethjs-signer": "^0.1.1",
|
"ethjs-signer": "^0.1.1",
|
||||||
"express": "^4.15.2",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
91
readme.md
91
readme.md
|
@ -13,7 +13,90 @@ All issues tagged with **[bounty](https://github.com/status-im/status-react/issu
|
||||||
|
|
||||||
#### The process
|
#### The process
|
||||||
|
|
||||||
- An **[issue](https://github.com/status-im/status-react/issues)** is created at the repo
|
1. 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)**
|
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}**
|
||||||
- [Status OpenBounty](https://openbounty.status.im/) bot adds a bounty to the issue and it is displayed in the issue's comments
|
3. [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.
|
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.
|
||||||
|
|
|
@ -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 &
|
||||||
|
|
|
@ -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() });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in New Issue