Migrate plugins to lib directory
This commit is contained in:
parent
09b29bba56
commit
e7ab8223c2
27 changed files with 869 additions and 858 deletions
|
|
@ -1,13 +1,12 @@
|
||||||
const configManager = require('./config-manager')
|
const configManager = require('./config-manager')
|
||||||
|
const ph = require('./plugin-helper')
|
||||||
|
|
||||||
function sendMessage (settings, rec) {
|
function sendMessage (settings, rec) {
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
const pluginCode = configManager.unscoped(settings.config).email
|
const pluginCode = configManager.unscoped(settings.config).email
|
||||||
|
const plugin = ph.load(ph.EMAIL, pluginCode)
|
||||||
if (!pluginCode) throw new Error('No email plugin defined')
|
|
||||||
const account = settings.accounts[pluginCode]
|
const account = settings.accounts[pluginCode]
|
||||||
const plugin = require('lamassu-' + pluginCode)
|
|
||||||
|
|
||||||
return plugin.sendMessage(account, rec)
|
return plugin.sendMessage(account, rec)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,5 @@
|
||||||
const configManager = require('./config-manager')
|
const configManager = require('./config-manager')
|
||||||
|
const ph = require('./plugin-helper')
|
||||||
function noExchangeError (cryptoCode) {
|
|
||||||
const err = new Error('No exchange plugin defined for: ' + cryptoCode)
|
|
||||||
err.name = 'NoExchangeError'
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
function lookupExchange (settings, cryptoCode) {
|
function lookupExchange (settings, cryptoCode) {
|
||||||
return configManager.cryptoScoped(cryptoCode, settings.config).exchange
|
return configManager.cryptoScoped(cryptoCode, settings.config).exchange
|
||||||
|
|
@ -15,9 +9,8 @@ function fetchExchange (settings, cryptoCode) {
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
const plugin = lookupExchange(settings, cryptoCode)
|
const plugin = lookupExchange(settings, cryptoCode)
|
||||||
if (!plugin) throw noExchangeError(cryptoCode)
|
const exchange = ph.load(ph.EXCHANGE, plugin)
|
||||||
const account = settings.accounts[plugin]
|
const account = settings.accounts[plugin]
|
||||||
const exchange = require('lamassu-' + plugin)
|
|
||||||
|
|
||||||
return {exchange, account}
|
return {exchange, account}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
23
lib/plugin-helper.js
Normal file
23
lib/plugin-helper.js
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
const _ = require('lodash/fp')
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
load,
|
||||||
|
TICKER: 'ticker',
|
||||||
|
EXCHANGE: 'exchange',
|
||||||
|
WALLET: 'wallet',
|
||||||
|
SMS: 'sms',
|
||||||
|
EMAIL: 'email'
|
||||||
|
}
|
||||||
|
|
||||||
|
function load (type, pluginCode) {
|
||||||
|
const me = module.exports
|
||||||
|
if (!_.includes(type, [me.TICKER, me.EXCHANGE, me.WALLET, me.SMS, me.EMAIL])) {
|
||||||
|
throw new Error(`Unallowed plugin type: ${type}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pluginCode.search(/[a-z0-9\-]/) === -1) {
|
||||||
|
throw new Error(`Unallowed plugin name: ${pluginCode}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return require(`./plugins/${type}/${pluginCode}/${pluginCode}`)
|
||||||
|
}
|
||||||
|
|
@ -366,7 +366,6 @@ function plugins (settings, deviceId) {
|
||||||
return executeTradeForType(tradeEntry)
|
return executeTradeForType(tradeEntry)
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
tradesQueues[market].push(tradeEntry)
|
tradesQueues[market].push(tradeEntry)
|
||||||
if (err.name === 'NoExchangeError') return logger.debug(err.message)
|
|
||||||
if (err.name === 'orderTooSmall') return logger.debug(err.message)
|
if (err.name === 'orderTooSmall') return logger.debug(err.message)
|
||||||
logger.error(err)
|
logger.error(err)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
81
lib/plugins/common/bitstamp.js
Normal file
81
lib/plugins/common/bitstamp.js
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
'use strict'
|
||||||
|
|
||||||
|
const querystring = require('querystring')
|
||||||
|
const axios = require('axios')
|
||||||
|
const crypto = require('crypto')
|
||||||
|
const _ = require('lodash')
|
||||||
|
|
||||||
|
const API_ENDPOINT = 'https://www.bitstamp.net/api/v2'
|
||||||
|
|
||||||
|
let counter = -1
|
||||||
|
let lastTimestamp = Date.now()
|
||||||
|
|
||||||
|
function pad (num) {
|
||||||
|
const asString = num.toString(10)
|
||||||
|
if (num < 10) return '00' + asString
|
||||||
|
if (num < 100) return '0' + asString
|
||||||
|
return asString
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateNonce () {
|
||||||
|
const timestamp = Date.now()
|
||||||
|
if (timestamp !== lastTimestamp) counter = -1
|
||||||
|
lastTimestamp = timestamp
|
||||||
|
counter = (counter + 1) % 1000
|
||||||
|
return timestamp.toString(10) + pad(counter)
|
||||||
|
}
|
||||||
|
|
||||||
|
function authRequest (config, path, data) {
|
||||||
|
if (!config.key || !config.secret || !config.clientId) {
|
||||||
|
const err = new Error('Must provide key, secret and client ID')
|
||||||
|
return Promise.reject(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data = data || {}
|
||||||
|
|
||||||
|
const nonce = generateNonce()
|
||||||
|
const msg = [nonce, config.clientId, config.key].join('')
|
||||||
|
|
||||||
|
const signature = crypto
|
||||||
|
.createHmac('sha256', Buffer.from(config.secret))
|
||||||
|
.update(msg)
|
||||||
|
.digest('hex')
|
||||||
|
.toUpperCase()
|
||||||
|
|
||||||
|
_.merge(data, {
|
||||||
|
key: config.key,
|
||||||
|
signature: signature,
|
||||||
|
nonce: nonce
|
||||||
|
})
|
||||||
|
|
||||||
|
return request(path, 'POST', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMarket (fiatCode, cryptoCode) {
|
||||||
|
if (cryptoCode !== 'BTC') throw new Error('Unsupported crypto: ' + cryptoCode)
|
||||||
|
if (fiatCode === 'USD') return 'btcusd'
|
||||||
|
if (fiatCode === 'EUR') return 'btceur'
|
||||||
|
throw new Error('Unsupported fiat: ' + fiatCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
function request (path, method, data) {
|
||||||
|
const options = {
|
||||||
|
method: method,
|
||||||
|
url: API_ENDPOINT + path + '/',
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/4.0 (compatible; Lamassu client)',
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data) options.data = querystring.stringify(data)
|
||||||
|
|
||||||
|
return axios(options)
|
||||||
|
.then(r => r.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
authRequest,
|
||||||
|
request,
|
||||||
|
buildMarket
|
||||||
|
}
|
||||||
17
lib/plugins/common/kraken.js
Normal file
17
lib/plugins/common/kraken.js
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
var BigNumber = require('bignumber.js')
|
||||||
|
|
||||||
|
var TEN = new BigNumber(10)
|
||||||
|
|
||||||
|
var UNIT_SCALES = {
|
||||||
|
BTC: 8,
|
||||||
|
ETH: 18
|
||||||
|
}
|
||||||
|
|
||||||
|
function unitScale (cryptoCoin) {
|
||||||
|
return UNIT_SCALES[cryptoCoin]
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.toUnit = function toUnit (cryptoAtoms, cryptoCoin) {
|
||||||
|
var scale = TEN.pow(unitScale(cryptoCoin))
|
||||||
|
return cryptoAtoms.div(scale)
|
||||||
|
}
|
||||||
23
lib/plugins/email/mailjet/mailjet.js
Normal file
23
lib/plugins/email/mailjet/mailjet.js
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
const Mailjet = require('node-mailjet')
|
||||||
|
|
||||||
|
const NAME = 'Mailjet'
|
||||||
|
|
||||||
|
function sendMessage (account, rec) {
|
||||||
|
const mailjet = Mailjet.connect(account.apiKey, account.apiSecret)
|
||||||
|
const sendEmail = mailjet.post('send')
|
||||||
|
|
||||||
|
const emailData = {
|
||||||
|
FromEmail: account.fromEmail,
|
||||||
|
FromName: 'Lamassu Server',
|
||||||
|
Subject: rec.email.subject,
|
||||||
|
'Text-part': rec.email.body,
|
||||||
|
Recipients: [{'Email': account.toEmail}]
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendEmail.request(emailData)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
NAME,
|
||||||
|
sendMessage
|
||||||
|
}
|
||||||
34
lib/plugins/email/mailjet/schema.json
Normal file
34
lib/plugins/email/mailjet/schema.json
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
{
|
||||||
|
"code": "mailjet",
|
||||||
|
"display": "Mailjet",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"code": "apiKey",
|
||||||
|
"display": "API key",
|
||||||
|
"fieldType": "string",
|
||||||
|
"required": true,
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "apiSecret",
|
||||||
|
"display": "API secret",
|
||||||
|
"fieldType": "password",
|
||||||
|
"required": true,
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "fromEmail",
|
||||||
|
"display": "From email",
|
||||||
|
"fieldType": "string",
|
||||||
|
"required": true,
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "toEmail",
|
||||||
|
"display": "To email",
|
||||||
|
"fieldType": "string",
|
||||||
|
"required": true,
|
||||||
|
"value": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
44
lib/plugins/exchange/bitstamp/bitstamp.js
Normal file
44
lib/plugins/exchange/bitstamp/bitstamp.js
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
const common = require('../common/bitstamp')
|
||||||
|
|
||||||
|
const SATOSHI_SHIFT = 8
|
||||||
|
|
||||||
|
function buy (account, cryptoAtoms, fiatCode, cryptoCode) {
|
||||||
|
return trade('buy', account, cryptoAtoms, fiatCode, cryptoCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sell (account, cryptoAtoms, fiatCode, cryptoCode) {
|
||||||
|
return trade('sell', account, cryptoAtoms, fiatCode, cryptoCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleErrors (data) {
|
||||||
|
if (!data.reason || !data.reason.__all__) return data
|
||||||
|
|
||||||
|
const err = new Error(data.reason.__all__[0])
|
||||||
|
|
||||||
|
if (data.reason.__all__[0].indexOf('Minimum order size is') === 0) {
|
||||||
|
err.name = 'orderTooSmall'
|
||||||
|
}
|
||||||
|
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
function trade (type, account, cryptoAtoms, fiatCode, cryptoCode) {
|
||||||
|
try {
|
||||||
|
const market = common.buildMarket(fiatCode, cryptoCode)
|
||||||
|
const options = {amount: cryptoAtoms.shift(-SATOSHI_SHIFT).toFixed(8)}
|
||||||
|
|
||||||
|
return common.authRequest(account, '/' + type + '/market/' + market, options)
|
||||||
|
.catch(e => {
|
||||||
|
if (e.response) handleErrors(e.response.data)
|
||||||
|
throw e
|
||||||
|
})
|
||||||
|
.then(handleErrors)
|
||||||
|
} catch (e) {
|
||||||
|
return Promise.reject(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
buy,
|
||||||
|
sell
|
||||||
|
}
|
||||||
27
lib/plugins/exchange/bitstamp/schema.json
Normal file
27
lib/plugins/exchange/bitstamp/schema.json
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"code": "bitstamp",
|
||||||
|
"display": "Bitstamp",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"code": "clientId",
|
||||||
|
"display": "Client ID",
|
||||||
|
"fieldType": "string",
|
||||||
|
"required": true,
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "key",
|
||||||
|
"display": "API key",
|
||||||
|
"fieldType": "string",
|
||||||
|
"required": true,
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "secret",
|
||||||
|
"display": "API secret",
|
||||||
|
"fieldType": "password",
|
||||||
|
"required": true,
|
||||||
|
"value": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
54
lib/plugins/exchange/kraken/kraken.js
Normal file
54
lib/plugins/exchange/kraken/kraken.js
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
const Kraken = require('kraken-api')
|
||||||
|
const coinmath = require('../common/kraken')
|
||||||
|
|
||||||
|
var PAIRS = {
|
||||||
|
BTC: {
|
||||||
|
USD: 'XXBTZUSD',
|
||||||
|
EUR: 'XXBTZEUR'
|
||||||
|
},
|
||||||
|
ETH: {
|
||||||
|
USD: 'XETHZUSD',
|
||||||
|
EUR: 'XETHZEUR'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {buy, sell}
|
||||||
|
|
||||||
|
function buy (account, cryptoAtoms, fiatCode, cryptoCode) {
|
||||||
|
return trade(account, 'buy', cryptoAtoms, fiatCode, cryptoCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sell (account, cryptoAtoms, fiatCode, cryptoCode) {
|
||||||
|
return trade(account, 'sell', cryptoAtoms, fiatCode, cryptoCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
function trade (account, type, cryptoAtoms, fiatCode, cryptoCode) {
|
||||||
|
const kraken = new Kraken(account.key, account.secret)
|
||||||
|
const amount = coinmath.toUnit(cryptoAtoms, cryptoCode)
|
||||||
|
|
||||||
|
if (amount.lte('0.01')) {
|
||||||
|
const err = new Error('Order size too small')
|
||||||
|
err.name = 'orderTooSmall'
|
||||||
|
return Promise.reject(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const amountStr = amount.toFixed(6)
|
||||||
|
const pair = PAIRS[cryptoCode][fiatCode]
|
||||||
|
|
||||||
|
var orderInfo = {
|
||||||
|
pair: pair,
|
||||||
|
type: type,
|
||||||
|
ordertype: 'market',
|
||||||
|
volume: amountStr,
|
||||||
|
expiretm: '+60'
|
||||||
|
}
|
||||||
|
|
||||||
|
kraken.api('AddOrder', orderInfo, function (error, response) {
|
||||||
|
if (error) {
|
||||||
|
// TODO: handle: EOrder:Order minimum not met (volume too low)
|
||||||
|
return Promise.reject(error)
|
||||||
|
} else {
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
14
lib/plugins/exchange/mock-exchange/mock-exchange.js
Normal file
14
lib/plugins/exchange/mock-exchange/mock-exchange.js
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
module.exports = {
|
||||||
|
buy,
|
||||||
|
sell
|
||||||
|
}
|
||||||
|
|
||||||
|
function buy (account, cryptoAtoms, fiatCode, cryptoCode) {
|
||||||
|
console.log('[mock] buying %s %s for %s', cryptoAtoms.toString(), cryptoCode, fiatCode)
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
function sell (account, cryptoAtoms, fiatCode, cryptoCode) {
|
||||||
|
console.log('[mock] selling %s %s for %s', cryptoAtoms.toString(), cryptoCode, fiatCode)
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
6
lib/plugins/sms/mock-sms/mock-sms.js
Normal file
6
lib/plugins/sms/mock-sms/mock-sms.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
exports.NAME = 'MockSMS'
|
||||||
|
|
||||||
|
exports.sendMessage = function sendMessage (account, rec) {
|
||||||
|
console.log('Sending SMS: %j', rec)
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
34
lib/plugins/sms/twilio/schema.json
Normal file
34
lib/plugins/sms/twilio/schema.json
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
{
|
||||||
|
"code": "twilio",
|
||||||
|
"display": "Twilio",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"code": "accountSid",
|
||||||
|
"display": "Account SID",
|
||||||
|
"fieldType": "string",
|
||||||
|
"required": true,
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "authToken",
|
||||||
|
"display": "Auth token",
|
||||||
|
"fieldType": "password",
|
||||||
|
"required": true,
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "fromNumber",
|
||||||
|
"display": "From number",
|
||||||
|
"fieldType": "string",
|
||||||
|
"required": true,
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "toNumber",
|
||||||
|
"display": "To number",
|
||||||
|
"fieldType": "string",
|
||||||
|
"required": true,
|
||||||
|
"value": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
33
lib/plugins/sms/twilio/twilio.js
Normal file
33
lib/plugins/sms/twilio/twilio.js
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
const Client = require('twilio')
|
||||||
|
const _ = require('lodash/fp')
|
||||||
|
|
||||||
|
const NAME = 'Twilio'
|
||||||
|
|
||||||
|
const BAD_NUMBER_CODES = [21201, 21202, 21211, 21214, 21216, 21217, 21219, 21408,
|
||||||
|
21610, 21612, 21614, 21608]
|
||||||
|
|
||||||
|
function sendMessage (account, rec) {
|
||||||
|
const client = Client(account.accountSid, account.authToken)
|
||||||
|
const body = rec.sms.body
|
||||||
|
const _toNumber = rec.sms.toNumber || account.toNumber
|
||||||
|
|
||||||
|
return client.sendMessage({
|
||||||
|
body: body,
|
||||||
|
to: _toNumber,
|
||||||
|
from: account.fromNumber
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
if (_.includes(err.code, BAD_NUMBER_CODES)) {
|
||||||
|
const badNumberError = new Error(err.message)
|
||||||
|
badNumberError.name = 'BadNumberError'
|
||||||
|
throw badNumberError
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(err.message)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
NAME,
|
||||||
|
sendMessage
|
||||||
|
}
|
||||||
25
lib/plugins/ticker/bitpay/bitpay.js
Normal file
25
lib/plugins/ticker/bitpay/bitpay.js
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
const axios = require('axios')
|
||||||
|
const BN = require('../../../bn')
|
||||||
|
|
||||||
|
function ticker (account, fiatCode, cryptoCode) {
|
||||||
|
if (cryptoCode !== 'BTC') {
|
||||||
|
return Promise.reject('Unsupported crypto: ' + cryptoCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return axios.get('https://bitpay.com/api/rates/' + fiatCode)
|
||||||
|
.then(r => {
|
||||||
|
const data = r.data
|
||||||
|
const price = BN(data.rate)
|
||||||
|
return {
|
||||||
|
rates: {
|
||||||
|
ask: price,
|
||||||
|
bid: price
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
ticker,
|
||||||
|
name: 'BitPay'
|
||||||
|
}
|
||||||
26
lib/plugins/ticker/bitstamp/bitstamp.js
Normal file
26
lib/plugins/ticker/bitstamp/bitstamp.js
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
const BN = require('../../../bn')
|
||||||
|
const common = require('../common/bitstamp')
|
||||||
|
|
||||||
|
function ticker (account, fiatCode, cryptoCode) {
|
||||||
|
return Promise.resolve()
|
||||||
|
.then(() => {
|
||||||
|
if (cryptoCode !== 'BTC') {
|
||||||
|
throw new Error('Unsupported crypto: ' + cryptoCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
const market = common.buildMarket(fiatCode, cryptoCode)
|
||||||
|
return common.request('/ticker/' + market, 'GET')
|
||||||
|
})
|
||||||
|
.then(r => ({
|
||||||
|
rates: {
|
||||||
|
ask: BN(r.ask),
|
||||||
|
bid: BN(r.bid)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
ticker
|
||||||
|
}
|
||||||
|
|
||||||
60
lib/plugins/ticker/kraken/kraken.js
Normal file
60
lib/plugins/ticker/kraken/kraken.js
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
const axios = require('axios')
|
||||||
|
const _ = require('lodash/fp')
|
||||||
|
|
||||||
|
const BN = require('../../../bn')
|
||||||
|
|
||||||
|
exports.NAME = 'Kraken'
|
||||||
|
exports.SUPPORTED_MODULES = ['ticker']
|
||||||
|
|
||||||
|
const PAIRS = {
|
||||||
|
BTC: {
|
||||||
|
USD: 'XXBTZUSD',
|
||||||
|
EUR: 'XXBTZEUR'
|
||||||
|
},
|
||||||
|
ETH: {
|
||||||
|
USD: 'XETHZUSD',
|
||||||
|
EUR: 'XETHZEUR'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findCurrency (fxRates, fiatCode) {
|
||||||
|
const rates = _.find(_.matchesProperty('code', fiatCode), fxRates)
|
||||||
|
if (!rates || !rates.rate) throw new Error(`Unsupported currency: ${fiatCode}`)
|
||||||
|
return BN(rates.rate)
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.ticker = function ticker (account, fiatCode, cryptoCode) {
|
||||||
|
if (fiatCode === 'USD' || fiatCode === 'EUR') {
|
||||||
|
return getCurrencyRates(fiatCode, cryptoCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return axios.get('https://bitpay.com/api/rates')
|
||||||
|
.then(response => {
|
||||||
|
const fxRates = response.data
|
||||||
|
const usdRate = findCurrency(fxRates, 'USD')
|
||||||
|
const fxRate = findCurrency(fxRates, fiatCode).div(usdRate)
|
||||||
|
|
||||||
|
return getCurrencyRates('USD', cryptoCode)
|
||||||
|
.then(res => ({
|
||||||
|
rates: {
|
||||||
|
ask: res.rates.ask.times(fxRate),
|
||||||
|
bid: res.rates.bid.times(fxRate)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrencyRates (fiatCode, cryptoCode) {
|
||||||
|
const pair = PAIRS[cryptoCode][fiatCode]
|
||||||
|
|
||||||
|
return axios.get('https://api.kraken.com/0/public/Ticker?pair=' + pair)
|
||||||
|
.then(function (response) {
|
||||||
|
const rates = response.data.result[pair]
|
||||||
|
return {
|
||||||
|
rates: {
|
||||||
|
ask: BN(rates.a[0]),
|
||||||
|
bid: BN(rates.b[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
146
lib/plugins/wallet/bitcoind/bitcoind.js
Normal file
146
lib/plugins/wallet/bitcoind/bitcoind.js
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
const path = require('path')
|
||||||
|
const os = require('os')
|
||||||
|
const RpcClient = require('bitcoind-rpc')
|
||||||
|
const fs = require('fs')
|
||||||
|
const pify = require('pify')
|
||||||
|
|
||||||
|
const BN = require('../../../bn')
|
||||||
|
|
||||||
|
const NAME = 'Bitcoind'
|
||||||
|
|
||||||
|
const SATOSHI_SHIFT = 8
|
||||||
|
|
||||||
|
const configPath = path.resolve(os.homedir(), '.bitcoin', 'bitcoin.conf')
|
||||||
|
const pluginConfig = {
|
||||||
|
account: '',
|
||||||
|
bitcoindConfigurationPath: configPath
|
||||||
|
}
|
||||||
|
|
||||||
|
function initRpc () {
|
||||||
|
const bitcoindConf = parseConf(pluginConfig.bitcoindConfigurationPath)
|
||||||
|
|
||||||
|
const rpcConfig = {
|
||||||
|
protocol: 'http',
|
||||||
|
user: bitcoindConf.rpcuser,
|
||||||
|
pass: bitcoindConf.rpcpassword
|
||||||
|
}
|
||||||
|
|
||||||
|
return new RpcClient(rpcConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
function richError (msg, name) {
|
||||||
|
const err = new Error(msg)
|
||||||
|
err.name = name
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* initialize RpcClient
|
||||||
|
*/
|
||||||
|
function parseConf (confPath) {
|
||||||
|
const conf = fs.readFileSync(confPath)
|
||||||
|
const lines = conf.toString().split('\n')
|
||||||
|
|
||||||
|
const res = {}
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const keyVal = lines[i].split('=')
|
||||||
|
|
||||||
|
// skip when value is empty
|
||||||
|
if (!keyVal[1]) continue
|
||||||
|
|
||||||
|
res[keyVal[0]] = keyVal[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkCryptoCode (cryptoCode) {
|
||||||
|
if (cryptoCode !== 'BTC') throw new Error('Unsupported crypto: ' + cryptoCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// We want a balance that includes all spends (0 conf) but only deposits that
|
||||||
|
// have at least 1 confirmation. getbalance does this for us automatically.
|
||||||
|
function balance (account, cryptoCode) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
checkCryptoCode(cryptoCode)
|
||||||
|
const rpc = initRpc()
|
||||||
|
rpc.getBalance(pluginConfig.account, 1, (err, result) => {
|
||||||
|
if (err) return reject(err)
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
return reject(richError(result.error, 'bitcoindError'))
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(BN(result.result).shift(SATOSHI_SHIFT).round())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendCoins (account, address, cryptoAtoms, cryptoCode) {
|
||||||
|
const rpc = initRpc()
|
||||||
|
const confirmations = 1
|
||||||
|
const bitcoins = cryptoAtoms.shift(-SATOSHI_SHIFT).toFixed(8)
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
checkCryptoCode(cryptoCode)
|
||||||
|
rpc.sendFrom(pluginConfig.account, address, bitcoins, confirmations, (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
if (err.code === -6) {
|
||||||
|
return reject(richError('Insufficient funds', 'InsufficientFunds'))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err instanceof Error) {
|
||||||
|
return reject(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return reject(richError(err.message, 'bitcoindError'))
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(result.result)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function newAddress (account, cryptoCode, info) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
checkCryptoCode(cryptoCode)
|
||||||
|
const rpc = initRpc()
|
||||||
|
rpc.getNewAddress((err, result) => {
|
||||||
|
if (err) return reject(err)
|
||||||
|
resolve(result.result)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function addressBalance (address, confs) {
|
||||||
|
const rpc = initRpc()
|
||||||
|
return pify(rpc.getReceivedByAddress.bind(rpc))(address, confs)
|
||||||
|
.then(r => BN(r.result).shift(SATOSHI_SHIFT).round())
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmedBalance = address => addressBalance(address, 1)
|
||||||
|
const pendingBalance = address => addressBalance(address, 0)
|
||||||
|
|
||||||
|
function getStatus (account, toAddress, requested, cryptoCode) {
|
||||||
|
return Promise.resolve()
|
||||||
|
.then(() => checkCryptoCode(cryptoCode))
|
||||||
|
.then(() => confirmedBalance(toAddress))
|
||||||
|
.then(confirmed => {
|
||||||
|
if (confirmed.gte(requested)) return {status: 'confirmed'}
|
||||||
|
|
||||||
|
return pendingBalance(toAddress)
|
||||||
|
.then(pending => {
|
||||||
|
if (pending.gte(requested)) return {status: 'authorized'}
|
||||||
|
if (pending.gt(0)) return {status: 'insufficientFunds'}
|
||||||
|
return {status: 'notSeen'}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
NAME,
|
||||||
|
balance,
|
||||||
|
sendCoins,
|
||||||
|
newAddress,
|
||||||
|
getStatus
|
||||||
|
}
|
||||||
91
lib/plugins/wallet/bitgo/bitgo.js
Normal file
91
lib/plugins/wallet/bitgo/bitgo.js
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
const BitGo = require('bitgo')
|
||||||
|
const BN = require('../../../bn')
|
||||||
|
|
||||||
|
const pjson = require('../../../package.json')
|
||||||
|
const userAgent = 'Lamassu-Server/' + pjson.version
|
||||||
|
|
||||||
|
const NAME = 'BitGo'
|
||||||
|
|
||||||
|
function buildBitgo (account) {
|
||||||
|
return new BitGo.BitGo({accessToken: account.token, env: 'prod', userAgent: userAgent})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWallet (account) {
|
||||||
|
const bitgo = buildBitgo(account)
|
||||||
|
return bitgo.wallets().get({ id: account.walletId })
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkCryptoCode (cryptoCode) {
|
||||||
|
if (cryptoCode !== 'BTC') {
|
||||||
|
return Promise.reject(new Error('Unsupported crypto: ' + cryptoCode))
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendCoins (account, address, cryptoAtoms, cryptoCode) {
|
||||||
|
return checkCryptoCode(cryptoCode)
|
||||||
|
.then(() => getWallet(account))
|
||||||
|
.then(wallet => {
|
||||||
|
const params = {
|
||||||
|
address: address,
|
||||||
|
amount: cryptoAtoms.toNumber(),
|
||||||
|
walletPassphrase: account.walletPassphrase
|
||||||
|
}
|
||||||
|
return wallet.sendCoins(params)
|
||||||
|
})
|
||||||
|
.then(result => {
|
||||||
|
return result.hash
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
if (err.message === 'Insufficient funds') {
|
||||||
|
err.name = 'InsufficientFunds'
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function balance (account, cryptoCode) {
|
||||||
|
return checkCryptoCode(cryptoCode)
|
||||||
|
.then(() => getWallet(account))
|
||||||
|
.then(wallet => BN(wallet.wallet.spendableConfirmedBalance))
|
||||||
|
}
|
||||||
|
|
||||||
|
function newAddress (account, cryptoCode, info) {
|
||||||
|
return checkCryptoCode(cryptoCode)
|
||||||
|
.then(() => getWallet(account))
|
||||||
|
.then(wallet => {
|
||||||
|
return wallet.createAddress()
|
||||||
|
.then(result => {
|
||||||
|
const address = result.address
|
||||||
|
|
||||||
|
// If a label was provided, set the label
|
||||||
|
if (info.label) {
|
||||||
|
return wallet.setLabel({ address: address, label: info.label })
|
||||||
|
.then(() => address)
|
||||||
|
}
|
||||||
|
|
||||||
|
return address
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatus (account, toAddress, requested, cryptoCode) {
|
||||||
|
const bitgo = buildBitgo(account)
|
||||||
|
return checkCryptoCode(cryptoCode)
|
||||||
|
.then(() => bitgo.blockchain().getAddress({address: toAddress}))
|
||||||
|
.then(rec => {
|
||||||
|
if (rec.balance === 0) return {status: 'notSeen'}
|
||||||
|
if (requested.gt(rec.balance)) return {status: 'insufficientFunds'}
|
||||||
|
if (requested.gt(rec.confirmedBalance)) return {status: 'authorized'}
|
||||||
|
return {status: 'confirmed'}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
NAME,
|
||||||
|
balance,
|
||||||
|
sendCoins,
|
||||||
|
newAddress,
|
||||||
|
getStatus
|
||||||
|
}
|
||||||
30
lib/plugins/wallet/bitgo/schema.json
Normal file
30
lib/plugins/wallet/bitgo/schema.json
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
{
|
||||||
|
"code": "bitgo",
|
||||||
|
"display": "BitGo",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"code": "token",
|
||||||
|
"display": "API token",
|
||||||
|
"fieldType": "string",
|
||||||
|
"secret": true,
|
||||||
|
"required": true,
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "walletId",
|
||||||
|
"display": "Wallet ID",
|
||||||
|
"fieldType": "string",
|
||||||
|
"secret": false,
|
||||||
|
"required": true,
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "walletPassphrase",
|
||||||
|
"display": "Wallet passphrase",
|
||||||
|
"fieldType": "string",
|
||||||
|
"secret": true,
|
||||||
|
"required": true,
|
||||||
|
"value": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
53
lib/plugins/wallet/mock-wallet/mock-wallet.js
Normal file
53
lib/plugins/wallet/mock-wallet/mock-wallet.js
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
const BN = require('../../../bn')
|
||||||
|
|
||||||
|
const NAME = 'FakeWallet'
|
||||||
|
|
||||||
|
const SECONDS = 1000
|
||||||
|
const UNSEEN_TIME = 6 * SECONDS
|
||||||
|
const PUBLISH_TIME = 12 * SECONDS
|
||||||
|
const AUTHORIZE_TIME = 60 * SECONDS
|
||||||
|
|
||||||
|
let t0
|
||||||
|
|
||||||
|
function balance (account, cryptoCode) {
|
||||||
|
return Promise.resolve()
|
||||||
|
.then(() => {
|
||||||
|
if (cryptoCode === 'BTC') return BN(1e8 * 10)
|
||||||
|
if (cryptoCode === 'ETH') return BN(1e18 * 10)
|
||||||
|
throw new Error('Unsupported crypto: ' + cryptoCode)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendCoins (account, toAddress, cryptoAtoms, cryptoCode) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log('[%s] DEBUG: Mock wallet sending %s cryptoAtoms to %s',
|
||||||
|
cryptoCode, cryptoAtoms.toString(), toAddress)
|
||||||
|
resolve('<txHash>')
|
||||||
|
}, 2000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function newAddress () {
|
||||||
|
t0 = Date.now()
|
||||||
|
return Promise.resolve('<Fake address, don\'t send>')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatus (account, toAddress, cryptoAtoms, cryptoCode) {
|
||||||
|
const elapsed = Date.now() - t0
|
||||||
|
|
||||||
|
if (elapsed < UNSEEN_TIME) return Promise.resolve({status: 'notSeen'})
|
||||||
|
if (elapsed < PUBLISH_TIME) return Promise.resolve({status: 'published'})
|
||||||
|
if (elapsed < AUTHORIZE_TIME) return Promise.resolve({status: 'authorized'})
|
||||||
|
|
||||||
|
console.log('[%s] DEBUG: Mock wallet has confirmed transaction', cryptoCode)
|
||||||
|
return Promise.resolve({status: 'confirmed'})
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
NAME,
|
||||||
|
balance,
|
||||||
|
sendCoins,
|
||||||
|
newAddress,
|
||||||
|
getStatus
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
const configManager = require('./config-manager')
|
const configManager = require('./config-manager')
|
||||||
|
const ph = require('./plugin-helper')
|
||||||
|
|
||||||
function sendMessage (settings, rec) {
|
function sendMessage (settings, rec) {
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
const pluginCode = configManager.unscoped(settings.config).sms
|
const pluginCode = configManager.unscoped(settings.config).sms
|
||||||
|
const plugin = ph.load(ph.SMS, pluginCode)
|
||||||
if (!pluginCode) throw new Error('No sms plugin defined')
|
|
||||||
const account = settings.accounts[pluginCode]
|
const account = settings.accounts[pluginCode]
|
||||||
const plugin = require('lamassu-' + pluginCode)
|
|
||||||
|
|
||||||
return plugin.sendMessage(account, rec)
|
return plugin.sendMessage(account, rec)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
const mem = require('mem')
|
const mem = require('mem')
|
||||||
const configManager = require('./config-manager')
|
const configManager = require('./config-manager')
|
||||||
|
const ph = require('./plugin-helper')
|
||||||
|
|
||||||
const FETCH_INTERVAL = 10000
|
const FETCH_INTERVAL = 10000
|
||||||
function getRates (settings, fiatCode, cryptoCode) {
|
function getRates (settings, fiatCode, cryptoCode) {
|
||||||
|
|
@ -8,10 +9,8 @@ function getRates (settings, fiatCode, cryptoCode) {
|
||||||
const config = settings.config
|
const config = settings.config
|
||||||
const plugin = configManager.cryptoScoped(cryptoCode, config).ticker
|
const plugin = configManager.cryptoScoped(cryptoCode, config).ticker
|
||||||
|
|
||||||
if (!plugin) throw new Error('No ticker plugin defined')
|
|
||||||
|
|
||||||
const account = settings.accounts[plugin]
|
const account = settings.accounts[plugin]
|
||||||
const ticker = require('lamassu-' + plugin)
|
const ticker = ph.load(ph.TICKER, plugin)
|
||||||
|
|
||||||
return ticker.ticker(account, fiatCode, cryptoCode)
|
return ticker.ticker(account, fiatCode, cryptoCode)
|
||||||
.then(r => ({
|
.then(r => ({
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ const configManager = require('./config-manager')
|
||||||
const pify = require('pify')
|
const pify = require('pify')
|
||||||
const fs = pify(require('fs'))
|
const fs = pify(require('fs'))
|
||||||
const options = require('./options')
|
const options = require('./options')
|
||||||
|
const ph = require('./plugin-helper')
|
||||||
|
|
||||||
const FETCH_INTERVAL = 5000
|
const FETCH_INTERVAL = 5000
|
||||||
const INSUFFICIENT_FUNDS_CODE = 570
|
const INSUFFICIENT_FUNDS_CODE = 570
|
||||||
|
|
@ -29,8 +30,8 @@ function fetchWallet (settings, cryptoCode) {
|
||||||
.then(hex => {
|
.then(hex => {
|
||||||
const masterSeed = Buffer.from(hex.trim(), 'hex')
|
const masterSeed = Buffer.from(hex.trim(), 'hex')
|
||||||
const plugin = configManager.cryptoScoped(cryptoCode, settings.config).wallet
|
const plugin = configManager.cryptoScoped(cryptoCode, settings.config).wallet
|
||||||
|
const wallet = ph.load(ph.WALLET, plugin)
|
||||||
const account = settings.accounts[plugin]
|
const account = settings.accounts[plugin]
|
||||||
const wallet = require('lamassu-' + plugin)
|
|
||||||
|
|
||||||
return {wallet, account: _.set('seed', computeSeed(masterSeed), account)}
|
return {wallet, account: _.set('seed', computeSeed(masterSeed), account)}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
14
package.json
14
package.json
|
|
@ -6,8 +6,11 @@
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
"author": "Lamassu (https://lamassu.is)",
|
"author": "Lamassu (https://lamassu.is)",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"axios": "^0.16.1",
|
||||||
|
"base-x": "^3.0.0",
|
||||||
"bignumber.js": "^4.0.1",
|
"bignumber.js": "^4.0.1",
|
||||||
"bip39": "^2.3.0",
|
"bip39": "^2.3.0",
|
||||||
|
"bitcoind-rpc": "^0.7.0",
|
||||||
"body-parser": "^1.15.1",
|
"body-parser": "^1.15.1",
|
||||||
"cookie-parser": "^1.4.3",
|
"cookie-parser": "^1.4.3",
|
||||||
"express": "^4.13.4",
|
"express": "^4.13.4",
|
||||||
|
|
@ -15,16 +18,6 @@
|
||||||
"express-rate-limit": "^2.6.0",
|
"express-rate-limit": "^2.6.0",
|
||||||
"got": "^6.6.3",
|
"got": "^6.6.3",
|
||||||
"helmet": "^3.1.0",
|
"helmet": "^3.1.0",
|
||||||
"lamassu-bitcoind": "lamassu/lamassu-bitcoind#alpha",
|
|
||||||
"lamassu-bitgo": "lamassu/lamassu-bitgo#alpha",
|
|
||||||
"lamassu-bitpay": "lamassu/lamassu-bitpay#alpha",
|
|
||||||
"lamassu-bitstamp": "lamassu/lamassu-bitstamp#alpha",
|
|
||||||
"lamassu-kraken": "^1.1.1",
|
|
||||||
"lamassu-mailjet": "lamassu/lamassu-mailjet",
|
|
||||||
"lamassu-mock-id-verify": "lamassu/lamassu-mock-id-verify",
|
|
||||||
"lamassu-mock-sms": "lamassu/lamassu-mock-sms",
|
|
||||||
"lamassu-mock-wallet": "lamassu/lamassu-mock-wallet",
|
|
||||||
"lamassu-twilio": "lamassu/lamassu-twilio",
|
|
||||||
"lodash": "^4.17.2",
|
"lodash": "^4.17.2",
|
||||||
"mem": "^1.1.0",
|
"mem": "^1.1.0",
|
||||||
"migrate": "^0.2.2",
|
"migrate": "^0.2.2",
|
||||||
|
|
@ -32,6 +25,7 @@
|
||||||
"moment": "^2.17.0",
|
"moment": "^2.17.0",
|
||||||
"morgan": "^1.7.0",
|
"morgan": "^1.7.0",
|
||||||
"node-hkdf-sync": "^1.0.0",
|
"node-hkdf-sync": "^1.0.0",
|
||||||
|
"node-mailjet": "^3.0.6",
|
||||||
"numeral": "^2.0.3",
|
"numeral": "^2.0.3",
|
||||||
"pg": "^6.1.2",
|
"pg": "^6.1.2",
|
||||||
"pg-native": "latest",
|
"pg-native": "latest",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue