Skip to content

Commit

Permalink
Live balances, transactions and prices (#9)
Browse files Browse the repository at this point in the history
* Adding CoinMarketCap types

* CoinMarketCap class to access the quotas(prices) of each token or coin

* Added type for query params for the prices endpoint

* Changing return type

* Changing return type

* Adding price endpoint

* Function to convert a new Date object into UTC epoch

* Adding loki js to cache the amount of requests we have performed to the coinmarketcap api

* Implementing lokijs to cache the request count and data from coinmarketcap

* Removing cache system and lokijs

* Adding types for metadata

* Removing error handling from the coinmarketcap lib

* Types for metadata response

* Adding sanitization functions for metadata and quote results

* Running linter

* Endpoint for prices.

* Reunning linter

* socket setup

* something proposal

* better emit event name

* Fixing types syntax

* push new balances working

* minor changes and handle disconnect

* Move parsing logic to coinmarketcap.ts

* Move files

* Refactor api

* Lint

* Refactor json result

* Add tests

* Add error handling tests - move validatoins to api

* review changes

* Getting new transactions from WS explorer api (#11)

* Lint

* Polling function for coinmarketcap prices

* Implemented pushNewPrices socket based on wallet address

* Implementing tokens addresses by chainId, 30 for mainnet and 31 for testnet

* Fixing tests

* Adding chainId parameter for the coinmarketcap class

* Lint

* Fixing merge conflicts

* Removing trailing comma

* Extends map between contract address and coin market cap

* Update list of supported tokens by coinmarketcap

* Filtering out unsupported tokens

* Commenting out the validatePricesRequest function which validates if the token is supported or not, since we are doing that inside the coinmarketcap function

* Commenting out the validatePricesRequest function which validates if the token is supported or not, since we are doing that inside the coinmarketcap function

* coinmarketcap api will fail if no ids passed therefore we added an if if there are no addresses

* adding an empty json as default value for prices, in case the wallet doesnt have any token yet, the backend wont crash because of the coinmarket cap api no receiving tokens ids

* Lint

* Adding only supported tokens

* exporting isConvertSupported function

* Lint

* Adding conditions to handle unsupported convert or addresses

* Modifying tests to handle new responses

* Adding chainId as parameter to the subscriptions

* Adding RBTC as default in the subscriptions

* Add caching strategy to coin market cap

* Fixing test for caching

* Migrate store in cache and filtering missing address

* Fixing name in test and add type for channel response

* Adding tests for caching

* Polling to get contract call transactions (#19)

* Enhancement/error handling (#17)

* Custom error class and errorhandler functio

* Removing errorhandler from here and using it in setupAPI

* Uncommenting the asserts that checked the text of the error message response

* Implementing next function in the error handlers of the promises

* Update src/middleware/index.ts

Co-authored-by: Ilan <36084092+ilanolkies@users.noreply.github.com>

* Update src/middleware/index.ts

Co-authored-by: Ilan <36084092+ilanolkies@users.noreply.github.com>

* Update src/api/index.ts

Co-authored-by: Ilan <36084092+ilanolkies@users.noreply.github.com>

* applying suggestions

Co-authored-by: Agustin Villalobos <agustin.villalobos@iovlabs.org>
Co-authored-by: Ilan <36084092+ilanolkies@users.noreply.github.com>

* Push transactions that have higher block number than the last sent

Co-authored-by: Agustin Villalobos V <agustinvillalobos@protonmail.com>
Co-authored-by: Christian Escalante <chescalante@gmail.com>
Co-authored-by: Ilan <ilanolkies@outlook.com>
Co-authored-by: Agustín Villalobos <avillaville@Agustins-MacBook-Pro.local>
Co-authored-by: sleyter93 <96137983+sleyter93@users.noreply.github.com>
Co-authored-by: Agustin Villalobos <agustin.villalobos@iovlabs.org>
Co-authored-by: Sleyter Sandoval <sleyter.sandoval@iovlabs.org>
Co-authored-by: Sleyter Sandoval <sleyter@Sleyters-MacBook-Pro.local>
Co-authored-by: Ilan <36084092+ilanolkies@users.noreply.github.com>
Co-authored-by: Agustín Villalobos <32603375+agustin-v@users.noreply.github.com>
  • Loading branch information
11 people committed Jan 27, 2022
1 parent efa7b4e commit c8f69e1
Show file tree
Hide file tree
Showing 19 changed files with 1,173 additions and 82 deletions.
756 changes: 747 additions & 9 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@
"@rsksmart/rsk-utils": "^1.1.0",
"axios": "^0.22.0",
"dotenv": "^10.0.0",
"express": "^4.17.1"
"express": "^4.17.1",
"node-cache": "^5.1.2",
"socket.io": "^4.4.0"
}
}
74 changes: 45 additions & 29 deletions src/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,69 +1,85 @@
import { Application, Request, Response } from 'express'
import { Application, NextFunction, Request, Response } from 'express'
import { RSKExplorerAPI } from '../rskExplorerApi'
import { CoinMarketCapAPI } from '../coinmatketcap'
import { CoinMarketCapAPI } from '../coinmarketcap'
import { registeredDapps as _registeredDapps } from '../registered_dapps'
import { PricesQueryParams } from './types'
import { validatePricesRequest } from '../coinmatketcap/validations'
import { isConvertSupported, isTokenSupported } from '../coinmarketcap/validations'
import NodeCache from 'node-cache'
import { findInCache, storeInCache } from '../coinmarketcap/priceCache'
import { errorHandler } from '../middleware'

const responseJsonOk = (res: Response) => res.status(200).json.bind(res)

const makeRequestFactory = (console) => async (req, res, query) => {
try {
console.log(req.url)
const result = await query()
res.status(200).json(result)
} catch (e: any) {
console.error(e)
res.status(500).send(e.message)
}
}

type APIOptions = {
rskExplorerApi: RSKExplorerAPI
coinMarketCapApi: CoinMarketCapAPI
registeredDapps: typeof _registeredDapps
priceCache: NodeCache
logger?: any
chainId: number
}

export const setupApi = (app: Application, {
rskExplorerApi, coinMarketCapApi, registeredDapps, logger = { log: () => {}, error: () => {} }
rskExplorerApi, coinMarketCapApi, registeredDapps, priceCache, chainId
}: APIOptions) => {
const makeRequest = makeRequestFactory(logger)

app.get('/tokens', (_: Request, res: Response) => rskExplorerApi.getTokens().then(res.status(200).json.bind(res)))
app.get('/tokens', (_: Request, res: Response, next: NextFunction) => rskExplorerApi.getTokens()
.then(res.status(200).json.bind(res))
.catch(next)
)

app.get(
'/address/:address/tokens',
({ params: { address } }: Request, res: Response) => rskExplorerApi.getTokensByAddress(address)
({ params: { address } }: Request, res: Response, next: NextFunction) => rskExplorerApi.getTokensByAddress(address)
.then(responseJsonOk(res))
.catch(next)
)

app.get(
'/address/:address/events',
({ params: { address } }: Request, res: Response) => rskExplorerApi.getEventsByAddress(address)
({ params: { address } }: Request, res: Response, next: NextFunction) => rskExplorerApi.getEventsByAddress(address)
.then(responseJsonOk(res))
.catch(next)
)

app.get(
'/address/:address/transactions',
({ params: { address }, query: { limit, prev, next } }: Request, res: Response) =>
({ params: { address }, query: { limit, prev, next } }: Request, res: Response, nextFunction: NextFunction) =>
rskExplorerApi.getTransactionsByAddress(
address, limit as string, prev as string, next as string
)
.then(responseJsonOk(res))
.catch(nextFunction)
)

app.get(
'/price',
async (req: Request<{}, {}, {}, PricesQueryParams>, res: Response) => makeRequest(
req, res, () => {
const addresses = req.query.addresses.split(',')
const convert = req.query.convert
validatePricesRequest(addresses, convert)
return coinMarketCapApi.getQuotesLatest({ addresses, convert })
}
)
(req: Request<{}, {}, {}, PricesQueryParams>, res: Response, next: NextFunction) => {
const addresses = req.query.addresses.split(',').filter((address) => isTokenSupported(address, chainId))
const convert = req.query.convert

if (!isConvertSupported(convert)) throw new Error('Convert not supported')

const isAddressesEmpty = addresses.length === 0
if (isAddressesEmpty) return responseJsonOk(res)({})

const { missingAddresses, pricesInCache } = findInCache(addresses, priceCache)
if (!missingAddresses.length) return responseJsonOk(res)(pricesInCache)

const prices = coinMarketCapApi.getQuotesLatest({ addresses: missingAddresses, convert })
prices
.then(pricesFromCMC => {
storeInCache(pricesFromCMC, priceCache)
const pricesRes = {
...pricesInCache,
...pricesFromCMC
}
return responseJsonOk(res)(pricesRes)
})
.catch(next)
}
)

app.get('/dapps', (_: Request, res: Response) => responseJsonOk(res)(registeredDapps))

app.use(errorHandler)
}
24 changes: 15 additions & 9 deletions src/coinmatketcap/index.ts → src/coinmarketcap/index.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
import _axios, { AxiosResponse } from 'axios'
import { ICoinMarketCapQuoteParams, ICoinMarketCapQuoteResponse } from './types'
import { addressToCoinmarketcapId } from './support'
import { isTokenSupported } from './validations'
import { Prices } from '../api/types'

type PricesQueryParams = { addresses: string[], convert: string }

const coinmarketcapIdToAddress = Object.keys(addressToCoinmarketcapId)
.reduce((p, c) => ({ ...p, [addressToCoinmarketcapId[c]]: c }), {})

const fromQueryParamsToRequestParams = (params: PricesQueryParams): ICoinMarketCapQuoteParams => ({
id: params.addresses.map(address => addressToCoinmarketcapId[address]).join(','),
const fromQueryParamsToRequestParams = (params: PricesQueryParams, chaindId: number): ICoinMarketCapQuoteParams => ({
id: params.addresses
.filter((address) => isTokenSupported(address, chaindId))
.map(address => addressToCoinmarketcapId[chaindId][address]).join(','),
convert: params.convert
})

const fromQuotesResponseToPrices =
(convert: string) =>
(convert: string, coinmarketcapIdToAddress: Record<string, string>) =>
(response: AxiosResponse<ICoinMarketCapQuoteResponse>) =>
Object.keys(response.data.data).reduce<Prices>((p, c) => ({
...p,
Expand All @@ -28,23 +28,29 @@ export class CoinMarketCapAPI {
headers: { 'X-CMC_PRO_API_KEY': string }
baseURL: string
axios: typeof _axios
chainId: number
coinmarketcapIdToAddress: Record<string, string>

constructor (
url: string,
version: string,
apiKey: string,
axios: typeof _axios
axios: typeof _axios,
chainId: number
) {
this.baseURL = `${url}/${version}`
this.headers = {
'X-CMC_PRO_API_KEY': apiKey
}
this.axios = axios
this.chainId = chainId
this.coinmarketcapIdToAddress = Object.keys(addressToCoinmarketcapId[chainId])
.reduce((p, c) => ({ ...p, [addressToCoinmarketcapId[chainId][c]]: c }), {})
}

getQuotesLatest = (queryParams: PricesQueryParams): Promise<Prices> => this.axios.get<ICoinMarketCapQuoteResponse>(
`${this.baseURL}/cryptocurrency/quotes/latest`, {
headers: this.headers,
params: fromQueryParamsToRequestParams(queryParams)
}).then(fromQuotesResponseToPrices(queryParams.convert!))
params: fromQueryParamsToRequestParams(queryParams, this.chainId)
}).then(fromQuotesResponseToPrices(queryParams.convert!, this.coinmarketcapIdToAddress))
}
21 changes: 21 additions & 0 deletions src/coinmarketcap/priceCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import NodeCache from 'node-cache'
import { Prices } from '../api/types'
import { IPriceCacheSearch } from './types'

export const findInCache = (addresses: string[], cache: NodeCache): IPriceCacheSearch => {
const response: IPriceCacheSearch = { missingAddresses: [], pricesInCache: {} }
addresses.forEach(address => {
if (cache.has(address)) {
response.pricesInCache = {
...response.pricesInCache,
...cache.get(address)
}
}
})
response.missingAddresses = addresses.filter(address => !Object.keys(response.pricesInCache).includes(address))
return response
}

export const storeInCache = (prices: Prices, cache: NodeCache): void => {
Object.keys(prices).forEach(address => cache.set(address, { [address]: prices[address] }), 60)
}
17 changes: 17 additions & 0 deletions src/coinmarketcap/support.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export const addressToCoinmarketcapId = {
30: {
'0x0000000000000000000000000000000000000000': '3626', // RBTC
'0x2acc95758f8b5f583470ba265eb685a8f45fc9d5': '3701', // RIF
'0xef213441a85df4d7acbdae0cf78004e1e486bb96': '825', // rUSDT
'0x4991516df6053121121274397a8c1dad608bc95b': '7785', // rBUND
'0xefc78fc7d48b64958315949279ba181c2114abbd': '8669' // SOV
},
31: {
'0x0000000000000000000000000000000000000000': '3626', // RBTC
'0x19f64674d8a5b4e652319f5e239efd3bc969a1fe': '3701', // tRIF
'0x4cfe225ce54c6609a525768b13f7d87432358c57': '825', // rKovUSDT
'0xe95afdfec031f7b9cd942eb7e60f053fb605dfcd': '7785' // rKovBUND
}
}

export const supportedFiat = ['USD']
7 changes: 7 additions & 0 deletions src/coinmatketcap/types.ts → src/coinmarketcap/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { Prices } from '../api/types'

export interface ICoinMarketCapQuoteParams {
id?: string;
slug?: string;
Expand Down Expand Up @@ -64,3 +66,8 @@ export interface ICoinMarketCapQuoteResponse {
status: IStatus;
data: ICryptocurrencyQuota;
}

export interface IPriceCacheSearch {
missingAddresses: string[];
pricesInCache: Prices
}
9 changes: 9 additions & 0 deletions src/coinmarketcap/validations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { addressToCoinmarketcapId, supportedFiat } from './support'

export const isTokenSupported = (address: string, chainId: number) => addressToCoinmarketcapId[chainId][address] !== undefined
export const isConvertSupported = (convert: string) => supportedFiat.includes(convert)

export const validatePricesRequest = (addresses: string[], convert: string, chainId: number) => {
addresses.forEach(address => { if (!isTokenSupported(address, chainId)) throw new Error('Token address not supported') })
if (!isConvertSupported(convert)) throw new Error('Convert not supported')
}
6 changes: 0 additions & 6 deletions src/coinmatketcap/support.ts

This file was deleted.

9 changes: 0 additions & 9 deletions src/coinmatketcap/validations.ts

This file was deleted.

63 changes: 56 additions & 7 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,16 @@ import 'dotenv/config'
import express from 'express'

import axios from 'axios'
import { CoinMarketCapAPI } from './coinmatketcap'
import { CoinMarketCapAPI } from './coinmarketcap'
import { RSKExplorerAPI } from './rskExplorerApi'
import { registeredDapps } from './registered_dapps'
import { setupApi } from './api'
import { Server } from 'socket.io'
import NodeCache from 'node-cache'
import http from 'http'
import pushNewBalances from './subscriptions/pushNewBalances'
import pushNewPrices from './subscriptions/pushNewPrices'
import pushNewTransactions from './subscriptions/pushNewTransactions'

const environment = {
// TODO: remove these defaults
Expand All @@ -16,21 +22,64 @@ const environment = {
CHAIN_ID: parseInt(process.env.CHAIN_ID as string) || 31,
COIN_MARKET_CAP_URL: process.env.COIN_MARKET_CAP_URL as string || 'https://pro-api.coinmarketcap.com',
COIN_MARKET_CAP_VERSION: process.env.COIN_MARKET_CAP_VERSION as string || 'v1',
COIN_MARKET_CAP_KEY: process.env.COIN_MARKET_CAP_KEY! as string
COIN_MARKET_CAP_KEY: process.env.COIN_MARKET_CAP_KEY! as string,
DEFAULT_CONVERT_FIAT: process.env.DEFAULT_CONVERT_FIAT! as string
}

const app = express()

const rskExplorerApi = new RSKExplorerAPI(environment.API_URL, environment.CHAIN_ID, axios)
const coinMarketCapApi = new CoinMarketCapAPI(environment.COIN_MARKET_CAP_URL, environment.COIN_MARKET_CAP_VERSION, environment.COIN_MARKET_CAP_KEY, axios)
const coinMarketCapApi = new CoinMarketCapAPI(
environment.COIN_MARKET_CAP_URL,
environment.COIN_MARKET_CAP_VERSION,
environment.COIN_MARKET_CAP_KEY,
axios,
environment.CHAIN_ID
)

const app = express()
const priceCache = new NodeCache()
setupApi(app, {
rskExplorerApi,
coinMarketCapApi,
registeredDapps,
logger: console
priceCache,
logger: console,
chainId: environment.CHAIN_ID
})

const server = http.createServer(app)
const io = new Server(server, {
// cors: {
// origin: 'https://amritb.github.io'
// },
path: '/ws'
})

io.on('connection', (socket) => {
console.log('new user connected')

socket.on('subscribe', ({ address }: { address: string }) => {
console.log('new subscription with address: ', address)

const stopPushingNewBalances = pushNewBalances(socket, rskExplorerApi, address)
const stopPushingNewTransactions = pushNewTransactions(socket, rskExplorerApi, address)
const stopPushingNewPrices = pushNewPrices(
socket,
rskExplorerApi,
coinMarketCapApi,
address,
environment.DEFAULT_CONVERT_FIAT,
environment.CHAIN_ID,
priceCache
)

socket.on('disconnect', () => {
stopPushingNewBalances()
stopPushingNewTransactions()
stopPushingNewPrices()
})
})
})

app.listen(environment.PORT, () => {
server.listen(environment.PORT, () => {
console.log(`RIF Wallet services running on port ${environment.PORT}.`)
})
17 changes: 17 additions & 0 deletions src/middleware/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { ErrorRequestHandler } from 'express'

export class CustomError extends Error {
status: number;
constructor (message: string, status: number) {
super(message)
this.status = status
}
}

export const errorHandler: ErrorRequestHandler = (error, req, res, next) => {
const status = error.status || 500
const message = error.message || 'Something went wrong'
console.error(error)
res.status(status).send(message)
next()
}
6 changes: 3 additions & 3 deletions src/rskExplorerApi/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,9 @@ export class RSKExplorerAPI {

async getTransactionsByAddress (
address:string,
limit: string | undefined,
prev: string | undefined,
next: string | undefined
limit?: string | undefined,
prev?: string | undefined,
next?: string | undefined
) {
const params = {
module: 'transactions',
Expand Down
6 changes: 6 additions & 0 deletions src/rskExplorerApi/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,9 @@ export interface IApiTransactions {
export interface TransactionsServerResponse {
data: IApiTransactions[];
}

export interface ChannelServerResponse {
channel: string;
action: string;
data: TransactionsServerResponse
}

0 comments on commit c8f69e1

Please sign in to comment.