Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[BlockchainApi] Add ability to get exchange rates from/to cGLD or cUSD #2005

Merged
merged 18 commits into from
Dec 12, 2019
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/blockchain-api/.env
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
DEPLOY_ENV=local
EXCHANGE_RATES_API=https://apilayer.net/api
BLOCKSCOUT_API=https://integration-blockscout.celo-testnet.org/api
FIREBASE_DB=https://celo-org-mobile-int.firebaseio.com
FAUCET_ADDRESS=0x47e172F6CfB6c7D01C1574fa3E2Be7CC73269D95
VERIFICATION_REWARDS_ADDRESS=0xb4fdaf5f3cd313654aa357299ada901b1d2dd3b5
WEB3_PROVIDER_URL=https://integration-forno.celo-testnet.org
3 changes: 3 additions & 0 deletions packages/blockchain-api/.gcloudignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,6 @@ dist/
# Exclude dependencies
node_modules/
.env

# Exclude tests
*.test.ts
3 changes: 2 additions & 1 deletion packages/blockchain-api/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ dist/
# Exclude dependencies
node_modules/

# keys
# Exclude secrets
serviceAccountKey.json
src/secrets.json
2 changes: 2 additions & 0 deletions packages/blockchain-api/__mocks__/@celo/contractkit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Empty implementation because it currently adds ~8 secs for each test
// TODO: investigate why
2 changes: 2 additions & 0 deletions packages/blockchain-api/__mocks__/firebase-admin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const initializeApp = jest.fn()
export const database = jest.fn()
1 change: 1 addition & 0 deletions packages/blockchain-api/app.alfajores.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ env_variables:
DEPLOY_ENV: "alfajores"
EXCHANGE_RATES_API: "https://apilayer.net/api"
BLOCKSCOUT_API: "https://alfajores-blockscout.celo-testnet.org/api"
FIREBASE_DB: "https://celo-org-mobile-alfajores.firebaseio.com"
# TODO Pull addresses from the build artifacts of the network in protocol/build
FAUCET_ADDRESS: "0xCEa3eF8e187490A9d85A1849D98412E5D27D1Bb3"
VERIFICATION_REWARDS_ADDRESS: "0xb4fdaf5f3cd313654aa357299ada901b1d2dd3b5"
Expand Down
1 change: 1 addition & 0 deletions packages/blockchain-api/app.alfajoresstaging.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ env_variables:
DEPLOY_ENV: "alfajoresstaging"
EXCHANGE_RATES_API: "https://apilayer.net/api"
BLOCKSCOUT_API: "https://alfajoresstaging-blockscout.celo-testnet.org/api"
FIREBASE_DB: "https://celo-org-mobile-alfajoresstaging.firebaseio.com"
# TODO Pull addresses from the build artifacts of the network in protocol/build
FAUCET_ADDRESS: "0xF4314cb9046bECe6AA54bb9533155434d0c76909"
VERIFICATION_REWARDS_ADDRESS: "0xb4fdaf5f3cd313654aa357299ada901b1d2dd3b5"
Expand Down
1 change: 1 addition & 0 deletions packages/blockchain-api/app.integration.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ env_variables:
DEPLOY_ENV: "integration"
EXCHANGE_RATES_API: "https://apilayer.net/api"
BLOCKSCOUT_API: "https://integration-blockscout.celo-testnet.org/api"
FIREBASE_DB: "https://celo-org-mobile-int.firebaseio.com"
# TODO Pull addresses from the build artifacts of the network in protocol/build
FAUCET_ADDRESS: "0x47e172F6CfB6c7D01C1574fa3E2Be7CC73269D95"
VERIFICATION_REWARDS_ADDRESS: "0xb4fdaf5f3cd313654aa357299ada901b1d2dd3b5"
Expand Down
1 change: 1 addition & 0 deletions packages/blockchain-api/app.pilot.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ env_variables:
DEPLOY_ENV: "pilot"
EXCHANGE_RATES_API: "https://apilayer.net/api"
BLOCKSCOUT_API: "https://pilot-blockscout.celo-testnet.org/api"
FIREBASE_DB: "https://celo-org-mobile-pilot.firebaseio.com"
# TODO Pull addresses from the build artifacts of the network in protocol/build
FAUCET_ADDRESS: "0x387bCb16Bfcd37AccEcF5c9eB2938E30d3aB8BF2"
VERIFICATION_REWARDS_ADDRESS: "0xb4fdaf5f3cd313654aa357299ada901b1d2dd3b5"
Expand Down
1 change: 1 addition & 0 deletions packages/blockchain-api/app.pilotstaging.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ env_variables:
DEPLOY_ENV: "pilotstaging"
EXCHANGE_RATES_API: "https://apilayer.net/api"
BLOCKSCOUT_API: "https://pilotstaging-blockscout.celo-testnet.org/api"
FIREBASE_DB: "https://celo-org-mobile-pilotstaging.firebaseio.com"
# TODO Pull addresses from the build artifacts of the network in protocol/build
FAUCET_ADDRESS: "0x545DEBe3030B570731EDab192640804AC8Cf65CA"
VERIFICATION_REWARDS_ADDRESS: "0xb4fdaf5f3cd313654aa357299ada901b1d2dd3b5"
Expand Down
1 change: 1 addition & 0 deletions packages/blockchain-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"bignumber.js": "^7.2.0",
"dotenv": "^6.1.0",
"express": "^4.16.4",
"firebase-admin": "^8.6.1",
"graphql": "^14.1.1",
"utf8": "^3.0.0",
"web3-eth-abi": "1.0.0-beta.37"
Expand Down
Binary file not shown.
2 changes: 1 addition & 1 deletion packages/blockchain-api/src/apolloServer.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ApolloServer } from 'apollo-server-express'
import { BlockscoutAPI } from './blockscout'
import { CurrencyConversionAPI } from './currencyConversion'
import CurrencyConversionAPI from './currencyConversion/CurrencyConversionAPI'
import { resolvers, typeDefs } from './schema'

