Initial commit

This commit is contained in:
Richard Ramos 2019-11-13 11:03:30 -04:00
parent a34bbd37ba
commit 12e9be66a9
20 changed files with 3522 additions and 0 deletions

14
.gitignore vendored Normal file
View File

@ -0,0 +1,14 @@
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
.idea
npm-debug.log*
yarn-debug.log*
yarn-error.log*
node_modules
.secret.json

154
api/index.js Normal file
View File

@ -0,0 +1,154 @@
const Events = require("events");
const stripHexPrefix = require('strip-hex-prefix');
const { isSignatureValid } = require("./utils");
const express = require("express");
const { check, validationResult } = require("express-validator");
const cors = require("cors");
const helmet = require("helmet");
const rateLimit = require("../middleware/rate-limit");
const config = require("../config");
const Database = require("../database");
const events = new Events();
const Subscriber = require("../models/subscriber");
const subscriberStatus = require("../models/subscriberStatus");
const Mailer = require('../mail/sendgrid');
const dappConfig = require('../config/dapps');
const mailer = new Mailer(config);
const db = new Database(events, config);
db.init();
const hexValidator = value => {
const regex = /^[0-9A-Fa-f]*$/g;
if(regex.test(stripHexPrefix(value))){
return true;
}
throw new Error('Invalid hex string');
};
events.on("db:connected", () => {
const app = express();
app.use(rateLimit());
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(helmet.expectCt({ enforce: true, maxAge: 60 }));
app.use(helmet());
app.post(
"/:dappId/subscribe",
[
check("signature")
.exists()
.isLength({min: 132, max: 132})
.custom(hexValidator),
check("address")
.exists()
.isLength({min: 42, max: 42})
.custom(hexValidator),
check("email")
.exists()
.isEmail(),
check("dappId").exists()
],
async (req, res) => {
const {
params: { dappId },
body: { address, email, signature }
} = req;
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(404).json({ errors: errors.array() });
}
// TODO: dappId should be a token
if(!dappConfig[dappId]){
return res.status(404).send("Invalid dapp");
}
if (!isSignatureValid(address, email, signature)) {
return res.status(404).send("Invalid signature");
}
// TODO: rate limit the number of times an user can subscribe
// TODO: handle subscriptions to particular events
try {
const subscriber = await Subscriber.findOne({
dappId,
address
});
if (!subscriber) {
await Subscriber.create({
dappId,
email,
address,
status: subscriberStatus.CONFIRMED // TODO: remove this once email confirmation is done
});
mailer.send(dappId, 'sign-up', { email });
}
} catch (err) {
// TODO: add global error handler
return res.status(400).send(err.message);
}
return res.status(200).send("OK");
}
);
app.post("/:dapp/unsubscribe", async (req, res) => {
const {
params: { dappId },
body: { address, email, signature }
} = req;
// TODO: validate dappId
if (dappId !== "status-teller-network") {
return res.status(404).send("Invalid dapp");
}
if (!isSignatureValid(address, email, signature)) {
return res.status(404).send("Invalid signature");
}
// TODO: handle unsubscribe to particular events
try {
await Subscriber.deleteOne({
dapp: req.params.dappId,
address: req.body.address
});
} catch (err) {
// TODO: add global error handler
return res.status(400).send(err.message);
}
return res.status(200).send("OK");
});
app.get("/confirm/:token", (req, res) => {});
app.get("/", (req, res) => res.status(200).json({ status: isConnected() }));
app.listen(config.PORT, () =>
console.log(`App listening on port ${config.PORT}!`)
);
});
// MVP
// ====
// Folder with DApp information, event ABI, and email templates
// TODO: register DAPP and content
// TODO: handle errors sending email

14
api/utils.js Normal file
View File

@ -0,0 +1,14 @@
const web3EthAccounts = require("web3-eth-accounts");
const web3Utils = require("web3-utils");
const isSignatureValid = (address, message, signature) => {
const accounts = new web3EthAccounts();
address = web3Utils.toChecksumAddress(address);
let recoverAddress = accounts.recover(message, signature);
recoverAddress = web3Utils.toChecksumAddress(recoverAddress);
return address === recoverAddress;
};
module.exports = {
isSignatureValid
};

3
config/dapps.js Normal file
View File

@ -0,0 +1,3 @@
module.exports = {
"teller-network": { }
};

19
config/index.js Normal file
View File

