WIP update to use new kredits library

Using the new kredits library to load contract data and create
proposals.
currently the kredits lib is only copied - which needs to be removed
asap.
This commit is contained in:
bumi 2018-04-18 16:27:22 +02:00
parent 821543056b
commit 050e8ce79f
12 changed files with 555 additions and 259 deletions

338
index.js
View File

@ -12,54 +12,27 @@
// IPFS_API_PORT: Port number (default '5001')
// IPFS_API_PROTOCOL: Protocol, e.g. 'http' or 'https' (default 'http')
//
//
const fs = require('fs');
const util = require('util');
const fetch = require('node-fetch');
const kreditsContracts = require('kredits-contracts');
const ProviderEngine = require('web3-provider-engine');
const Wallet = require('ethereumjs-wallet');
const WalletSubprovider = require('ethereumjs-wallet/provider-engine');
const Web3Subprovider = require('web3-provider-engine/subproviders/web3.js');
const Web3 = require('web3');
const ipfsAPI = require('ipfs-api');
const schemas = require('kosmos-schemas');
const tv4 = require('tv4');
(function() {
const ethers = require('ethers');
const kredits = require('./kredits');
(async function() {
"use strict";
//
// Instantiate ethereum client and wallet
//
let engine = new ProviderEngine();
let walletPath = process.env.KREDITS_WALLET_PATH || './wallet.json';
let walletJson = fs.readFileSync(walletPath);
let wallet = Wallet.fromV3(JSON.parse(walletJson), process.env.KREDITS_WALLET_PASSWORD);
let providerUrl = process.env.KREDITS_PROVIDER_URL || 'http://localhost:8545';
let hubotWalletAddress = '0x' + wallet.getAddress().toString('hex');
let networkId = parseInt(process.env.KREDITS_NETWORK_ID);
engine.addProvider(new WalletSubprovider(wallet, {}));
engine.addProvider(new Web3Subprovider(new Web3.providers.HttpProvider(providerUrl)));
// TODO only start engine if providerURL is accessible
engine.start();
let web3 = new Web3(engine);
web3.eth.defaultAccount = hubotWalletAddress;
//
// Instantiate contracts
//
let contractConfig = {};
if (process.env.KREDITS_CONTRACT_ADDRESS) {
contractConfig = { Kredits: { address: process.env.KREDITS_CONTRACT_ADDRESS }};
}
let contracts = kreditsContracts(web3, contractConfig);
let kredits = contracts['Kredits'];
//
// Instantiate IPFS API client
//
let ipfsConfig = {};
if (process.env.IPFS_API_HOST) {
ipfsConfig = {
@ -68,144 +41,88 @@ const tv4 = require('tv4');
protocol: process.env.IPFS_API_PROTOCOL
};
}
let ipfs = ipfsAPI(ipfsConfig);
const wallet = await ethers.Wallet.fromEncryptedWallet(walletJson, process.env.KREDITS_WALLET_PASSWORD);
const ethProvider = new ethers.providers.JsonRpcProvider(providerUrl, {chainId: networkId});
ethProvider.signer = wallet;
wallet.provider = ethProvider;
const kredits = await Kredits.setup(ethProvider, wallet, ipfsConfig);
const Contributor = kredits.Contributor;
const Operator = kredits.Operator;
module.exports = function(robot) {
robot.logger.info('[hubot-kredits] Wallet address: ' + hubotWalletAddress);
function messageRoom(message) {
robot.messageRoom(process.env.KREDITS_ROOM, message);
}
getBalance().then(balance => {
if (balance <= 0) {
robot.logger.info('[hubot-kredits] Wallet address: ' + wallet.address);
ethProvider.getBalance(wallet.address).then(balance => {
robot.logger.info('[hubot-kredits] Wallet balance: ' + balance.toString());
if (balance.toNumber() <= 0) {
messageRoom(`Yo gang, I\'m broke! Please drop me some ETH to ${hubotWalletAddress}. kthxbai.`);
}
});
function getBalance() {
return new Promise((resolve, reject) => {
web3.eth.getBalance(hubotWalletAddress, function (err, balance) {
if (err) {
robot.logger.error('[hubot-kredits] Error checking balance');
reject(err);
return;
robot.respond(/got ETH\?/i, res => {
ethProvider.getBalance(wallet.address).then((balance) => {
res.send(`my wallet contains ${ethers.utils.formatEther(balance)} ETH`);
});
});
robot.respond(/propose (\d*)\s?\S*\s?to (\S+)(?:\sfor (.*))?$"/i, res => {
let amount = res.match[1];
let githubUser = res.match[2];
let description = res.match[3];
let url = null;
createProposal(githubUser, amount, description, url).then((result) => {
messageRoom('Proposal created');
});
});
robot.respond(/list open proposals/i, res => {
Operator.all().then((proposals) => {
proposals.forEach((proposal) => {
if (!proposal.executed) {
Contributor.getById(proposal.contributorId).then((contributor) => {
messageRoom(`* ${proposal.amount} kredits to ${contributor.name} for ${proposal.description}`);
});
}
resolve(balance);
});
});
}
function getValueFromContract(contractMethod, ...args) {
return new Promise((resolve, reject) => {
kredits[contractMethod](...args, (err, data) => {
if (err) { reject(err); }
resolve(data);
});
});
}
function loadProfileFromIPFS(contributor) {
let promise = new Promise((resolve, reject) => {
return ipfs.cat(contributor.ipfsHash, { buffer: true }).then(res => {
let content = res.toString();
let profile = JSON.parse(content);
contributor.name = profile.name;
contributor.kind = profile.kind;
let accounts = profile.accounts;
let github = accounts.find(a => a.site === 'github.com');
let wiki = accounts.find(a => a.site === 'wiki.kosmos.org');
if (github) {
contributor.github_username = github.username;
contributor.github_uid = github.uid;
}
if (wiki) {
contributor.wiki_username = wiki.username;
}
resolve(contributor);
}).catch((err) => {
console.log(err);
reject(err);
});
});
return promise;
}
function getContributorData(i) {
let promise = new Promise((resolve, reject) => {
getValueFromContract('contributorAddresses', i).then(address => {
// robot.logger.debug('address', address);
getValueFromContract('contributors', address).then(person => {
// robot.logger.debug('person', person);
let c = {
address: address,
name: person[1],
id: person[0],
ipfsHash: person[2]
};
if (c.ipfsHash) {
// robot.logger.debug('[kredits] loading contributor profile loaded for', c.name, c.ipfsHash, '...');
loadProfileFromIPFS(c).then(contributor => {
// robot.logger.debug('[kredits] contributor profile loaded for', c.name);
resolve(contributor);
}).catch(() => console.log('[kredits] error fetching contributor info from IPFS for '+c.name));
} else {
resolve(c);
}
});
}).catch(err => reject(err));
});
return promise;
}
function getContributors() {
return getValueFromContract('contributorsCount').then(contributorsCount => {
let contributors = [];
for(var i = 0; i < contributorsCount.toNumber(); i++) {
contributors.push(getContributorData(i));
}
return Promise.all(contributors);
});
}
});
function getContributorByGithubUser(username) {
let promise = new Promise((resolve, reject) => {
getContributors().then(contributors => {
let contrib = contributors.find(c => {
return c.github_username === username;
});
if (contrib) {
resolve(contrib);
} else {
reject();
}
return Contributor.all().then(contributors => {
let contrib = contributors.find(c => {
return c.github_username === username;
});
if (!contrib) {
throw new Errro(`No contributor found for ${username}`);A
} else {
return contrib;
}
});
return promise;
}
function getContributorByAddress(address) {
let promise = new Promise((resolve, reject) => {
getContributors().then(contributors => {
let contrib = contributors.find(c => {
return c.address === address;
function createProposal(githubUser, amount, description, url, details) {
return getContributorByGithubUser(githubUser).then((contributor) => {
robot.logger.debug(`[kredits] Creating proposal to issue ${amount}₭S to ${githubUser} for ${url}...`);
let contributionAttr = {
contributorIpfsHash: contributor.ipfsHash,
url,
description,
details,
kind: 'dev'
};
return Operator.add(contributionAttr).then((result) => {
robot.logger.debug('[kredits] proposal created:', util.inspect(result));
});
if (contrib) {
resolve(contrib);
} else {
reject();
}
}).catch(() => {
messageRoom(`I wanted to propose giving kredits to ${githubUser} for ${url}, but I can't find their contact data. Please add them as a contributor: https://kredits.kosmos.org`);
});
});
return promise;
}
function messageRoom(message) {
robot.messageRoom(process.env.KREDITS_ROOM, message);
}
function amountFromIssueLabels(issue) {
@ -231,55 +148,6 @@ const tv4 = require('tv4');
return amount;
}
function createContributionDocument(contributor, url, description, details) {
let contribution = {
"@context": "https://schema.kosmos.org",
"@type": "Contribution",
contributor: {
ipfs: contributor.ipfsHash
},
kind: 'dev',
url: url,
description: description,
details: details
};
if (! tv4.validate(contribution, schemas["contribution"])) {
robot.logger.error('[kredits] invalid contribution data: ', util.inspect(contribution));
return Promise.reject('invalid contribution data');
}
// robot.logger.debug('[kredits] creating IPFS document for contribution:', contribution.description);
return ipfs.add(new ipfs.Buffer(JSON.stringify(contribution)))
.then(res => {
// robot.logger.debug('[kredits] created IPFS document', res[0].hash);
return res[0].hash;
}).catch(err => robot.logger.error('[kredits] couldn\'t create IPFS document', err));
}
function createProposal(recipient, amount, url, description, details) {
robot.logger.debug(`[kredits] Creating proposal to issue ${amount}₭S to ${recipient} for ${url}...`);
return new Promise((resolve, reject) => {
// Get contributor details for GitHub user
getContributorByGithubUser(recipient).then(c => {
// Create document containing contribution data on IPFS
createContributionDocument(c, url, description, details).then(ipfsHash => {
// Create proposal on ethereum blockchain
kredits.addProposal(c.address, amount, url, ipfsHash, (e, d) => {
if (e) { reject(e); return; }
robot.logger.debug('[kredits] proposal created:', util.inspect(d));
resolve();
});
});
}, () => {
messageRoom(`I wanted to propose giving kredits to ${recipient} for ${url}, but I can't find their contact data. Please add them as a contributor: https://kredits.kosmos.org`);
resolve();
});
});
}
function handleGitHubIssueClosed(data) {
return new Promise((resolve/*, reject*/) => {
// fs.writeFileSync('tmp/github-issue.json', JSON.stringify(data, null, 4));
@ -301,7 +169,7 @@ const tv4 = require('tv4');
let description = `${repoName}: ${issue.title}`;
recipients.forEach(recipient => {
createProposal(recipient, amount, web_url, description, issue)
createProposal(recipient, amount, description, web_url, issue)
.catch(err => robot.logger.error(err));
});
@ -339,29 +207,18 @@ const tv4 = require('tv4');
let repoName = pull_request.base.repo.full_name;
let description = `${repoName}: ${pull_request.title}`;
let proposalPromisses = [];
recipients.forEach(recipient => {
createProposal(recipient, amount, web_url, description, pull_request)
.catch(err => robot.logger.error(err));
proposalPromisses.push(
createProposal(recipient, amount, description, web_url, pull_request)
.catch(err => robot.logger.error(err))
);
});
resolve();
return Promise.all(proposalPromisses);
});
});
}
robot.respond(/(got ETH)|(got gas)\?/i, res => {
getBalance().then(balance => {
if (balance <= 0) {
res.send(`HALP, I\'m totally broke! Not a single wei in my pocket.`);
}
else if (balance >= 1e+17) {
res.send(`my wallet contains ${web3.fromWei(balance, 'ether')} ETH`);
}
else {
res.send(`I\'m almost broke! Only have ${web3.fromWei(balance, 'ether')} ETH left in my pocket. :(`);
}
});
});
robot.router.post('/incoming/kredits/github/'+process.env.KREDITS_WEBHOOK_TOKEN, (req, res) => {
let evt = req.header('X-GitHub-Event');
@ -383,45 +240,22 @@ const tv4 = require('tv4');
});
function watchContractEvents() {
web3.eth.getBlockNumber((err, blockNumber) => {
if (err) {
robot.logger.error('[kredits] couldn\t get current block number');
return false;
}
ethProvider.getBlockNumber().then((blockNumber) => {
// current block is the last mined one, thus we check from the next
// mined one onwards to prevent getting previous events
let nextBlock = blockNumber + 1;
robot.logger.debug(`[kredits] watching events from block ${nextBlock} onward`);
ethProvider.resetEventsBlock(nextBlock);
kredits.allEvents({fromBlock: nextBlock, toBlock: 'latest'}, (error, data) => {
robot.logger.debug('[kredits] received contract event', data.event);
if (data.blockNumber < nextBlock) {
// I don't know why, but the filter doesn't work as intended
robot.logger.debug('[kredits] dismissing old event from block', data.blockNumber);
return false;
}
switch (data.event) {
case 'ProposalCreated':
handleProposalCreated(data);
break;
// case 'ProposalExecuted':
// handleProposalExecuted(data);
// break;
// case 'ProposalVoted':
// handleProposalVoted(data);
// break;
// case 'Transfer':
// handleTransfer(data);
// break;
}
});
Operator.on('ProposalCreated', handleProposalCreated);
});
}
function handleProposalCreated(data) {
getContributorByAddress(data.args.recipient).then((contributor) => {
messageRoom(`Let's give ${contributor.name} some kredits for ${data.args.url}: https://kredits.kosmos.org`);
function handleProposalCreated(proposalId, creatorAccount, contributorId, amount) {
Contributor.getById(contributorId).then((contributor) => {
Operator.getBy(proposalId).then((proposal) => {
messageRoom(`Let's give ${contributor.name} some kredits for ${proposal.url} (${proposal.description}): https://kredits.kosmos.org`);
});
});
}

17
kredits/contracts/base.js Normal file
View File

@ -0,0 +1,17 @@
export default class Base {
constructor(contract) {
this.contract = contract;
}
get functions() {
return this.contract.functions;
}
on(type, callback) {
let eventMethod = `on${type.toLowerCase()}`;
// Don't use this.contract.events here. Seems to be a bug in ethers.js
this.contract[eventMethod] = callback;
return this;
}
}

View File

@ -0,0 +1,61 @@
import ethers from 'ethers';
import RSVP from 'rsvp';
import Kredits from '../kredits';
import ContributorSerializer from '../serializers/contributor';
import Base from './base';
export default class Contributor extends Base {
all() {
return this.functions.contributorsCount()
.then((count) => {
count = count.toNumber();
let contributors = [];
for (let id = 1; id <= count; id++) {
contributors.push(this.getById(id));
}
return RSVP.all(contributors);
});
}
getById(id) {
id = ethers.utils.bigNumberify(id);
return this.functions.getContributorById(id)
.then((data) => {
// TODO: remove as soon as the contract provides the id
data.id = id;
// TODO: rename address to account
data.address = data.account;
return data;
})
// Fetch IPFS data if available
.then((data) => {
return Kredits.ipfs.catAndMerge(data, ContributorSerializer.deserialize);
});
}
add(contributorAttr) {
let json = ContributorSerializer.serialize(contributorAttr);
// TODO: validate against schema
return Kredits.ipfs
.add(json)
.then((ipfsHashAttr) => {
let contributor = [
contributorAttr.address,
ipfsHashAttr.ipfsHash,
ipfsHashAttr.hashFunction,
ipfsHashAttr.hashSize,
contributorAttr.isCore,
];
console.log('[kredits] addContributor', ...contributor);
return this.functions.addContributor(...contributor);
});
}
}

View File

@ -0,0 +1,9 @@
import Contributor from './contributor';
import Operator from './operator';
import Token from './token';
export default {
Contributors: Contributor,
Operator,
Token
};

View File

@ -0,0 +1,61 @@
import ethers from 'ethers';
import RSVP from 'rsvp';
import Kredits from '../kredits';
import ContributionSerializer from '../serializers/contribution';
import Base from './base';
export default class Operator extends Base {
all() {
return this.functions.proposalsCount()
.then((count) => {
count = count.toNumber();
let proposals = [];
for (let id = 1; id <= count; id++) {
proposals.push(this.getById(id));
}
return RSVP.all(proposals);
});
}
getById(id) {
id = ethers.utils.bigNumberify(id);
return this.functions.proposals(id)
.then((data) => {
// TODO: remove as soon as the contract provides the id
data.id = id;
// TODO: rename creatorAddress to creator
data.creatorAddress = data.creator;
return data;
})
// Fetch IPFS data if available
.then((data) => {
return Kredits.ipfs.catAndMerge(data, ContributionSerializer.deserialize);
});
}
addProposal(proposalAttr) {
let json = ContributionSerializer.serialize(proposalAttr);
// TODO: validate against schema
return Kredits.ipfs
.add(json)
.then((ipfsHashAttr) => {
let proposal = [
proposalAttr.contributorId,
proposalAttr.amount,
ipfsHashAttr.ipfsHash,
ipfsHashAttr.hashFunction,
ipfsHashAttr.hashSize,
];
console.log('[kredits] addProposal', ...proposal);
return this.functions.addProposal(...proposal);
});
}
}

View File

@ -0,0 +1,4 @@
import Base from './base';
export default class Token extends Base {
}

2
kredits/index.js Normal file
View File

@ -0,0 +1,2 @@
import kredits from './kredits';
export default kredits;

102
kredits/kredits.js Normal file
View File

@ -0,0 +1,102 @@
import ethers from 'npm:ethers';
import RSVP from 'rsvp';
import abis from 'contracts/abis';
import addresses from 'contracts/addresses';
import contracts from './contracts';
import IPFS from './utils/ipfs';
// Helpers
function capitalize(word) {
let [first, ...rest] = word;
return `${first.toUpperCase()}${rest.join('')}`;
}
export default class Kredits {
constructor(provider, signer, addresses) {
this.provider = provider;
this.signer = signer;
// Initialize our registry contract
this.addresses = addresses;
this.contracts = {};
}
static setup(provider, signer, ipfsConfig) {
this.ipfsConfig = ipfsConfig;
this.ipfs = new IPFS(ipfsConfig);
return this.ipfs._ipfsAPI.id().catch((error) => {
throw new Error(`IPFS node not available; config: ${JSON.stringify(ipfsConfig)} - ${error.message}`);
}).then(() => {
let registryContract = this.initRegistryContract(provider);
let addresses = Object.keys(contracts).reduce((mem, name) => {
let contractName = capitalize(name);
mem[contractName] = registryContract.functions.getProxyFor(contractName).catch((error) => {
throw new Error(`Failed to get address for ${contractName} from registry at ${registryContract.address}
- correct registry? does it have version entry? - ${error.message}`
);
});
return mem;
}, {});
return RSVP.hash(addresses)
.then((addresses) => {
return new Kredits(provider, signer, addresses);
});
});
}
static initRegistryContract(provider) {
let address = addresses['Registry'][provider.chainId];
if (!address) {
throw new Error(`Registry address not found; invalid network?
requested network: ${provider.chainId}
supported networks: ${Object.keys(addresses['Registry'])}
`);
}
provider.getCode(address).then((code) => {
// not sure if we always get the same return value of the code is not available
// that's why checking if it is < 5 long
if (code === '0x00' || code.length < 5) {
throw new Error(`Registry not found at ${address} on network ${provider.chainId}`);
}
});
let abi = abis['Registry'];
console.log('Initialize registry contract:', address, abi, provider);
return new ethers.Contract(address, abi, provider);
}
get Contributor() {
// TODO: rename to contributor
return this.contractFor('contributors');
}
get Operator() {
return this.contractFor('operator');
}
get Token() {
return this.contractFor('token');
}
// Should be private
contractFor(name) {
if (this.contracts[name]) {
return this.contracts[name];
}
let contractName = capitalize(name);
let address = this.addresses[contractName];
if (!address || !abis[contractName]) {
throw new Error(`Address or ABI not found for ${contractName}`);
}
let contract = new ethers.Contract(address, abis[contractName], this.signer);
this.contracts[name] = new contracts[contractName](contract);
return this.contracts[name];
}
}

View File

@ -0,0 +1,64 @@
/**
* Handle serialization for JSON-LD object of the contribution, according to
* https://github.com/67P/kosmos-schemas/blob/master/schemas/contribution.json
*
* @class
* @public
*/
export default class Contributor {
/**
* Deserialize JSON to object
*
* @method
* @public
*/
static deserialize(serialized) {
let {
kind,
description,
details,
url,
} = JSON.parse(serialized.toString('utf8'));
return {
kind,
description,
details,
url,
ipfsData: serialized,
};
}
/**
* Serialize object to JSON
*
* @method
* @public
*/
static serialize(deserialized) {
let {
contributorIpfsHash,
kind,
description,
url,
} = deserialized;
let data = {
"@context": "https://schema.kosmos.org",
"@type": "Contribution",
"contributor": {
"ipfs": contributorIpfsHash
},
kind,
description,
"details": {}
};
if (url) {
data["url"] = url;
}
// Write it pretty to ipfs
return JSON.stringify(data, null, 2);
}
}

View File

@ -0,0 +1,93 @@
/**
* Handle serialization for JSON-LD object of the contributor, according to
* https://github.com/67P/kosmos-schemas/blob/master/schemas/contributor.json
*
* @class
* @public
*/
export default class Contributor {
/**
* Deserialize JSON to object
*
* @method
* @public
*/
static deserialize(serialized) {
let {
name,
kind,
url,
accounts,
} = JSON.parse(serialized.toString('utf8'));
let github_username, github_uid, wiki_username;
let github = accounts.find((a) => a.site === 'github.com');
let wiki = accounts.find((a) => a.site === 'wiki.kosmos.org');
if (github) {
({ username: github_username, uid: github_uid} = github);
}
if (wiki) {
({ username: wiki_username } = wiki);
}
return {
name,
kind,
url,
github_uid,
github_username,
wiki_username,
ipfsData: serialized,
};
}
/**
* Serialize object to JSON
*
* @method
* @public
*/
static serialize(deserialized) {
let {
name,
kind,
url,
github_uid,
github_username,
wiki_username,
} = deserialized;
let data = {
"@context": "https://schema.kosmos.org",
"@type": "Contributor",
kind,
name,
"accounts": []
};
if (url) {
data["url"] = url;
}
if (github_uid) {
data.accounts.push({
"site": "github.com",
"uid": github_uid,
"username": github_username,
"url": `https://github.com/${github_username}`
});
}
if (wiki_username) {
data.accounts.push({
"site": "wiki.kosmos.org",
"username": wiki_username,
"url": `https://wiki.kosmos.org/User:${wiki_username}`
});
}
// Write it pretty to ipfs
return JSON.stringify(data, null, 2);
}
}

54
kredits/utils/ipfs.js Normal file
View File

@ -0,0 +1,54 @@
import ipfsAPI from 'ipfs-api';
import multihashes from 'multihashes';
export default class IPFS {
constructor(config) {
this._ipfsAPI = ipfsAPI(config);
this._config = config;
}
catAndMerge(data, deserialize) {
// if no hash details are found simply return the data; nothing to merge
if (!data.hashSize || data.hashSize === 0) {
return data;
}
return this.cat(data)
.then(deserialize)
.then((attributes) => {
return Object.assign({}, data, attributes);
});
}
add(data) {
return this._ipfsAPI
.add(new this._ipfsAPI.Buffer(data))
.then((res) => {
return this.decodeHash(res[0].hash);
});
}
cat(hashData) {
let ipfsHash = hashData; // default - if it is a string
if (hashData.hasOwnProperty('hashSize')) {
ipfsHash = this.encodeHash(hashData);
}
return this._ipfsAPI.cat(ipfsHash);
}
decodeHash(ipfsHash) {
let multihash = multihashes.decode(multihashes.fromB58String(ipfsHash));
return {
ipfsHash: '0x' + multihashes.toHexString(multihash.digest),
hashSize: multihash.length,
hashFunction: multihash.code,
sourceHash: ipfsHash
};
}
encodeHash(hashData) {
let digest = this._ipfsAPI.Buffer.from(hashData.ipfsHash.slice(2), 'hex');
return multihashes.encode(digest, hashData.hashFunction, hashData.hashSize);
}
}

View File

@ -10,15 +10,10 @@
"kredits-wallet-create": "scripts/create-wallet.js"
},
"dependencies": {
"ethereumjs-wallet": "mvayngrib/ethereumjs-wallet",
"ipfs-api": "^14.0.3",
"kosmos-schemas": "^1.1.2",
"kredits-contracts": "2.0.0",
"node-fetch": "^1.6.3",
"prompt": "^1.0.0",
"tv4": "^1.3.0",
"web3": "^0.18.4",
"web3-provider-engine": "^12.0.3"
"node-fetch": "^2.1.1",
"prompt": "^1.0.0"
},
"repository": {
"type": "git",