export interface DataSources {
Expand Down
24 changes: 24 additions & 0 deletions packages/blockchain-api/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,34 @@ function getSecrets(deployEnv: string) {
return envSecrets
}

export function getFirebaseAdminCreds(admin: any) {
// TODO: move project to celo-org-mobile
// until then, using serviceAccountKey for all envs
// tslint:disable-next-line: no-constant-condition
if (true /* DEPLOY_ENV === 'local' */) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove if (true) ? Or uncomment /* DEPLOY_ENV === 'local' */?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted to keep it this way so we can uncomment once we deploy it to celo-org-mobile.
Makes sense?

try {
const serviceAccount = require('../serviceAccountKey.json')
return admin.credential.cert(serviceAccount)
} catch (error) {
console.error(
'Error: Could not initialize admin credentials. Is serviceAccountKey.json missing?',
error
)
}
} else {
try {
return admin.credential.applicationDefault()
} catch (error) {
console.error('Error: Could not retrieve default app creds', error)
}
}
}

export const DEPLOY_ENV = (process.env.DEPLOY_ENV as string).toLowerCase()
export const EXCHANGE_RATES_API = (process.env.EXCHANGE_RATES_API as string).toLowerCase()
export const { EXCHANGE_RATES_API_ACCESS_KEY } = getSecrets(DEPLOY_ENV)
export const BLOCKSCOUT_API = (process.env.BLOCKSCOUT_API as string).toLowerCase()
export const FIREBASE_DB = process.env.FIREBASE_DB
export const FAUCET_ADDRESS = (process.env.FAUCET_ADDRESS as string).toLowerCase()
export const VERIFICATION_REWARDS_ADDRESS = (process.env
.VERIFICATION_REWARDS_ADDRESS as string).toLowerCase()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { InMemoryLRUCache } from 'apollo-server-caching'
import BigNumber from 'bignumber.js'
import CurrencyConversionAPI from './CurrencyConversionAPI'
import ExchangeRateAPI from './ExchangeRateAPI'
import GoldExchangeRateAPI from './GoldExchangeRateAPI'

jest.mock('./ExchangeRateAPI')
jest.mock('./GoldExchangeRateAPI')

const mockDefaultGetExchangeRate = ExchangeRateAPI.prototype.getExchangeRate as jest.Mock
mockDefaultGetExchangeRate.mockResolvedValue(new BigNumber(20))

const mockGoldGetExchangeRate = GoldExchangeRateAPI.prototype.getExchangeRate as jest.Mock
mockGoldGetExchangeRate.mockResolvedValue(new BigNumber(10))