@ -0,0 +1,19 @@
const secret = require('./.secret.json');
const env = process.env
/* some defaults cannot be known in advance */
const config = {
/* Hosting */
PORT : env.PORT || 4000,
RATE_LIMIT_TIME: env.RATE_LIMIT_TIME || 15,
RATE_LIMIT_MAX_REQ: env.RATE_LIMIT_MAX_REQ || 1,
/* Database */
DB_CONNECTION: env.DB_CONNECTION || secret.DB_CONNECTION || null,
/* Blockchain */
BLOCKCHAIN_CONNECTION_POINT: env.BLOCKCHAIN_CONNECTION_POINT || secret.BLOCKCHAIN_CONNECTION_POINT || "http://localhost:8545",
/* Email */
SENDGRID_API_KEY: secret.SENDGRID_API_KEY
}
module.exports = config;

View File

@ -0,0 +1,36 @@
module.exports = {
from: {
email: "noreply@teller.exchange",
name: "Teller Network"
},
templates: {
"sign-up": {
subject: "Signup email",
html: "sign-up.html",
text: "sign-up.txt"
},
contracts: {
"0xEE301C6A57e2fBf593F558C1aE52B20485101fC2": {
events: {
"escrow-creation-seller": {
ABI: {
name: "Created",
type: "event",
inputs: [
{ indexed: true, name: "offerId", type: "uint256" },
{ indexed: true, name: "seller", type: "address" },
{ indexed: true, name: "buyer", type: "address" },
{ indexed: false, name: "escrowId", type: "uint256" }
]
},
index: "seller",
template: {
html: "escrow-creation-seller.html",
text: "escrow-creation-seller.txt"
}
}
}
}
}
}
};

View File

@ -0,0 +1 @@
<p>A new escrow was created!</p>

View File

@ -0,0 +1 @@
A new escrow was created!

View File

@ -0,0 +1 @@
<p>Signup email</p>

View File

@ -0,0 +1 @@
Signup email

33
database.js Normal file
View File

@ -0,0 +1,33 @@
const mongoose = require('mongoose');
const config = require('./config');
class Database {
constructor(events, config){
this.events = events;
this.config = config;
this.db = null;
this.client = null;
}
init(){
if (config.DB_CONNECTION == undefined) {
throw Error('Unable to find MongoDB URI in DB_CONNECTION env variable!')
}
mongoose.Promise = global.Promise;
mongoose.connect(config.DB_CONNECTION, { useNewUrlParser: true, useUnifiedTopology: true }, err => {
if(err) {
throw err;
}
}).then(() => {
this.db = mongoose;
console.log("Connected successfully to db");
this.events.emit('db:connected', this.db);
});
}
}
module.exports = Database;

32
mail/sendgrid.js Normal file
View File

@ -0,0 +1,32 @@
const sgMail = require("@sendgrid/mail");
const path = require("path");
const fs = require('fs');
class SendGridMailer {
constructor(config) {
sgMail.setApiKey(config.SENDGRID_API_KEY);
}
send(dappId, template, data) {
// TODO: extract this logic. Mailer only needs to worry about sending emails
const templatePath = path.join("dapps", dappId);
const config = require(path.join(path.join('../', templatePath, 'config.js')));
const t = config.templates[template];
// TODO: do not read these files constantly. Keep it on a cache or something. Also, don't use Sync.
const text = fs.readFileSync(path.join(templatePath, t.text)).toString();
const html = fs.readFileSync(path.join(templatePath, t.html)).toString();
const msg = {
to: data.email,
from: config.from,
subject: t.subject,
text,
html
};
sgMail.send(msg);
}
}
module.exports = SendGridMailer;

22
middleware/rate-limit.js Normal file
View File

@ -0,0 +1,22 @@
const rateLimit = require("express-rate-limit");
const config = require("./../config");
class RateLimitMiddleware {
static setup() {
const windowMs = config.RATE_LIMIT_TIME;
const maxReq = config.RATE_LIMIT_MAX_REQ;
let limiter = rateLimit({
windowMs: windowMs,
max: maxReq,
handler: function(req, res) {
console.warn(this.message);
res.status(this.statusCode).send({ error: this.message });
},
message: `Rate limit was reached, you are able to do ${maxReq} requests per ${windowMs} milliseconds`
});
return limiter;
}
}
module.exports = RateLimitMiddleware.setup;

50
models/subscriber.js Normal file
View File

@ -0,0 +1,50 @@
const mongoose = require("mongoose");
const subscriberStatus = require('./subscriberStatus');
const Schema = mongoose.Schema;
const validator = require("validator");
const SubscriberSchema = new Schema({
id: Schema.Types.ObjectId,
dappId: {
type: String,
required: true,
validate: {
validator: function(value) {
// TODO: Validate dapp code
return true;
},
message: props => `${props.value} is not a valid dapp token`
}
},
address: {
type: String,
required: true,
validate: {
validator: function(value) {
// TODO: validate address
return true;
},
message: props => `${props.value} is not a valid address`
}
},
email: {
type: String,
required: true,
validate: {
validator: function(value) {
return validator.isEmail(value);
},
message: props => `${props.value} is not a valid email`
}
},
status: {
type: String,
default: subscriberStatus.UNCONFIRMED,
enum: subscriberStatus.ALL
}
});
module.exports = mongoose.model("Subscribers", SubscriberSchema);

