Skip to content

Commit

Permalink
Initial price feed backed by CoinGecko (#64)
Browse files Browse the repository at this point in the history
* Implement simple CoinGecko client.

* Refactor the ApplicationException to capture an original error and prevent
   information loss.

* Add price service backed by CoinGecko.

* 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.

* Format CoinGecko coin index JSON.

* Refactor error DTO to include origin error outside production.

* Reduce the no-restrict-imports of getAddress and isAddress message.

* Wire price service in the evaluation step

* Refactor source to asset ID into a repository

* Use a map for the chains constant to ensure engineers will handle the undefined
   possibility.

* Use constant to generate transfers seed

* Move chain constants to a central module

* Inject request price feed in the evaluation request

* Move address and hex schema to dedicated modules
  • Loading branch information
wcalderipe authored Jan 31, 2024
1 parent e93bc30 commit 0636da8
Show file tree
Hide file tree
Showing 45 changed files with 7,285 additions and 110 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 need to verify or retrieve an address regardless of its format, you should use the corresponding functions in `evm.util.ts`."
}
]
}
]
}
},
{
"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.
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { Approval, AuthorizationRequest, SignTransaction } from '@app/orchestrat
import { readRequestSchema } from '@app/orchestration/policy-engine/persistence/schema/request.schema'
import { readSignTransactionSchema } from '@app/orchestration/policy-engine/persistence/schema/sign-transaction.schema'
import { signatureSchema } from '@app/orchestration/policy-engine/persistence/schema/signature.schema'
import { Decision, Signature } from '@narval/authz-shared'
import { readTransactionRequestSchema } from '@app/orchestration/policy-engine/persistence/schema/transaction-request.schema'
import { Decision, Signature, TransactionRequest } from '@narval/authz-shared'
import { AuthorizationRequestStatus } from '@prisma/client/orchestration'
import { z } from 'zod'
import { Fixture } from 'zod-fixture'
Expand Down Expand Up @@ -33,6 +34,17 @@ const authorizationRequestSchema = z.object({
updatedAt: z.date()
})

export const generateTransactionRequest = (partial?: Partial<TransactionRequest>): TransactionRequest => {
const fixture = new Fixture()
.extend([hexGenerator, addressGenerator, chainIdGenerator])
.fromSchema(readTransactionRequestSchema)

return {
...fixture,
...partial
}
}

export const generateSignTransactionRequest = (partial?: Partial<SignTransaction>): SignTransaction => {
const fixture = new Fixture()
.extend([hexGenerator, addressGenerator, chainIdGenerator])
Expand Down
15 changes: 7 additions & 8 deletions apps/orchestration/src/__test__/fixture/shared.fixture.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import {
addressSchema,
hexSchema
} from '@app/orchestration/policy-engine/persistence/schema/transaction-request.schema'
import { CHAINS } from '@app/orchestration/orchestration.constant'
import { addressSchema } from '@app/orchestration/shared/schema/address.schema'
import { chainIdSchema } from '@app/orchestration/shared/schema/chain-id.schema'
import { hexSchema } from '@app/orchestration/shared/schema/hex.schema'
import { faker } from '@faker-js/faker'
import { sample } from 'lodash/fp'
import { z } from 'zod'
import { Generator } from 'zod-fixture'

export const hexGenerator = Generator({
Expand All @@ -18,7 +17,7 @@ export const addressGenerator = Generator({
})

export const chainIdGenerator = Generator({
schema: z.number().min(1),
filter: ({ transform, def }) => transform.utils.checks(def.checks).has('chainId'),
output: () => sample([1, 137])
schema: chainIdSchema,
filter: ({ context }) => context.path.at(-1) === 'chainId',
output: () => sample(Array.from(CHAINS.keys()))
})
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { addressGenerator, chainIdGenerator } from '@app/orchestration/__test__/fixture/shared.fixture'
import { addressSchema } from '@app/orchestration/policy-engine/persistence/schema/transaction-request.schema'
import { Transfer } from '@app/orchestration/shared/core/type/transfer-feed.type'
import { addressSchema } from '@app/orchestration/shared/schema/address.schema'
import { z } from 'zod'
import { Fixture } from 'zod-fixture'

Expand Down
8 changes: 1 addition & 7 deletions apps/orchestration/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,7 @@ const withSwagger = (app: INestApplication): INestApplication => {
.setVersion('1.0')
.build()
)
SwaggerModule.setup('docs', app, document, {
swaggerOptions: {
// Temporary disable the "Try it out" button while the API is just a
// placeholder.
supportedSubmitMethods: []
}
})
SwaggerModule.setup('docs', app, document)

return app
}
Expand Down
64 changes: 63 additions & 1 deletion apps/orchestration/src/orchestration.constant.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import { Chain, ChainId } from '@app/orchestration/shared/core/lib/chains.lib'
import { FiatId } from '@app/orchestration/shared/core/type/price.type'
import { AssetId } from '@narval/authz-shared'
import { BackoffOptions } from 'bull'

export const REQUEST_HEADER_ORG_ID = 'x-org-id'

//
// Queues
//

export const QUEUE_PREFIX = 'orchestration'

export const AUTHORIZATION_REQUEST_PROCESSING_QUEUE = 'authorization-request:processing'
Expand All @@ -9,4 +18,57 @@ export const AUTHORIZATION_REQUEST_PROCESSING_QUEUE_BACKOFF: BackoffOptions = {
delay: 1_000
}

export const REQUEST_HEADER_ORG_ID = 'x-org-id'
//
// Asset 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'

//
// Supported chains
//

export const ETHEREUM: Chain = {
id: ChainId.ETHEREUM,
isTestnet: false,
name: 'Ethereum Mainnet',
chain: 'ETH',
coin: {
id: 'eip155:1/slip44:60',
name: 'Ethereum',
symbol: 'ETH',
decimals: 18
},
coinGecko: {
coinId: 'ethereum',
platform: 'ethereum'
}
}

export const POLYGON: Chain = {
id: ChainId.POLYGON,
isTestnet: false,
name: 'Polygon Mainnet',
chain: 'Polygon',
coin: {
id: 'eip155:137/slip44:966',
name: 'Polygon',
symbol: 'MATIC',
decimals: 18
},
coinGecko: {
coinId: 'matic-network',
platform: 'polygon-pos'
}
}

/**
* @see https://chainid.network/chains.json
*/
export const CHAINS = new Map<number, Chain>([
[ChainId.ETHEREUM, ETHEREUM],
[ChainId.POLYGON, POLYGON]
])
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ import {
generateApproval,
generateAuthorizationRequest,
generateSignTransactionRequest,
generateSignature
generateSignature,
generateTransactionRequest
} from '@app/orchestration/__test__/fixture/authorization-request.fixture'
import { generateTransferFeed } from '@app/orchestration/__test__/fixture/transfer-feed.fixture'
import { FIAT_ID_USD } from '@app/orchestration/orchestration.constant'
import { AuthorizationRequestService } from '@app/orchestration/policy-engine/core/service/authorization-request.service'
import {
Approval,
Expand All @@ -18,31 +20,39 @@ import {
} from '@app/orchestration/policy-engine/http/client/authz-application.client'
import { AuthorizationRequestRepository } from '@app/orchestration/policy-engine/persistence/repository/authorization-request.repository'
import { AuthorizationRequestProcessingProducer } from '@app/orchestration/policy-engine/queue/producer/authorization-request-processing.producer'
import { PriceService } from '@app/orchestration/price/core/service/price.service'
import { ChainId } from '@app/orchestration/shared/core/lib/chains.lib'
import { Transfer } from '@app/orchestration/shared/core/type/transfer-feed.type'
import { TransferFeedService } from '@app/orchestration/transfer-feed/core/service/transfer-feed.service'
import { AccountId, Action, AssetId, Decision } from '@narval/authz-shared'
import { Action, Decision, getAccountId, getAssetId } from '@narval/authz-shared'
import { Intents, TransferNative } from '@narval/transaction-request-intent'
import { Test, TestingModule } from '@nestjs/testing'
import { mock } from 'jest-mock-extended'
import { MockProxy, mock } from 'jest-mock-extended'
import { times } from 'lodash/fp'

describe(AuthorizationRequestService.name, () => {
let module: TestingModule
let authzRequestRepositoryMock: AuthorizationRequestRepository
let authzRequestProcessingProducerMock: AuthorizationRequestProcessingProducer
let transferFeedServiceMock: TransferFeedService
let authzApplicationClientMock: AuthzApplicationClient
let authzRequestRepositoryMock: MockProxy<AuthorizationRequestRepository>
let authzRequestProcessingProducerMock: MockProxy<AuthorizationRequestProcessingProducer>
let transferFeedServiceMock: MockProxy<TransferFeedService>
let authzApplicationClientMock: MockProxy<AuthzApplicationClient>
let priceServiceMock: MockProxy<PriceService>
let service: AuthorizationRequestService

const authzRequest: AuthorizationRequest = generateAuthorizationRequest({
request: generateSignTransactionRequest()
request: generateSignTransactionRequest({
transactionRequest: generateTransactionRequest({
chainId: ChainId.POLYGON
})
})
})

beforeEach(async () => {
authzRequestRepositoryMock = mock<AuthorizationRequestRepository>()
authzRequestProcessingProducerMock = mock<AuthorizationRequestProcessingProducer>()
transferFeedServiceMock = mock<TransferFeedService>()
authzApplicationClientMock = mock<AuthzApplicationClient>()
priceServiceMock = mock<PriceService>()

module = await Test.createTestingModule({
providers: [
Expand All @@ -62,6 +72,10 @@ describe(AuthorizationRequestService.name, () => {
{
provide: AuthzApplicationClient,
useValue: authzApplicationClientMock
},
{
provide: PriceService,
useValue: priceServiceMock
}
]
}).compile()
Expand All @@ -77,9 +91,14 @@ describe(AuthorizationRequestService.name, () => {
approvals: [approval]
}

it('creates a new approval and evaluates the authorization request', async () => {
jest.spyOn(authzRequestRepositoryMock, 'update').mockResolvedValue(updatedAuthzRequest)
beforeEach(() => {
// To isolate the approve scenario, prevents the evaluation procedure to
// run by mocking it.
jest.spyOn(service, 'evaluate').mockResolvedValue(updatedAuthzRequest)
})

it('creates a new approval and evaluates the authorization request', async () => {
authzRequestRepositoryMock.update.mockResolvedValue(updatedAuthzRequest)

await service.approve(authzRequest.id, approval)

Expand All @@ -92,28 +111,41 @@ describe(AuthorizationRequestService.name, () => {
})

describe('evaluate', () => {
const matic = getAssetId('eip155:137/slip44:966')

const evaluationResponse: EvaluationResponse = {
decision: Decision.PERMIT,
request: authzRequest.request,
attestation: generateSignature(),
// TODO (@wcalderipe, 25/01/24): Revisit the types of
// @narval/transaction-request-intent with @pierre and start using a
// shared library.
transactionRequestIntent: {
type: Intents.TRANSFER_NATIVE,
amount: '1000000000000000000',
to: 'eip155:137:0x08a08d0504d4f3363a5b7fda1f5fff1c7bca8ad4' as AccountId,
from: 'eip155:137:0x90d03a8971a2faa19a9d7ffdcbca28fe826a289b' as AccountId,
token: 'eip155:137/slip44/966' as AssetId
to: getAccountId('eip155:137/0x08a08d0504d4f3363a5b7fda1f5fff1c7bca8ad4'),
from: getAccountId('eip155:137/0x90d03a8971a2faa19a9d7ffdcbca28fe826a289b'),
token: matic
}
}

const transfers: Transfer[] = times(() => generateTransferFeed({ orgId: authzRequest.orgId }), 2)

beforeEach(() => {
jest.spyOn(authzApplicationClientMock, 'evaluation').mockResolvedValue(evaluationResponse)
jest.spyOn(authzRequestRepositoryMock, 'update').mockResolvedValue(authzRequest)
jest.spyOn(transferFeedServiceMock, 'findByOrgId').mockResolvedValue(transfers)
authzApplicationClientMock.evaluation.mockResolvedValue(evaluationResponse)
authzRequestRepositoryMock.update.mockResolvedValue(authzRequest)
transferFeedServiceMock.findByOrgId.mockResolvedValue(transfers)
priceServiceMock.getPrices.mockResolvedValue({
[matic]: {
[FIAT_ID_USD]: 0.99
}
})
})

it('gets request assets prices', async () => {
await service.evaluate(authzRequest)

expect(priceServiceMock.getPrices).toHaveBeenNthCalledWith(1, {
from: [matic],
to: [FIAT_ID_USD]
})
})

it('calls authz application client', async () => {
Expand Down Expand Up @@ -147,6 +179,15 @@ describe(AuthorizationRequestService.name, () => {
})
})

it('gets transfer asset prices', async () => {
await service.evaluate(authzRequest)

expect(priceServiceMock.getPrices).toHaveBeenNthCalledWith(2, {
from: [matic],
to: [FIAT_ID_USD]
})
})

it('tracks approved transfer when signing a transaction', async () => {
await service.evaluate(authzRequest)

Expand Down
Loading

0 comments on commit 0636da8

Please sign in to comment.