Skip to content

Commit

Permalink
refactor(backend): rate convert methods to be explicit
Browse files Browse the repository at this point in the history
  • Loading branch information
BlairCurrey committed Nov 14, 2024
1 parent e6cad1e commit f120aab
Show file tree
Hide file tree
Showing 12 changed files with 133 additions and 91 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,14 @@ export const RafikiServicesFactory = Factory.define<MockRafikiServices>(
await accounting._getByIncomingToken(token)
}))
.attr('rates', {
convert: async (opts) => ({
convertSource: async (opts) => ({
amount: opts.sourceAmount,
scaledExchangeRate: 1
}),
convertDestination: async (opts) => ({
amount: opts.destinationAmount,
scaledExchangeRate: 1
}),
rates: () => {
throw new Error('unimplemented')
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export function createBalanceMiddleware(): ILPMiddleware {
}

const sourceAmount = BigInt(amount)
const destinationAmountOrError = await services.rates.convert({
const destinationAmountOrError = await services.rates.convertSource({
sourceAmount,
sourceAsset: accounts.incoming.asset,
destinationAsset: accounts.outgoing.asset
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ describe('Balance Middleware', function () {
ctx.response.fulfill = fulfill
})
const destinationAmount = BigInt(200)
jest.spyOn(rates, 'convert').mockImplementationOnce(async () => ({
jest.spyOn(rates, 'convertSource').mockImplementationOnce(async () => ({
amount: destinationAmount,
scaledExchangeRate: 1
}))
Expand Down
6 changes: 3 additions & 3 deletions packages/backend/src/payment-method/local/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ describe('LocalPaymentService', (): void => {
test('fails on unknown rate service error', async (): Promise<void> => {
const ratesService = await deps.use('ratesService')
jest
.spyOn(ratesService, 'convert')
.spyOn(ratesService, 'convertSource')
.mockImplementation(() => Promise.reject(new Error('fail')))

expect.assertions(4)
Expand Down Expand Up @@ -119,7 +119,7 @@ describe('LocalPaymentService', (): void => {
test('fails on rate service error', async (): Promise<void> => {
const ratesService = await deps.use('ratesService')
jest
.spyOn(ratesService, 'convert')
.spyOn(ratesService, 'convertSource')
.mockImplementation(() =>
Promise.resolve(ConvertError.InvalidDestinationPrice)
)
Expand Down Expand Up @@ -201,7 +201,7 @@ describe('LocalPaymentService', (): void => {
test('fails if receive amount is non-positive', async (): Promise<void> => {
const ratesService = await deps.use('ratesService')
jest
.spyOn(ratesService, 'convert')
.spyOn(ratesService, 'convertDestination')
.mockImplementation(() =>
Promise.resolve({ amount: 100n, scaledExchangeRate: 1 })
)
Expand Down
16 changes: 9 additions & 7 deletions packages/backend/src/payment-method/local/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import {
import {
ConvertError,
isConvertError,
RateConvertOpts,
RateConvertDestinationOpts,
RateConvertSourceOpts,
RatesService
} from '../../rates/service'
import { IAppConfig } from '../../config/app'
Expand Down Expand Up @@ -66,11 +67,14 @@ async function getQuote(
let exchangeRate: number

const convert = async (
opts: RateConvertOpts
opts: RateConvertSourceOpts | RateConvertDestinationOpts
): Promise<ConvertResults | ConvertError> => {
let convertResults: ConvertResults | ConvertError
try {
convertResults = await deps.ratesService.convert(opts)
convertResults =
'sourceAmount' in opts
? await deps.ratesService.convertSource(opts)
: await deps.ratesService.convertDestination(opts)
} catch (err) {
deps.logger.error(
{ opts, err },
Expand Down Expand Up @@ -114,8 +118,7 @@ async function getQuote(
} else if (receiveAmount) {
receiveAmountValue = receiveAmount.value
const convertResults = await convert({
reverseDirection: true,
sourceAmount: receiveAmountValue,
destinationAmount: receiveAmountValue,
sourceAsset: {
code: walletAddress.asset.code,
scale: walletAddress.asset.scale
Expand All @@ -139,8 +142,7 @@ async function getQuote(
} else if (receiver.incomingAmount) {
receiveAmountValue = receiver.incomingAmount.value
const convertResults = await convert({
reverseDirection: true,
sourceAmount: receiveAmountValue,
destinationAmount: receiveAmountValue,
sourceAsset: {
code: walletAddress.asset.code,
scale: walletAddress.asset.scale
Expand Down
18 changes: 9 additions & 9 deletions packages/backend/src/rates/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ describe('Rates service', function () {
await appContainer.shutdown()
})

describe('convert', () => {
describe('convertSource', () => {
beforeAll(() => {
mockRatesApi(exchangeRatesUrl, (base) => {
apiRequestCount++
Expand All @@ -76,7 +76,7 @@ describe('Rates service', function () {

it('returns the source amount when assets are alike', async () => {
await expect(
service.convert({
service.convertSource({
sourceAmount: 1234n,
sourceAsset: { code: 'USD', scale: 9 },
destinationAsset: { code: 'USD', scale: 9 }
Expand All @@ -90,7 +90,7 @@ describe('Rates service', function () {

it('scales the source amount when currencies are alike but scales are different', async () => {
await expect(
service.convert({
service.convertSource({
sourceAmount: 123n,
sourceAsset: { code: 'USD', scale: 9 },
destinationAsset: { code: 'USD', scale: 12 }
Expand All @@ -100,7 +100,7 @@ describe('Rates service', function () {
scaledExchangeRate: 1000
})
await expect(
service.convert({
service.convertSource({
sourceAmount: 123456n,
sourceAsset: { code: 'USD', scale: 12 },
destinationAsset: { code: 'USD', scale: 9 }
Expand All @@ -115,7 +115,7 @@ describe('Rates service', function () {
it('returns the converted amount when assets are different', async () => {
const sourceAmount = 500
await expect(
service.convert({
service.convertSource({
sourceAmount: BigInt(sourceAmount),
sourceAsset: { code: 'USD', scale: 2 },
destinationAsset: { code: 'EUR', scale: 2 }
Expand All @@ -125,7 +125,7 @@ describe('Rates service', function () {
scaledExchangeRate: exampleRates.USD.EUR
})
await expect(
service.convert({
service.convertSource({
sourceAmount: BigInt(sourceAmount),
sourceAsset: { code: 'EUR', scale: 2 },
destinationAsset: { code: 'USD', scale: 2 }
Expand All @@ -138,21 +138,21 @@ describe('Rates service', function () {

it('returns an error when an asset price is invalid', async () => {
await expect(
service.convert({
service.convertSource({
sourceAmount: 1234n,
sourceAsset: { code: 'USD', scale: 2 },
destinationAsset: { code: 'MISSING', scale: 2 }
})
).resolves.toBe(ConvertError.InvalidDestinationPrice)
await expect(
service.convert({
service.convertSource({
sourceAmount: 1234n,
sourceAsset: { code: 'USD', scale: 2 },
destinationAsset: { code: 'ZERO', scale: 2 }
})
).resolves.toBe(ConvertError.InvalidDestinationPrice)
await expect(
service.convert({
service.convertSource({
sourceAmount: 1234n,
sourceAsset: { code: 'USD', scale: 2 },
destinationAsset: { code: 'NEGATIVE', scale: 2 }
Expand Down
79 changes: 49 additions & 30 deletions packages/backend/src/rates/service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { BaseService } from '../shared/baseService'
import Axios, { AxiosInstance, isAxiosError } from 'axios'
import { convert, convertReverse, ConvertOptions, ConvertResults } from './util'
import {
ConvertResults,
ConvertSourceOptions,
ConvertDestinationOptions,
convertDestination,
convertSource
} from './util'
import { createInMemoryDataStore } from '../middleware/cache/data-stores/in-memory'
import { CacheDataStore } from '../middleware/cache/data-stores'

Expand All @@ -11,13 +17,20 @@ export interface Rates {
rates: Record<string, number>
}

export type RateConvertOpts = Omit<ConvertOptions, 'exchangeRate'> & {
reverseDirection?: boolean
}
export type RateConvertSourceOpts = Omit<ConvertSourceOptions, 'exchangeRate'>
export type RateConvertDestinationOpts = Omit<
ConvertDestinationOptions,
'exchangeRate'
>

export interface RatesService {
rates(baseAssetCode: string): Promise<Rates>
convert(opts: RateConvertOpts): Promise<ConvertResults | ConvertError>
convertSource(
opts: RateConvertSourceOpts
): Promise<ConvertResults | ConvertError>
convertDestination(
opts: RateConvertDestinationOpts
): Promise<ConvertResults | ConvertError>
}

interface ServiceDependencies extends BaseService {
Expand Down Expand Up @@ -53,42 +66,48 @@ class RatesServiceImpl implements RatesService {
this.cachedRates = createInMemoryDataStore(deps.exchangeRatesLifetime)
}

async convert(opts: RateConvertOpts): Promise<ConvertResults | ConvertError> {
const {
reverseDirection = false,
sourceAsset,
destinationAsset,
sourceAmount
} = opts

async convert<T extends RateConvertSourceOpts | RateConvertDestinationOpts>(
opts: T,
convertFn: (
opts: T & { exchangeRate: number }
) => ConvertResults | ConvertError
): Promise<ConvertResults | ConvertError> {
const { sourceAsset, destinationAsset } = opts
const sameCode = sourceAsset.code === destinationAsset.code
const sameScale = sourceAsset.scale === destinationAsset.scale
if (sameCode && sameScale)
return { amount: sourceAmount, scaledExchangeRate: 1 }

const conversionFn = reverseDirection ? convertReverse : convert
if (sameCode && sameScale) {
const amount =
'sourceAmount' in opts ? opts.sourceAmount : opts.destinationAmount
return {
amount,
scaledExchangeRate: 1
}
}

if (sameCode)
return conversionFn({
exchangeRate: 1.0,
sourceAsset,
destinationAsset,
sourceAmount
})
if (sameCode) {
return convertFn({ ...opts, exchangeRate: 1.0 })
}

const { rates } = await this.getRates(sourceAsset.code)

const destinationExchangeRate = rates[destinationAsset.code]
if (!destinationExchangeRate || !isValidPrice(destinationExchangeRate)) {
return ConvertError.InvalidDestinationPrice
}

return conversionFn({
exchangeRate: destinationExchangeRate,
sourceAsset,
destinationAsset,
sourceAmount
})
return convertFn({ ...opts, exchangeRate: destinationExchangeRate })
}

async convertSource(
opts: RateConvertSourceOpts
): Promise<ConvertResults | ConvertError> {
return this.convert(opts, convertSource)
}

async convertDestination(
opts: RateConvertDestinationOpts
): Promise<ConvertResults | ConvertError> {
return this.convert(opts, convertDestination)
}

async rates(baseAssetCode: string): Promise<Rates> {
Expand Down
34 changes: 17 additions & 17 deletions packages/backend/src/rates/util.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { convert, Asset, convertReverse } from './util'
import { convertSource, Asset, convertDestination } from './util'

describe('Rates util', () => {
describe('convert', () => {
Expand All @@ -20,7 +20,7 @@ describe('Rates util', () => {
expectedResult
}): Promise<void> => {
expect(
convert({
convertSource({
exchangeRate,
sourceAmount,
sourceAsset: createAsset(assetScale),
Expand All @@ -46,7 +46,7 @@ describe('Rates util', () => {
expectedResult
}): Promise<void> => {
expect(
convert({
convertSource({
exchangeRate,
sourceAmount,
sourceAsset: createAsset(sourceAssetScale),
Expand All @@ -60,23 +60,23 @@ describe('Rates util', () => {
describe('convert reverse', () => {
describe('convert same scales', () => {
test.each`
exchangeRate | sourceAmount | assetScale | expectedResult | description
${2.0} | ${100n} | ${9} | ${{ amount: 50n, scaledExchangeRate: 2.0 }} | ${'exchange rate above 1'}
${1.1602} | ${12345n} | ${2} | ${{ amount: 10641n, scaledExchangeRate: 1.1602 }} | ${'exchange rate above 1 with rounding up'}
${0.5} | ${100n} | ${9} | ${{ amount: 200n, scaledExchangeRate: 0.5 }} | ${'exchange rate below 1'}
${0.8611} | ${1000n} | ${2} | ${{ amount: 1162n, scaledExchangeRate: 0.8611 }} | ${'exchange rate below 1 with rounding up'}
exchangeRate | destinationAmount | assetScale | expectedResult | description
${2.0} | ${100n} | ${9} | ${{ amount: 50n, scaledExchangeRate: 2.0 }} | ${'exchange rate above 1'}
${1.1602} | ${12345n} | ${2} | ${{ amount: 10641n, scaledExchangeRate: 1.1602 }} | ${'exchange rate above 1 with rounding up'}
${0.5} | ${100n} | ${9} | ${{ amount: 200n, scaledExchangeRate: 0.5 }} | ${'exchange rate below 1'}
${0.8611} | ${1000n} | ${2} | ${{ amount: 1162n, scaledExchangeRate: 0.8611 }} | ${'exchange rate below 1 with rounding up'}
`(
'$description',
async ({
exchangeRate,
sourceAmount,
destinationAmount,
assetScale,
expectedResult
}): Promise<void> => {
expect(
convertReverse({
convertDestination({
exchangeRate,
sourceAmount,
destinationAmount,
sourceAsset: createAsset(assetScale),
destinationAsset: createAsset(assetScale)
})
Expand All @@ -87,22 +87,22 @@ describe('Rates util', () => {

describe('convert different scales', () => {
test.each`
exchangeRate | sourceAmount | sourceAssetScale | destinationAssetScale | expectedResult | description
${2.0} | ${100n} | ${9} | ${12} | ${{ amount: 50_000n, scaledExchangeRate: 0.002 }} | ${'convert scale from low to high'}
${2.0} | ${100_000n} | ${12} | ${9} | ${{ amount: 50n, scaledExchangeRate: 2000 }} | ${'convert scale from high to low'}
exchangeRate | destinationAmount | sourceAssetScale | destinationAssetScale | expectedResult | description
${2.0} | ${100n} | ${9} | ${12} | ${{ amount: 50_000n, scaledExchangeRate: 0.002 }} | ${'convert scale from low to high'}
${2.0} | ${100_000n} | ${12} | ${9} | ${{ amount: 50n, scaledExchangeRate: 2000 }} | ${'convert scale from high to low'}
`(
'$description',
async ({
exchangeRate,
sourceAmount,
destinationAmount,
sourceAssetScale,
destinationAssetScale,
expectedResult
}): Promise<void> => {
expect(
convertReverse({
convertDestination({
exchangeRate,
sourceAmount,
destinationAmount,
sourceAsset: createAsset(sourceAssetScale),
destinationAsset: createAsset(destinationAssetScale)
})
Expand Down
Loading

0 comments on commit f120aab

Please sign in to comment.