View File

@ -0,0 +1,5 @@
module.exports = {
UNCONFIRMED: "UNCONFIRMED",
CONFIRMED: "CONFIRMED",
ALL: ["UNCONFIRMED", "CONFIRMED"]
};

25
package.json Normal file
View File

@ -0,0 +1,25 @@
{
"name": "contract-event-notifier",
"version": "0.0.1",
"main": "index.js",
"license": "MIT",
"scripts": {
"api": "node api",
"watch": "node watcher"
},
"dependencies": {
"@sendgrid/mail": "^6.4.0",
"body-parser": "^1.19.0",
"config": "^3.2.4",
"cors": "^2.8.5",
"dotenv": "^8.2.0",
"express": "^4.17.1",
"express-rate-limit": "^5.0.0",
"express-validator": "^6.2.0",
"helmet": "^3.21.2",
"mongoose": "5.7.5",
"strip-hex-prefix": "^1.0.0",
"validator": "11.0.0",
"web3": "^1.2.2"
}
}

9
tests/Escrow.sol Normal file
View File

@ -0,0 +1,9 @@
pragma solidity >=0.5.0 <0.6.0;
contract Escrow {
event Created(uint indexed offerId, address indexed seller, address indexed buyer, uint escrowId);
function create() public {
emit Created(block.number, msg.sender, msg.sender, block.number);
}
}

93
watcher/ethereum.js Normal file
View File

@ -0,0 +1,93 @@
const Web3 = require("web3");
class Ethereum {
constructor(events, config) {
this.events = events;
this.config = config;
}
init() {
this.web3 = new Web3(this.config.BLOCKCHAIN_CONNECTION_POINT);
this.web3.eth.net
.isListening()
.then(() => {
console.log("Connected successfully to web3");
this.events.emit("web3:connected");
})
.catch(error => {
throw error;
});
}
sleep(milliseconds) {
return new Promise(resolve => setTimeout(resolve, milliseconds));
}
async poll(fn) {
await fn();
await this.sleep(1 * 1000); // TODO: extract to config
await this.poll(fn);
}
async getEvents(fromBlock, toBlock) {
console.log("Queriying ", fromBlock, toBlock);
// TODO:
let events = await this.contract.getPastEvents("Created", {
filter: {},
fromBlock: fromBlock,
toBlock: toBlock
});
for (let event of events) {
// TODO: process each event. See if the return values matches the indexed address field, and send email
console.log(event);
}
}
async scan() {
// TODO: obtain this for all contracts / events in dapps/ folder
const abi = [
{
name: "Created",
type: "event",
inputs: [
{ indexed: true, name: "offerId", type: "uint256" },
{ indexed: true, name: "seller", type: "address" },
{ indexed: true, name: "buyer", type: "address" },
{ indexed: false, name: "escrowId", type: "uint256" }
]
}
];
this.contract = new this.web3.eth.Contract(
abi,
"0xEE301C6A57e2fBf593F558C1aE52B20485101fC2"
);
const MaxBlockRange = 30; // TODO: extract to config
let latestCachedBlock = 5434202; // TODO: obtain latest block written to database
let latestEthBlock = 0; // latest block in blockchain
await this.poll(async () => {
try {
latestEthBlock = (await this.web3.eth.getBlockNumber()) - 12; // 12 blocks of delay to avoid reorgs.
if (latestCachedBlock + MaxBlockRange > latestEthBlock) return; // Wait until more blocks are mined
latestEthBlock = Math.min(
latestEthBlock,
latestCachedBlock + MaxBlockRange
);
if (latestEthBlock > latestCachedBlock) {
await this.getEvents(latestCachedBlock, latestEthBlock);
latestCachedBlock = latestEthBlock + 1;
}
} catch (e) {
console.log(e.toString());
}
});
}
}
module.exports = Ethereum;

25
watcher/index.js Normal file
View File

@ -0,0 +1,25 @@
const Events = require("events");
const config = require("../config");
const Database = require("../database");
const Ethereum = require("./ethereum");
const events = new Events();
const Mailer = require('../mail/sendgrid');
const mailer = new Mailer(config);
const db = new Database(events, config);
const eth = new Ethereum(events, config);
db.init();
eth.init();
events.on("db:connected", () => {
events.on('web3:connected', () => {
eth.scan();
})
});
// TODO: handle errors sending email
// TODO: handle web3js disconnects

2984
yarn.lock Normal file

File diff suppressed because it is too large Load Diff