Skip to content

Commit

Permalink
Add price service backed by CoinGecko
Browse files Browse the repository at this point in the history
Add script to generate a JSON index of Asset ID to CoinGecko ID.

Add chain libs at the core to govern the supported chains in the application.

Add EVM utility to check and get address without regard of the given string
format.
  • Loading branch information
wcalderipe committed Jan 30, 2024
1 parent ad672a4 commit 6f515cf
Show file tree
Hide file tree
Showing 22 changed files with 6,686 additions and 17 deletions.
15 changes: 14 additions & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,20 @@
{
"files": ["*.ts", "*.tsx"],
"extends": ["plugin:@nx/typescript"],
"rules": {}
"rules": {
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "viem",
"importNames": ["getAddress", "isAddress"],
"message": "Please note the `getAddress` and `isAddress` functions work exclusively with checksummed addresses. If your requirement is to verify or retrieve an address regardless of its format, you should use the corresponding functions in `evm.util.ts`, which are designed to handle various address formats."
}
]
}
]
}
},
{
"files": ["*.js", "*.jsx"],
Expand Down
15 changes: 13 additions & 2 deletions apps/orchestration/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ orchestration/copy-default-env:
cp ${ORCHESTRATION_PROJECT_DIR}/.env.default ${ORCHESTRATION_PROJECT_DIR}/.env
cp ${ORCHESTRATION_PROJECT_DIR}/.env.test.default ${ORCHESTRATION_PROJECT_DIR}/.env.test

# === Build ===

orchestration/build/script:
npx tsc --project ${ORCHESTRATION_PROJECT_DIR}/tsconfig.app.json
npx tsc-alias --project ${ORCHESTRATION_PROJECT_DIR}/tsconfig.app.json

# == Code format ==

orchestration/format:
Expand Down Expand Up @@ -73,8 +79,7 @@ orchestration/db/create-migration:
# project and resolve its path aliases before running the vanilla JavaScript
# seed entry point.
orchestration/db/seed:
npx tsc --project ${ORCHESTRATION_PROJECT_DIR}/tsconfig.app.json
npx tsc-alias --project ${ORCHESTRATION_PROJECT_DIR}/tsconfig.app.json
make orchestration/build/script
npx dotenv -e ${ORCHESTRATION_PROJECT_DIR}/.env -- \
node dist/out-tsc/${ORCHESTRATION_PROJECT_DIR}/src/shared/module/persistence/seed.js

Expand Down Expand Up @@ -115,3 +120,9 @@ orchestration/test:
make orchestration/test/unit
make orchestration/test/integration
make orchestration/test/e2e

# === Price Module ===

orchestration/price/generate-coin-gecko-asset-id-index:
make orchestration/build/script
node dist/out-tsc/${ORCHESTRATION_PROJECT_DIR}/src/price/script/generate-coin-gecko-asset-id-index.script.js
23 changes: 23 additions & 0 deletions apps/orchestration/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,26 @@ make orchestration/lint
make orchestration/format/check
make orchestration/lint/check
```

## Price Module

The price module is tasked with fetching and maintaining cached price data from
various sources. It exclusively utilizes CAIP Asset ID as the dialect for its
API.

### Generate CoinGecko Asset ID Index

To accurately retrieve prices from the CoinGecko API, their internal Coin ID is
used. Due to the infrequent listing of relevant assets on CoinGecko, we maintain
a static index mapping Asset IDs to Coin IDs. This index is used to translate
inputs from the application to the CoinGecko.

```bash
make orchestration/price/generate-coin-gecko-asset-id-index
```

The script will write the index to [coin-gecko-asset-id-index.json](./src/price/resource/coin-gecko-asset-id-index.json).

> [!IMPORTANT]
> This script only includes assets from supported chains. If you introduce a new
> chain, you must rerun the script to update the static index file.
9 changes: 9 additions & 0 deletions apps/orchestration/src/orchestration.constant.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { FiatId } from '@app/orchestration/shared/core/type/price.type'
import { AssetId } from '@narval/authz-shared'
import { BackoffOptions } from 'bull'

export const QUEUE_PREFIX = 'orchestration'
Expand All @@ -10,3 +12,10 @@ export const AUTHORIZATION_REQUEST_PROCESSING_QUEUE_BACKOFF: BackoffOptions = {
}

export const REQUEST_HEADER_ORG_ID = 'x-org-id'

export const ASSET_ID_MAINNET_USDC: AssetId = 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'
export const ASSET_ID_POLYGON_USDC: AssetId = 'eip155:1/erc20:0x2791bca1f2de4661ed88a30c99a7a9449aa84174'

export const FIAT_ID_USD: FiatId = 'fiat:usd'

export const SUPPORTED_CHAIN_IDS = [1, 137]
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { isAddress, isHex } from 'viem'
import { isAddress } from '@narval/authz-shared'
import { isHex } from 'viem'
import { z } from 'zod'

/**
Expand Down
2 changes: 2 additions & 0 deletions apps/orchestration/src/policy-engine/policy-engine.module.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { AUTHORIZATION_REQUEST_PROCESSING_QUEUE } from '@app/orchestration/orchestration.constant'
import { AuthzApplicationClient } from '@app/orchestration/policy-engine/http/client/authz-application.client'
import { AuthorizationRequestController } from '@app/orchestration/policy-engine/http/rest/controller/authorization-request.controller'
import { PriceModule } from '@app/orchestration/price/price.module'
import { ApplicationExceptionFilter } from '@app/orchestration/shared/filter/application-exception.filter'
import { ZodExceptionFilter } from '@app/orchestration/shared/filter/zod-exception.filter'
import { PersistenceModule } from '@app/orchestration/shared/module/persistence/persistence.module'
Expand All @@ -23,6 +24,7 @@ import { AuthorizationRequestProcessingProducer } from './queue/producer/authori
HttpModule,
PersistenceModule,
TransferFeedModule,
PriceModule,
// TODO (@wcalderipe, 11/01/24): Figure out why can't I have a wrapper to
// register both queue and board at the same time.
//
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { ApplicationException } from '@app/orchestration/shared/exception/application.exception'

export class PriceException extends ApplicationException {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { ASSET_ID_MAINNET_USDC, FIAT_ID_USD } from '@app/orchestration/orchestration.constant'
import { PriceException } from '@app/orchestration/price/core/exception/price.exception'
import { PriceService } from '@app/orchestration/price/core/service/price.service'
import { CoinGeckoClient } from '@app/orchestration/price/http/client/coin-gecko/coin-gecko.client'
import { ETHEREUM, POLYGON } from '@app/orchestration/shared/core/lib/chains.lib'
import { getAssetId } from '@narval/authz-shared'
import { Test, TestingModule } from '@nestjs/testing'
import { mock } from 'jest-mock-extended'

describe(PriceService.name, () => {
let module: TestingModule
let service: PriceService
let coinGeckoClientMock: CoinGeckoClient

const SIMPLE_PRICE = {
ethereum: {
usd: 2313.8968819430966
},
'matic-network': {
usd: 0.8123732992684908
},
'usd-coin': {
usd: 1.000709110429112
}
}

beforeEach(async () => {
coinGeckoClientMock = mock<CoinGeckoClient>()

jest.spyOn(coinGeckoClientMock, 'getSimplePrice').mockResolvedValue(SIMPLE_PRICE)

module = await Test.createTestingModule({
providers: [
PriceService,
{
provide: CoinGeckoClient,
useValue: coinGeckoClientMock
}
]
}).compile()

service = module.get<PriceService>(PriceService)
})

describe('getPrices', () => {
it('converts asset id to coingecko id', async () => {
await service.getPrices({
from: [ETHEREUM.coin.id, POLYGON.coin.id, ASSET_ID_MAINNET_USDC],
to: [FIAT_ID_USD]
})

expect(coinGeckoClientMock.getSimplePrice).toHaveBeenCalledWith({
data: {
ids: ['ethereum', 'matic-network', 'usd-coin'],
vs_currencies: ['usd'],
precision: 18
}
})
})

it('responds with prices', async () => {
const prices = await service.getPrices({
from: [ETHEREUM.coin.id, POLYGON.coin.id, ASSET_ID_MAINNET_USDC],
to: [FIAT_ID_USD]
})

expect(prices).toEqual({
[ETHEREUM.coin.id]: {
[FIAT_ID_USD]: SIMPLE_PRICE.ethereum.usd
},
[POLYGON.coin.id]: {
[FIAT_ID_USD]: SIMPLE_PRICE['matic-network'].usd
},
[ASSET_ID_MAINNET_USDC]: {
[FIAT_ID_USD]: SIMPLE_PRICE['usd-coin'].usd
}
})
})

it('throws PriceException when given asset id does not exist on coin gecko index', () => {
expect(() =>
service.getPrices({
from: [getAssetId('eip155:00000/erc20:0x0000000000000000000000000000000000000000')],
to: [FIAT_ID_USD]
})
).rejects.toThrow(PriceException)
})
})
})
96 changes: 96 additions & 0 deletions apps/orchestration/src/price/core/service/price.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { PriceException } from '@app/orchestration/price/core/exception/price.exception'
import { CoinGeckoClient } from '@app/orchestration/price/http/client/coin-gecko/coin-gecko.client'
import { SimplePrice } from '@app/orchestration/price/http/client/coin-gecko/coin-gecko.type'
import CoinGeckoAssetIdIndex from '@app/orchestration/price/resource/coin-gecko-asset-id-index.json'
import { CHAINS } from '@app/orchestration/shared/core/lib/chains.lib'
import { FiatId, Price } from '@app/orchestration/shared/core/type/price.type'
import { AssetId, getAssetId, isCoin, parseAsset } from '@narval/authz-shared'
import { HttpStatus, Injectable } from '@nestjs/common'
import { compact } from 'lodash/fp'

type GetPricesOption = {
from: AssetId[]
to: FiatId[]
}

@Injectable()
export class PriceService {
constructor(private coinGeckoClient: CoinGeckoClient) {}

async getPrices(options: GetPricesOption): Promise<Price> {
const from = options.from.map(this.getCoinGeckoId)

if (from.some((id) => id === null)) {
throw new PriceException({
message: "Couldn't determine the source ID for the given asset ID",
suggestedHttpStatusCode: HttpStatus.BAD_REQUEST,
context: { options, from }
})
}

const prices = await this.coinGeckoClient.getSimplePrice({
data: {
ids: compact(from),
vs_currencies: options.to.map(this.getCoinGeckoCurrencyId),
precision: 18
}
})

return this.buildPrice(prices)
}

private buildPrice(prices: SimplePrice): Price {
return Object.keys(prices).reduce((acc, coinId) => {
const assetId = this.getAssetId(coinId)

if (assetId) {
return {
...acc,
[assetId]: Object.keys(prices[coinId]).reduce((result, fiat) => {
return {
...result,
[`fiat:${fiat}`]: prices[coinId].usd
}
}, {})
}
}

return acc
}, {})
}

private getAssetId(coinId: string): AssetId | null {
const chain = Object.values(CHAINS).find((chain) => chain.coinGecko.coinId === coinId)

if (chain) {
return chain.coin.id
}

for (const key in CoinGeckoAssetIdIndex) {
if (CoinGeckoAssetIdIndex[key as keyof typeof CoinGeckoAssetIdIndex] === coinId) {
return getAssetId(key)
}
}

return null
}

private getCoinGeckoId(assetId: AssetId): string | null {
const asset = parseAsset(assetId)
const chain = CHAINS[asset.chainId]

if (!chain) {
return null
}

if (isCoin(asset)) {
return chain.coinGecko.coinId
}

return CoinGeckoAssetIdIndex[assetId as keyof typeof CoinGeckoAssetIdIndex] || null
}

private getCoinGeckoCurrencyId(fiat: FiatId): string {
return fiat.replace('fiat:', '')
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { CoinGeckoException } from '@app/orchestration/price/http/client/coin-gecko/coin-gecko.exception'
import { SimplePrice, SimplePriceOption } from '@app/orchestration/price/http/client/coin-gecko/coin-gecko.type'
import {
CoinList,
SimplePrice,
SimplePriceOption
} from '@app/orchestration/price/http/client/coin-gecko/coin-gecko.type'
import { HttpService } from '@nestjs/axios'
import { HttpStatus, Injectable } from '@nestjs/common'
import { AxiosError, AxiosRequestConfig } from 'axios'
Expand All @@ -14,10 +18,10 @@ export class CoinGeckoClient {
static AUTH_HEADER = 'x-cg-pro-api-key'
static V3_URL = 'https://api.coingecko.com/api/v3'

getSimplePrice(options: SimplePriceOption): Promise<SimplePrice> {
async getSimplePrice(options: SimplePriceOption): Promise<SimplePrice> {
const request = {
method: 'get',
url: `${options.url}/simple/price`,
url: this.getEndpoint('/simple/price', options.url),
headers: {
...(options.apiKey && { [CoinGeckoClient.AUTH_HEADER]: options.apiKey })
},
Expand All @@ -40,6 +44,29 @@ export class CoinGeckoClient {
)
}

// IMPORTANT: used internally to build the static Asset ID to Coin ID index
// JSON.
async getCoinList(): Promise<CoinList> {
const request: AxiosRequestConfig = {
method: 'get',
url: `${CoinGeckoClient.V3_URL}/coins/list`,
params: {
include_platform: true
}
}

return lastValueFrom(
this.httpService.request<CoinList>(request).pipe(
map((response) => response.data),
catchError((error) => this.throwError(request, error))
)
)
}

private getEndpoint(path: string, url?: string): string {
return `${url || CoinGeckoClient.V3_URL}${path}`
}

private formatStringArray(value: string[]): string {
return value.map(lowerCase).join(',')
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
type CoinId = string

type Currency = 'usd' | 'eur'
type Currency = 'usd'

type Option<Data> = {
url: string
url?: string
apiKey?: string
data: Data
}
Expand All @@ -12,6 +12,7 @@ type Price = {
[C in Currency]: number
}

// TODO: Change this before merge.
type ChangeMetadata = {
[C in Currency as `${C}_24h_change`]?: number
}
Expand Down Expand Up @@ -41,3 +42,12 @@ type SimplePriceMetadata = ChangeMetadata &
}

export type SimplePrice = Record<CoinId, Price & SimplePriceMetadata>

export type Coin = {
id: string
symbol: string
name: string
platforms: Record<string, string>
}

export type CoinList = Coin[]
Loading

0 comments on commit 6f515cf

Please sign in to comment.