-
Notifications
You must be signed in to change notification settings - Fork 378
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
Changes from 16 commits
e639642
f861a22
536356e
4ba55e3
a43bbe5
a304396
301317d
40bfc60
e056ab8
e78a8c6
941dfe2
b3ea316
cf0d86c
2082fd4
b4c3341
5630c37
475687b
a7abbe6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -6,3 +6,6 @@ dist/ | |
# Exclude dependencies | ||
node_modules/ | ||
.env | ||
|
||
# Exclude tests | ||
*.test.ts |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -6,5 +6,6 @@ dist/ | |
# Exclude dependencies | ||
node_modules/ | ||
|
||
# keys | ||
# Exclude secrets | ||
serviceAccountKey.json | ||
src/secrets.json |
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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
export const initializeApp = jest.fn() | ||
export const database = jest.fn() |
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 () => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does this test cover any additional logic from the There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 () => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes similar to my comment above. |
||
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] | ||
annakaz marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
// 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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👍 |
||
return Promise.resolve(new BigNumber(1)) | ||
annakaz marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} 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, | ||
}) | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
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' */?
There was a problem hiding this comment.
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?