describe('CurrencyConversionAPI', () => {
let currencyConversionAPI: CurrencyConversionAPI

beforeEach(() => {
jest.clearAllMocks()
currencyConversionAPI = new CurrencyConversionAPI()
currencyConversionAPI.initialize({ context: {}, cache: new InMemoryLRUCache() })
})

it('should retrieve rate for cGLD/cUSD', async () => {
const result = await currencyConversionAPI.getExchangeRate({
sourceCurrencyCode: 'cGLD',
currencyCode: 'cUSD',
})
expect(result).toEqual(new BigNumber(10))
expect(mockDefaultGetExchangeRate).toHaveBeenCalledTimes(0)
expect(mockGoldGetExchangeRate).toHaveBeenCalledTimes(1)
})

it('should retrieve rate for cUSD/cGLD', async () => {
const result = await currencyConversionAPI.getExchangeRate({
sourceCurrencyCode: 'cUSD',
currencyCode: 'cGLD',
})
expect(result).toEqual(new BigNumber(10))
expect(mockDefaultGetExchangeRate).toHaveBeenCalledTimes(0)
expect(mockGoldGetExchangeRate).toHaveBeenCalledTimes(1)
})

it('should retrieve rate for cGLD/USD', async () => {
const result = await currencyConversionAPI.getExchangeRate({
sourceCurrencyCode: 'cGLD',
currencyCode: 'USD',
})
expect(result).toEqual(new BigNumber(10))
expect(mockDefaultGetExchangeRate).toHaveBeenCalledTimes(0)
expect(mockGoldGetExchangeRate).toHaveBeenCalledTimes(1)
})

it('should retrieve rate for USD/cGLD', async () => {
const result = await currencyConversionAPI.getExchangeRate({
sourceCurrencyCode: 'USD',
currencyCode: 'cGLD',
})
expect(result).toEqual(new BigNumber(10))
expect(mockDefaultGetExchangeRate).toHaveBeenCalledTimes(0)
expect(mockGoldGetExchangeRate).toHaveBeenCalledTimes(1)
})

it('should retrieve rate for cGLD/MXN', async () => {
const result = await currencyConversionAPI.getExchangeRate({
sourceCurrencyCode: 'cGLD',
currencyCode: 'MXN',
})
expect(result).toEqual(new BigNumber(200))
expect(mockDefaultGetExchangeRate).toHaveBeenCalledTimes(1)
expect(mockGoldGetExchangeRate).toHaveBeenCalledTimes(1)
})

it('should retrieve rate for MXN/cGLD', async () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this test cover any additional logic from the MXN/cGLD one given the mocks?

Copy link
Contributor Author

@jeanregisser jeanregisser Dec 11, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They look really similar indeed but the idea was to cover both local to cGLD and cGLD to local.

const result = await currencyConversionAPI.getExchangeRate({
sourceCurrencyCode: 'MXN',
currencyCode: 'cGLD',
})
expect(result).toEqual(new BigNumber(200))
expect(mockDefaultGetExchangeRate).toHaveBeenCalledTimes(1)
expect(mockGoldGetExchangeRate).toHaveBeenCalledTimes(1)
})

it('should retrieve rate for USD/MXN', async () => {
const result = await currencyConversionAPI.getExchangeRate({
sourceCurrencyCode: 'USD',
currencyCode: 'MXN',
})
expect(result).toEqual(new BigNumber(20))
expect(mockDefaultGetExchangeRate).toHaveBeenCalledTimes(1)
expect(mockGoldGetExchangeRate).toHaveBeenCalledTimes(0)
})

it('should retrieve rate for MXN/USD', async () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same with this one- is there anything this test would catch that wouldn't be covered by the USD/MXN one?
Could also consider adding some tests for getConversionSteps

Copy link
Contributor Author

@jeanregisser jeanregisser Dec 11, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes similar to my comment above.
I decided not to test getConversionSteps directly to avoid testing the internals too much.

const result = await currencyConversionAPI.getExchangeRate({
sourceCurrencyCode: 'MXN',
currencyCode: 'USD',
})
expect(result).toEqual(new BigNumber(20))
expect(mockDefaultGetExchangeRate).toHaveBeenCalledTimes(1)
expect(mockGoldGetExchangeRate).toHaveBeenCalledTimes(0)
})

it('should retrieve rate for cUSD/MXN', async () => {
const result = await currencyConversionAPI.getExchangeRate({
sourceCurrencyCode: 'cUSD',
currencyCode: 'MXN',
})
expect(result).toEqual(new BigNumber(20))
expect(mockDefaultGetExchangeRate).toHaveBeenCalledTimes(1)
expect(mockGoldGetExchangeRate).toHaveBeenCalledTimes(0)
})

it('should retrieve rate for MXN/cUSD', async () => {
const result = await currencyConversionAPI.getExchangeRate({
sourceCurrencyCode: 'MXN',
currencyCode: 'cUSD',
})
expect(result).toEqual(new BigNumber(20))
expect(mockDefaultGetExchangeRate).toHaveBeenCalledTimes(1)
expect(mockGoldGetExchangeRate).toHaveBeenCalledTimes(0)
})

it('should return 1 when using the same currency code', async () => {
const result = await currencyConversionAPI.getExchangeRate({
sourceCurrencyCode: 'ABC',
currencyCode: 'ABC',
})
expect(result).toEqual(new BigNumber(1))
expect(mockDefaultGetExchangeRate).toHaveBeenCalledTimes(0)
expect(mockGoldGetExchangeRate).toHaveBeenCalledTimes(0)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { DataSource, DataSourceConfig } from 'apollo-datasource'
import BigNumber from 'bignumber.js'
import { CurrencyConversionArgs } from '../schema'
import { CGLD, CUSD, USD } from './consts'
import ExchangeRateAPI from './ExchangeRateAPI'
import GoldExchangeRateAPI from './GoldExchangeRateAPI'

function insertIf<T>(condition: boolean, element: T) {
return condition ? [element] : []
}

export default class CurrencyConversionAPI<TContext = any> extends DataSource {
exchangeRateAPI = new ExchangeRateAPI()
goldExchangeRateAPI = new GoldExchangeRateAPI()

initialize(config: DataSourceConfig<TContext>): void {
this.exchangeRateAPI.initialize(config)
this.goldExchangeRateAPI.initialize(config)
}

async getExchangeRate({
sourceCurrencyCode,
currencyCode,
timestamp,
}: CurrencyConversionArgs): Promise<BigNumber> {
const fromCode = sourceCurrencyCode || USD
const toCode = currencyCode

const steps = this.getConversionSteps(fromCode, toCode)

const ratesPromises = []
for (let i = 1; i < steps.length; i++) {
const prevCode = steps[i - 1]
const code = steps[i]
ratesPromises.push(this.getSupportedExchangeRate(prevCode, code, timestamp))
}

const rates = await Promise.all(ratesPromises)

// Multiply all rates
return rates.reduce((acc, rate) => acc.multipliedBy(rate), new BigNumber(1))
}

// Get conversion steps given the data we have today
// Going from cGLD to local currency (or vice versa) is currently assumed to be the same as cGLD -> cUSD -> USD -> local currency.
// And similar to cUSD to local currency, but with one less step.
private getConversionSteps(fromCode: string, toCode: string) {
if (fromCode === toCode) {
// Same code, nothing to do
return []
} else if (fromCode === CGLD || toCode === CGLD) {
// cGLD -> X (where X !== cUSD)
if (fromCode === CGLD && toCode !== CUSD) {
return [CGLD, CUSD, ...insertIf(toCode !== USD, USD), toCode]
}
// X -> cGLD (where X !== cUSD)
else if (fromCode !== CUSD && toCode === CGLD) {
return [fromCode, ...insertIf(fromCode !== USD, USD), CUSD, CGLD]
}
} else {
// cUSD -> X (where X !== USD)
if (fromCode === CUSD && toCode !== USD) {
return [CUSD, USD, toCode]
}
// X -> cUSD (where X !== USD)
else if (fromCode !== USD && toCode === CUSD) {
return [fromCode, USD, CUSD]
}
}

return [fromCode, toCode]
}

private getSupportedExchangeRate(
fromCode: string,
toCode: string,
timestamp?: number
): Promise<BigNumber> {
const pair = `${fromCode}/${toCode}`

if (pair === 'cUSD/USD' || pair === 'USD/cUSD') {
// TODO: use real rates once we have the data
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

return Promise.resolve(new BigNumber(1))
} else if (pair === 'cGLD/cUSD' || pair === 'cUSD/cGLD') {
return this.goldExchangeRateAPI.getExchangeRate({
sourceCurrencyCode: fromCode,
currencyCode: toCode,
timestamp,
})
} else {
return this.exchangeRateAPI.getExchangeRate({
sourceCurrencyCode: fromCode,
currencyCode: toCode,
timestamp,
})
}
}
}
Loading