initial
This commit is contained in:
commit
53b02516ab
|
@ -0,0 +1,25 @@
|
||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
.idea
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"watch": ["src"],
|
||||||
|
"ext": ".ts,.js",
|
||||||
|
"ignore": [],
|
||||||
|
"exec": "ts-node ./src/index.ts"
|
||||||
|
}
|
|
@ -0,0 +1,48 @@
|
||||||
|
{
|
||||||
|
"name": "mango_alert_server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"license": "MIT",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "ts-node src/index.ts",
|
||||||
|
"start:dev": "nodemon"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/email-validator": "^1.0.6",
|
||||||
|
"@types/koa": "^2.13.1",
|
||||||
|
"@types/koa-bodyparser": "^4.3.0",
|
||||||
|
"@types/koa-mongo": "^1.9.0",
|
||||||
|
"@types/koa-router": "^7.4.1",
|
||||||
|
"@types/koa__cors": "^3.0.2",
|
||||||
|
"@types/mongodb": "^3.6.12",
|
||||||
|
"@types/node": "^14.14.37",
|
||||||
|
"@types/node-cron": "^2.0.3",
|
||||||
|
"@types/node-fetch": "^2.5.10",
|
||||||
|
"@types/node-telegram-bot-api": "^0.51.1",
|
||||||
|
"@types/nodemailer": "^6.4.1",
|
||||||
|
"@types/winston": "^2.4.4",
|
||||||
|
"nodemon": "^2.0.7",
|
||||||
|
"ts-node": "^9.1.1",
|
||||||
|
"typescript": "^4.2.4"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@blockworks-foundation/mango-client": "^0.1.10",
|
||||||
|
"@koa/cors": "^3.1.0",
|
||||||
|
"@solana/web3.js": "^1.2.6",
|
||||||
|
"dotenv": "^8.2.0",
|
||||||
|
"email-validator": "^2.0.4",
|
||||||
|
"koa": "^2.13.1",
|
||||||
|
"koa-bodyparser": "^4.3.0",
|
||||||
|
"koa-mongo": "^1.9.3",
|
||||||
|
"koa-router": "^10.0.0",
|
||||||
|
"mongodb": "^3.6.6",
|
||||||
|
"node-cron": "^3.0.0",
|
||||||
|
"node-fetch": "^2.6.1",
|
||||||
|
"node-telegram-bot-api": "^0.52.0",
|
||||||
|
"nodemailer": "^6.5.0",
|
||||||
|
"twilio": "^3.60.0",
|
||||||
|
"winston": "^3.3.3",
|
||||||
|
"winston-discord-transport": "^1.3.0"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,18 @@
|
||||||
|
import * as dotenv from 'dotenv';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
export default {
|
||||||
|
dbConnectionString: `mongodb://${process.env.DB_USER}:${process.env.DB_PASS}@${process.env.DB_HOSTS}/${process.env.DB}${process.env.DB_OPTIONS}`,
|
||||||
|
db: process.env.DB || '',
|
||||||
|
port: process.env.PORT || 3000,
|
||||||
|
|
||||||
|
twilioSid: process.env.TWILIO_ACCOUNT_SID || '',
|
||||||
|
twilioToken: process.env.TWILIO_AUTH_TOKEN || '',
|
||||||
|
twilioNumber: process.env.TWILIO_PHONE_NUMBER || '',
|
||||||
|
|
||||||
|
mailUser: process.env.MAIL_USER || '',
|
||||||
|
mailPass: process.env.MAIL_PASS || '',
|
||||||
|
|
||||||
|
tgToken: process.env.TG_TOKEN || ''
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
export class UserError extends Error {
|
||||||
|
constructor(message = 'Error', ...params: any[]) {
|
||||||
|
// Pass remaining arguments (including vendor specific ones) to parent constructor
|
||||||
|
super(...params);
|
||||||
|
|
||||||
|
// Maintains proper stack trace for where our error was thrown (only available on V8)
|
||||||
|
if (Error.captureStackTrace) {
|
||||||
|
Error.captureStackTrace(this, UserError);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.name = 'UserError';
|
||||||
|
// Custom debugging information
|
||||||
|
this.message = message;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,107 @@
|
||||||
|
|
||||||
|
import Koa from "koa";
|
||||||
|
import Router from "koa-router";
|
||||||
|
import mongo from "koa-mongo";
|
||||||
|
import bodyParser from "koa-bodyparser";
|
||||||
|
import cors from "@koa/cors";
|
||||||
|
import * as cron from "node-cron";
|
||||||
|
import {MongoClient, ObjectId} from "mongodb";
|
||||||
|
|
||||||
|
import { MangoClient, IDS } from '@blockworks-foundation/mango-client';
|
||||||
|
import { Connection, PublicKey } from '@solana/web3.js';
|
||||||
|
|
||||||
|
import { UserError } from './errors';
|
||||||
|
import { sendLogsToDiscord } from './logger';
|
||||||
|
import { initiateTelegramBot, generateTelegramCode, validateMarginAccount, validatePhoneNumber, validateEmail, reduceMangoGroups, sendAlert } from './utils';
|
||||||
|
import config from './environment';
|
||||||
|
|
||||||
|
const MESSAGE = 'Your collateral ratio is at or below @ratio@% \n';
|
||||||
|
|
||||||
|
const app = new Koa();
|
||||||
|
const router = new Router;
|
||||||
|
|
||||||
|
const cluster = 'mainnet-beta';
|
||||||
|
const client = new MangoClient();
|
||||||
|
const clusterIds = IDS[cluster];
|
||||||
|
const connection = new Connection(IDS.cluster_urls[cluster], 'singleGossip');
|
||||||
|
const dexProgramId = new PublicKey(clusterIds.dex_program_id);
|
||||||
|
|
||||||
|
app.use(cors());
|
||||||
|
app.use(bodyParser());
|
||||||
|
app.use(mongo({ uri: config.dbConnectionString }, { useUnifiedTopology: true }));
|
||||||
|
|
||||||
|
initiateTelegramBot();
|
||||||
|
|
||||||
|
router.post('/alerts', async(ctx, next) => {
|
||||||
|
try {
|
||||||
|
const alert = ctx.request.body;
|
||||||
|
await validateMarginAccount(client, connection, dexProgramId, alert);
|
||||||
|
if (alert.alertProvider == 'sms') {
|
||||||
|
const phoneNumber = `+${alert.phoneNumber.code}${alert.phoneNumber.phone}`;
|
||||||
|
await validatePhoneNumber(phoneNumber);
|
||||||
|
ctx.body = { status: 'success' };
|
||||||
|
} else if (alert.alertProvider == 'mail') {
|
||||||
|
validateEmail(alert.email);
|
||||||
|
ctx.body = { status: 'success' };
|
||||||
|
} else if (alert.alertProvider == 'tg') {
|
||||||
|
const code = generateTelegramCode();
|
||||||
|
alert.tgCode = code;
|
||||||
|
ctx.body = { code };
|
||||||
|
} else {
|
||||||
|
throw new UserError('Invalid alert provider');
|
||||||
|
}
|
||||||
|
alert.open = true;
|
||||||
|
alert.timestamp = new Date();
|
||||||
|
ctx.db.collection('alerts').insertOne(alert);
|
||||||
|
} catch (e) {
|
||||||
|
let errorMessage = 'Something went wrong';
|
||||||
|
if (e.name == 'UserError') {
|
||||||
|
errorMessage = e.message;
|
||||||
|
} else {
|
||||||
|
sendLogsToDiscord(null, e);
|
||||||
|
}
|
||||||
|
ctx.throw(400, errorMessage);
|
||||||
|
}
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(router.allowedMethods());
|
||||||
|
app.use(router.routes());
|
||||||
|
|
||||||
|
app.listen(config.port, () => {
|
||||||
|
const readyMessage = `> Server ready on http://localhost:${config.port}`;
|
||||||
|
console.log(readyMessage)
|
||||||
|
sendLogsToDiscord(readyMessage, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
cron.schedule("1 * * * *", async () => {
|
||||||
|
try {
|
||||||
|
const mongoConnection = await MongoClient.connect(config.dbConnectionString, { useUnifiedTopology: true });
|
||||||
|
const db = mongoConnection.db(config.db);
|
||||||
|
const alerts = await db.collection('alerts').find({open: true}).toArray();
|
||||||
|
const uniqueMangoGroupPks: string[] = [...new Set(alerts.map(alert => alert.mangoGroupPk))];
|
||||||
|
const mangoGroups:any = await reduceMangoGroups(client, connection, uniqueMangoGroupPks);
|
||||||
|
alerts.forEach(async (alert) => {
|
||||||
|
const marginAccountPk = new PublicKey(alert.marginAccountPk);
|
||||||
|
const marginAccount = await client.getMarginAccount(connection, marginAccountPk, dexProgramId);
|
||||||
|
const collateralRatio = marginAccount.getCollateralRatio(mangoGroups[alert.mangoGroupPk]['mangoGroup'], mangoGroups[alert.mangoGroupPk]['prices']);
|
||||||
|
if (collateralRatio <= alert.collateralRatioThresh) {
|
||||||
|
let message = MESSAGE.replace('@ratio@', alert.collateralRatioThresh);
|
||||||
|
message += marginAccount.toPrettyString(
|
||||||
|
mangoGroups[alert.mangoGroupPk]['mangoGroup'],
|
||||||
|
mangoGroups[alert.mangoGroupPk]['prices']
|
||||||
|
);
|
||||||
|
message += '\nVisit https://trade.mango.markets/'
|
||||||
|
const alertSent = sendAlert(alert, message);
|
||||||
|
if (alertSent) {
|
||||||
|
db.collection('alerts').updateOne({ _id: new ObjectId(alert._id) }, { '$set': { open: false } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const expiryTime = new Date( Date.now() - (1000 * 60 * 1) ); // 15 Minutes
|
||||||
|
console.log(expiryTime);
|
||||||
|
db.collection('alerts').deleteMany({ tgChatId: { $exists: false }, timestamp: { '$lt': expiryTime } });
|
||||||
|
} catch (e) {
|
||||||
|
sendLogsToDiscord(null, e);
|
||||||
|
}
|
||||||
|
});
|
|
@ -0,0 +1,123 @@
|
||||||
|
import { Twilio } from "twilio";
|
||||||
|
import * as nodemailer from 'nodemailer';
|
||||||
|
import * as TelegramBot from 'node-telegram-bot-api';
|
||||||
|
import * as EmailValidator from 'email-validator';
|
||||||
|
import { MongoClient } from "mongodb";
|
||||||
|
|
||||||
|
import { MangoClient } from '@blockworks-foundation/mango-client';
|
||||||
|
import { Connection, PublicKey } from '@solana/web3.js';
|
||||||
|
|
||||||
|
import { UserError } from './errors';
|
||||||
|
|
||||||
|
import config from './environment';
|
||||||
|
|
||||||
|
// This needs to be global because it uses event listeners
|
||||||
|
const bot = new TelegramBot.default(config.tgToken, {polling: true});
|
||||||
|
const twilioClient = new Twilio(config.twilioSid, config.twilioToken);
|
||||||
|
|
||||||
|
export const validateMarginAccount = (client: MangoClient, connection: Connection, dexProgramId: PublicKey, alert: any) => {
|
||||||
|
return new Promise<void>(async (resolve, reject) => {
|
||||||
|
const mangoGroupPk = new PublicKey(alert.mangoGroupPk);
|
||||||
|
const marginAccountPk = new PublicKey(alert.marginAccountPk);
|
||||||
|
const mangoGroup = await client.getMangoGroup(connection, mangoGroupPk);
|
||||||
|
const marginAccount = await client.getMarginAccount(connection, marginAccountPk, dexProgramId);
|
||||||
|
if (!mangoGroup || !marginAccount) {
|
||||||
|
reject(new UserError('Invalid margin account or mango group'));
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const validatePhoneNumber = (phoneNumber: string) => {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
twilioClient.lookups.phoneNumbers(phoneNumber).fetch((error, _) => {
|
||||||
|
if (error) {
|
||||||
|
reject(new UserError('The entered phone number is incorrect'));
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const validateEmail = (email: string) => {
|
||||||
|
if (!EmailValidator.validate(email)) {
|
||||||
|
throw new UserError('The entered email is incorrect');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendSms = (phoneNumber: string, message: string) => {
|
||||||
|
const twilioClient = new Twilio(config.twilioSid, config.twilioToken);
|
||||||
|
twilioClient.messages
|
||||||
|
.create({
|
||||||
|
from: config.twilioNumber,
|
||||||
|
to: phoneNumber,
|
||||||
|
body: message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendEmail = (email: string, message: string) => {
|
||||||
|
const transporter = nodemailer.createTransport(
|
||||||
|
`smtps://${config.mailUser}%40gmail.com:${config.mailPass}@smtp.gmail.com`
|
||||||
|
);
|
||||||
|
const mailOptions = {
|
||||||
|
from : `${config.mailUser}@gmail.com`,
|
||||||
|
to : email,
|
||||||
|
subject : 'Mango Markets Alerts',
|
||||||
|
text: message
|
||||||
|
};
|
||||||
|
transporter.sendMail( mailOptions );
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sendAlert = (alert: any, message: string) => {
|
||||||
|
if (alert.alertProvider == 'sms') {
|
||||||
|
const phoneNumber = `+${alert.phoneNumber.code}${alert.phoneNumber.phone}`;
|
||||||
|
sendSms(phoneNumber, message);
|
||||||
|
} else if (alert.alertProvider == 'mail') {
|
||||||
|
const email = alert.email;
|
||||||
|
sendEmail(email, message);
|
||||||
|
} else if (alert.alertProvider == 'tg') {
|
||||||
|
if (!alert.tgChatId) return false;
|
||||||
|
bot.sendMessage(alert.tgChatId, message);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const reduceMangoGroups = async (client: MangoClient, connection: Connection, mangoGroupPks: string[]) => {
|
||||||
|
const mangoGroups:any = {};
|
||||||
|
for (let mangoGroupPk of mangoGroupPks) {
|
||||||
|
const mangoGroup = await client.getMangoGroup(connection, new PublicKey(mangoGroupPk));
|
||||||
|
mangoGroups[mangoGroupPk] = {
|
||||||
|
mangoGroup,
|
||||||
|
prices: await mangoGroup.getPrices(connection),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return mangoGroups;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const initiateTelegramBot = () => {
|
||||||
|
bot.on('message', async (message: any) => {
|
||||||
|
const mongoConnection = await MongoClient.connect(config.dbConnectionString, { useUnifiedTopology: true });
|
||||||
|
const db = mongoConnection.db(config.db);
|
||||||
|
const tgCode = message.text;
|
||||||
|
const alert = await db.collection('alerts').findOne({tgCode});
|
||||||
|
if (alert) {
|
||||||
|
await db.collection('alerts').updateOne({ tgCode }, {'$set': { tgChatId: message.chat.id } } );
|
||||||
|
bot.sendMessage(message.chat.id, 'Thanks, You have successfully claimed your alert\nYou can now close the dialogue on website');
|
||||||
|
} else {
|
||||||
|
bot.sendMessage(message.chat.id, 'Sorry, this code is either invalid or expired');
|
||||||
|
}
|
||||||
|
mongoConnection.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const generateTelegramCode = () => {
|
||||||
|
var text = "";
|
||||||
|
var possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
text += possible.charAt(Math.floor(Math.random() * possible.length));
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es5",
|
||||||
|
"module": "commonjs",
|
||||||
|
"lib": ["es6"],
|
||||||
|
"allowJs": true,
|
||||||
|
"outDir": "build",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"downlevelIteration": true
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue