From 87fec89c6442724e8a60c18286f118facd74c2db Mon Sep 17 00:00:00 2001 From: Alex Kuznicki Date: Fri, 31 Jan 2025 18:24:00 -0700 Subject: [PATCH 1/4] refactor price into a base transport add separate timeout for GLV info APIs tighten up vars + code fixing up tests --- .changeset/sour-glasses-travel.md | 5 + packages/composites/glv-token/README.md | 29 +- .../composites/glv-token/src/config/index.ts | 7 + .../glv-token/src/endpoint/index.ts | 1 + .../glv-token/src/endpoint/price.ts | 4 +- .../glv-token/src/transport/base.ts | 376 ++++++++++++++++++ .../glv-token/src/transport/price.ts | 376 ++---------------- 7 files changed, 429 insertions(+), 369 deletions(-) create mode 100644 .changeset/sour-glasses-travel.md create mode 100644 packages/composites/glv-token/src/transport/base.ts diff --git a/.changeset/sour-glasses-travel.md b/.changeset/sour-glasses-travel.md new file mode 100644 index 0000000000..302f01bd1a --- /dev/null +++ b/.changeset/sour-glasses-travel.md @@ -0,0 +1,5 @@ +--- +'@chainlink/glv-token-adapter': minor +--- + +Refactor price into a base transport diff --git a/packages/composites/glv-token/README.md b/packages/composites/glv-token/README.md index 098d81485d..73d80aad9b 100644 --- a/packages/composites/glv-token/README.md +++ b/packages/composites/glv-token/README.md @@ -6,20 +6,21 @@ This document was generated automatically. Please see [README Generator](../../s ## Environment Variables -| Required? | Name | Description | Type | Options | Default | -| :-------: | :--------------------------: | :---------------------------------------------------------------------------------------: | :----: | :-----: | :------------------------------------------: | -| ✅ | ARBITRUM_RPC_URL | RPC url of Arbitrum node | string | | | -| ✅ | ARBITRUM_CHAIN_ID | The chain id to connect to | number | | `42161` | -| ✅ | DATASTORE_CONTRACT_ADDRESS | Address of Data Store contract | string | | `0xFD70de6b91282D8017aA4E741e9Ae325CAb992d8` | -| ✅ | GLV_READER_CONTRACT_ADDRESS | Address of Glv Reader Contract | string | | `0x6a9505D0B44cFA863d9281EA5B0b34cB36243b45` | -| ✅ | TIINGO_ADAPTER_URL | URL of Tiingo EA | string | | | -| ✅ | NCFX_ADAPTER_URL | URL of NCFX EA | string | | | -| ✅ | COINMETRICS_ADAPTER_URL | URL of Coinmetrics EA | string | | | -| ✅ | MIN_REQUIRED_SOURCE_SUCCESS | Minimum number of source EAs that need to successfully return a value. | number | | `2` | -| ✅ | MARKET_INFO_API | URL market meta data supported by Glv | string | | `https://arbitrum-api.gmxinfra.io/markets` | -| ✅ | TOKEN_INFO_API | URL to token meta data supported by Glv | string | | `https://arbitrum-api.gmxinfra.io/tokens` | -| | METADATA_REFRESH_INTERVAL_MS | The amount of time the metadata should be refreshed | number | | `10800000` | -| | BACKGROUND_EXECUTE_MS | The amount of time the background execute should sleep before performing the next request | number | | `10000` | +| Required? | Name | Description | Type | Options | Default | +| :-------: | :--------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----: | :-----: | :------------------------------------------: | +| ✅ | ARBITRUM_RPC_URL | RPC url of Arbitrum node | string | | | +| ✅ | ARBITRUM_CHAIN_ID | The chain id to connect to | number | | `42161` | +| ✅ | DATASTORE_CONTRACT_ADDRESS | Address of Data Store contract | string | | `0xFD70de6b91282D8017aA4E741e9Ae325CAb992d8` | +| ✅ | GLV_READER_CONTRACT_ADDRESS | Address of Glv Reader Contract | string | | `0x6a9505D0B44cFA863d9281EA5B0b34cB36243b45` | +| ✅ | TIINGO_ADAPTER_URL | URL of Tiingo EA | string | | | +| ✅ | NCFX_ADAPTER_URL | URL of NCFX EA | string | | | +| ✅ | COINMETRICS_ADAPTER_URL | URL of Coinmetrics EA | string | | | +| ✅ | MIN_REQUIRED_SOURCE_SUCCESS | Minimum number of source EAs that need to successfully return a value. | number | | `2` | +| ✅ | MARKET_INFO_API | URL market meta data supported by Glv | string | | `https://arbitrum-api.gmxinfra.io/markets` | +| ✅ | TOKEN_INFO_API | URL to token meta data supported by Glv | string | | `https://arbitrum-api.gmxinfra.io/tokens` | +| | METADATA_REFRESH_INTERVAL_MS | The amount of time the metadata should be refreshed | number | | `10800000` | +| | BACKGROUND_EXECUTE_MS | The amount of time the background execute should sleep before performing the next request | number | | `10000` | +| | GLV_INFO_API_TIMEOUT_MS | The amount of time the request to the GLV info APIs should wait before timing out. Distinct from timeout used to make requests to the EAs which can be set with API_TIMEOUT | number | | `10000` | --- diff --git a/packages/composites/glv-token/src/config/index.ts b/packages/composites/glv-token/src/config/index.ts index ed94984e01..68ef164bc5 100644 --- a/packages/composites/glv-token/src/config/index.ts +++ b/packages/composites/glv-token/src/config/index.ts @@ -60,6 +60,13 @@ export const config = new AdapterConfig( required: true, default: 'https://arbitrum-api.gmxinfra.io/tokens', }, + GLV_INFO_API_TIMEOUT_MS: { + description: + 'The amount of time the request to the GLV info APIs should wait before timing out. ' + + 'Distinct from timeout used to make requests to the EAs which can be set with API_TIMEOUT', + type: 'number', + default: 10_000, + }, METADATA_REFRESH_INTERVAL_MS: { description: 'The amount of time the metadata should be refreshed', type: 'number', diff --git a/packages/composites/glv-token/src/endpoint/index.ts b/packages/composites/glv-token/src/endpoint/index.ts index 11a44912b4..ac9492a769 100644 --- a/packages/composites/glv-token/src/endpoint/index.ts +++ b/packages/composites/glv-token/src/endpoint/index.ts @@ -1 +1,2 @@ export { endpoint as price } from './price' +export { endpoint as lwba } from './lwba' diff --git a/packages/composites/glv-token/src/endpoint/price.ts b/packages/composites/glv-token/src/endpoint/price.ts index 14d9e27028..cf8b9b7cf4 100644 --- a/packages/composites/glv-token/src/endpoint/price.ts +++ b/packages/composites/glv-token/src/endpoint/price.ts @@ -2,7 +2,7 @@ import { AdapterEndpoint } from '@chainlink/external-adapter-framework/adapter' import { InputParameters } from '@chainlink/external-adapter-framework/validation' import { SingleNumberResultResponse } from '@chainlink/external-adapter-framework/util' import { config } from '../config' -import { glvTokenTransport } from '../transport/price' +import { glvPriceTransport } from '../transport/price' export const inputParameters = new InputParameters( { @@ -31,6 +31,6 @@ export type BaseEndpointTypes = { export const endpoint = new AdapterEndpoint({ name: 'price', - transport: glvTokenTransport, + transport: glvPriceTransport, inputParameters, }) diff --git a/packages/composites/glv-token/src/transport/base.ts b/packages/composites/glv-token/src/transport/base.ts new file mode 100644 index 0000000000..db307ba92a --- /dev/null +++ b/packages/composites/glv-token/src/transport/base.ts @@ -0,0 +1,376 @@ +import { ethers, utils } from 'ethers' +import { SubscriptionTransport } from '@chainlink/external-adapter-framework/transports/abstract/subscription' +import { TransportDependencies } from '@chainlink/external-adapter-framework/transports' +import { ResponseCache } from '@chainlink/external-adapter-framework/cache/response' +import { Requester } from '@chainlink/external-adapter-framework/util/requester' +import { + EndpointContext, + LwbaResponseDataFields, +} from '@chainlink/external-adapter-framework/adapter' +import { AdapterResponse, makeLogger } from '@chainlink/external-adapter-framework/util' +import { AdapterDataProviderError } from '@chainlink/external-adapter-framework/validation/error' +import glvAbi from '../config/glvReaderAbi.json' +import { BaseEndpointTypes, inputParameters } from '../endpoint/price' +import { + mapParameter, + mapSymbol, + Market, + median, + PriceData, + SIGNED_PRICE_DECIMALS, + Source, + toFixed, + Token, +} from './utils' +import { TypeFromDefinition } from '@chainlink/external-adapter-framework/validation/input-params' + +const logger = makeLogger('GlvBaseTransport') + +interface glvInformation { + glvToken: string + longToken: Token + shortToken: Token + markets: Record +} + +type RequestParams = typeof inputParameters.validated + +/** + * The base class contains all logic that is shared across both + * 'price' and 'lwba' variants. The child transports will override + * `formatResponse()` to produce different output shapes. + */ +export abstract class BaseGlvTransport< + T extends BaseEndpointTypes, +> extends SubscriptionTransport { + abstract backgroundHandler( + context: EndpointContext, + entries: TypeFromDefinition[], + ): Promise + + abstract handleRequest(param: TypeFromDefinition): Promise + + name!: string + responseCache!: ResponseCache + requester!: Requester + provider!: ethers.providers.JsonRpcProvider + glvReaderContract!: ethers.Contract + settings!: T['Settings'] + + tokensMap: Record = {} + marketsMap: Record = {} + decimals: Record = {} + + async initialize( + dependencies: TransportDependencies, + adapterSettings: T['Settings'], + endpointName: string, + transportName: string, + ): Promise { + await super.initialize(dependencies, adapterSettings, endpointName, transportName) + this.settings = adapterSettings + this.provider = new ethers.providers.JsonRpcProvider( + adapterSettings.ARBITRUM_RPC_URL, + adapterSettings.ARBITRUM_CHAIN_ID, + ) + this.requester = dependencies.requester + + this.glvReaderContract = new ethers.Contract( + adapterSettings.GLV_READER_CONTRACT_ADDRESS, + glvAbi, + this.provider, + ) + + await this.tokenInfo() + await this.marketInfo() + + if (this.settings.METADATA_REFRESH_INTERVAL_MS > 0) { + setInterval(() => { + this.tokenInfo() + this.marketInfo() + }, this.settings.METADATA_REFRESH_INTERVAL_MS) + } + } + + protected abstract formatResponse( + result: number, + minimizedValue: number, + maximizedValue: number, + sources: Record, + timestamps: { + providerDataRequestedUnixMs: number + providerDataReceivedUnixMs: number + providerIndicatedTimeUnixMs: undefined + }, + ): AdapterResponse + + async tokenInfo() { + const requestConfig = { + url: this.settings.TOKEN_INFO_API, + method: 'GET', + timeout: this.settings.GLV_INFO_API_TIMEOUT_MS, + } + + logger.info('Fetching token info') + const response = await this.requester.request<{ tokens: Token[] }>( + JSON.stringify(requestConfig), + requestConfig, + ) + + const data: Token[] = response.response.data.tokens + data.map((token) => { + this.tokensMap[token.address] = token + this.decimals[token.symbol] = token.decimals + }) + } + + async marketInfo() { + const requestConfig = { + url: this.settings.MARKET_INFO_API, + method: 'GET', + timeout: this.settings.GLV_INFO_API_TIMEOUT_MS, + } + + logger.info('Fetching market info') + const response = await this.requester.request<{ markets: Market[] }>( + JSON.stringify(requestConfig), + requestConfig, + ) + + const data: Market[] = response.response.data.markets + data.map((market) => { + this.marketsMap[market.marketToken] = market + }) + } + + async _handleRequest(param: RequestParams): Promise> { + const providerDataRequestedUnixMs = Date.now() + const glv_address = param.glv + + const glvInfo = await this.glvReaderContract.getGlvInfo( + this.settings.DATASTORE_CONTRACT_ADDRESS, + glv_address, + ) + + const glv: glvInformation = { + glvToken: glvInfo.glv.glvToken, + longToken: mapSymbol(glvInfo.glv.longToken, this.tokensMap), + shortToken: mapSymbol(glvInfo.glv.shortToken, this.tokensMap), + markets: {}, + } + + for (let i = 0; i < glvInfo.markets.length; i++) { + glv.markets[glvInfo.markets[i]] = mapSymbol(glvInfo.markets[i], this.marketsMap) + } + + const assets: Array = [glv.longToken.symbol, glv.shortToken.symbol] + Object.keys(glv.markets).forEach((m) => { + assets.push(mapSymbol(glv.markets[m].indexToken, this.tokensMap).symbol) + }) + + assets.sort() + const priceResult = await this.fetchPrices([...new Set(assets)], providerDataRequestedUnixMs) + + const indexTokensPrices: Array[] = [] + Object.keys(glv.markets).forEach((m) => { + const symbol = mapSymbol(glv.markets[m].indexToken, this.tokensMap).symbol + indexTokensPrices.push([priceResult.prices[symbol].bid, priceResult.prices[symbol].ask]) + }) + + const glvTokenPriceContractParams = [ + this.settings.DATASTORE_CONTRACT_ADDRESS, + glvInfo.markets, + indexTokensPrices, + [priceResult.prices[glv.longToken.symbol].bid, priceResult.prices[glv.longToken.symbol].ask], + [ + priceResult.prices[glv.shortToken.symbol].bid, + priceResult.prices[glv.shortToken.symbol].ask, + ], + glv_address, + ] + + const [[maximizedPriceRaw], [minimizedPriceRaw]] = await Promise.all([ + this.glvReaderContract.getGlvTokenPrice(...glvTokenPriceContractParams, true), + this.glvReaderContract.getGlvTokenPrice(...glvTokenPriceContractParams, false), + ]) + + const maximizedPrice = Number(utils.formatUnits(maximizedPriceRaw, SIGNED_PRICE_DECIMALS)) + const minimizedPrice = Number(utils.formatUnits(minimizedPriceRaw, SIGNED_PRICE_DECIMALS)) + const result = median([minimizedPrice, maximizedPrice]) + + const timestamps = { + providerDataRequestedUnixMs, + providerDataReceivedUnixMs: Date.now(), + providerIndicatedTimeUnixMs: undefined, + } + + return this.formatResponse( + result, + minimizedPrice, + maximizedPrice, + priceResult.sources, + timestamps, + ) + } + + private async fetchPrices(assets: string[], dataRequestedTimestamp: number) { + const priceData = {} as PriceData + + const sources = [ + { url: this.settings.TIINGO_ADAPTER_URL, name: 'tiingo' }, + { url: this.settings.COINMETRICS_ADAPTER_URL, name: 'coinmetrics' }, + { url: this.settings.NCFX_ADAPTER_URL, name: 'ncfx' }, + ] + + const priceProviders: Record = {} + const promises = [] + + for (let i = 0; i < sources.length; i++) { + const source = sources[i] + const assetPromises = assets.map(async (asset) => { + const mappedAsset = mapParameter(source.name, asset) + const base = this.unwrapAsset(mappedAsset) + const requestConfig = { + url: source.url, + method: 'POST', + data: { + data: { + endpoint: 'crypto-lwba', + base, + quote: 'USD', + }, + }, + } + + try { + const response = await this.requester.request<{ data: LwbaResponseDataFields['Data'] }>( + JSON.stringify(requestConfig), + requestConfig, + ) + const { bid, ask } = response.response.data.data + + priceData[asset] = { + bids: [...(priceData[asset]?.bids || []), bid], + asks: [...(priceData[asset]?.asks || []), ask], + } + + priceProviders[asset] = priceProviders[asset] + ? [...new Set([...priceProviders[asset], source.name])] + : [source.name] + } catch (error) { + const e = error as Error + logger.error( + `Error fetching data for ${asset} from ${source.name}, url - ${source.url}: ${e.message}`, + ) + } + }) + + promises.push(...assetPromises) + } + + await Promise.all(promises) + + this.validateRequiredResponses(priceProviders, sources, assets, dataRequestedTimestamp) + + const medianValues = this.calculateMedian(assets, priceData) + + const prices: Record> = {} + + medianValues.map( + (v) => + (prices[v.asset] = { + ...v, + ask: toFixed(v.ask, this.decimals[v.asset as keyof typeof this.decimals]), + bid: toFixed(v.bid, this.decimals[v.asset as keyof typeof this.decimals]), + }), + ) + + return { + prices, + sources: priceProviders, + } + } + + private calculateMedian(assets: string[], priceData: PriceData) { + return assets.map((asset) => { + const medianBid = median([...new Set(priceData[asset].bids)]) + const medianAsk = median([...new Set(priceData[asset].asks)]) + return { asset, bid: medianBid, ask: medianAsk } + }) + } + + private unwrapAsset(asset: string) { + if (asset === 'WBTC.b') { + return 'BTC' + } + if (asset === 'WETH') { + return 'ETH' + } + return asset + } + + private validateRequiredResponses( + priceProviders: Record = {}, + sources: Source[], + assets: string[], + dataRequestedTimestamp: number, + ) { + const allSource = sources.map((s) => s.name) + if (!Object.entries(priceProviders)?.length) { + throw new AdapterDataProviderError( + { + statusCode: 502, + message: `Missing responses from '${allSource.join(',')}' for all assets.`, + }, + { + providerDataRequestedUnixMs: dataRequestedTimestamp, + providerDataReceivedUnixMs: Date.now(), + providerIndicatedTimeUnixMs: undefined, + }, + ) + } + + assets.forEach((asset) => { + const base = this.unwrapAsset(asset) + const respondedSources = priceProviders[base] + + if (respondedSources.length < this.settings.MIN_REQUIRED_SOURCE_SUCCESS) { + const missingSources = allSource.filter((s) => !respondedSources.includes(s)) + logger.error(`Missing responses from '${missingSources.join(',')}' for asset: ${asset}`) + throw new AdapterDataProviderError( + { + statusCode: 502, + message: `Cannot calculate median price for '${asset}'. At least ${ + this.settings.MIN_REQUIRED_SOURCE_SUCCESS + } EAs are required to provide a response but response was received only from ${ + respondedSources.length + } EA(s). Missing responses from '${missingSources.join(',')}'.`, + }, + { + providerDataRequestedUnixMs: dataRequestedTimestamp, + providerDataReceivedUnixMs: Date.now(), + providerIndicatedTimeUnixMs: undefined, + }, + ) + } + }) + } + + protected handleError(e: unknown): AdapterResponse { + const errorMessage = e instanceof Error ? e.message : 'Unknown error occurred' + logger.error(e, errorMessage) + return { + statusCode: 502, + errorMessage, + timestamps: { + providerDataRequestedUnixMs: 0, + providerDataReceivedUnixMs: 0, + providerIndicatedTimeUnixMs: undefined, + }, + } as AdapterResponse + } + + getSubscriptionTtlFromConfig(adapterSettings: T['Settings']): number { + return adapterSettings.WARMUP_SUBSCRIPTION_TTL + } +} diff --git a/packages/composites/glv-token/src/transport/price.ts b/packages/composites/glv-token/src/transport/price.ts index 7332a0ba2d..02b25f4125 100644 --- a/packages/composites/glv-token/src/transport/price.ts +++ b/packages/composites/glv-token/src/transport/price.ts @@ -1,372 +1,42 @@ -import { TransportDependencies } from '@chainlink/external-adapter-framework/transports' -import { ResponseCache } from '@chainlink/external-adapter-framework/cache/response' -import { Requester } from '@chainlink/external-adapter-framework/util/requester' - -import { BaseEndpointTypes, inputParameters } from '../endpoint/price' -import { ethers, utils } from 'ethers' -import { SubscriptionTransport } from '@chainlink/external-adapter-framework/transports/abstract/subscription' -import { - EndpointContext, - LwbaResponseDataFields, -} from '@chainlink/external-adapter-framework/adapter' -import { AdapterResponse, makeLogger, sleep } from '@chainlink/external-adapter-framework/util' -import { - toFixed, - median, - PriceData, - SIGNED_PRICE_DECIMALS, - Source, - Token, - Market, - mapSymbol, - mapParameter, -} from './utils' -import glvAbi from '../config/glvReaderAbi.json' -import { AdapterDataProviderError } from '@chainlink/external-adapter-framework/validation/error' - -const logger = makeLogger('GlvToken') - -type RequestParams = typeof inputParameters.validated - -export type GlvTokenTransportTypes = BaseEndpointTypes - -interface glvInformation { - glvToken: string - longToken: Token - shortToken: Token - markets: Record -} - -export class GlvTokenTransport extends SubscriptionTransport { - name!: string - responseCache!: ResponseCache - requester!: Requester - provider!: ethers.providers.JsonRpcProvider - glvReaderContract!: ethers.Contract - settings!: GlvTokenTransportTypes['Settings'] - - tokensMap: Record = {} - marketsMap: Record = {} - decimals: Record = {} - - async initialize( - dependencies: TransportDependencies, - adapterSettings: GlvTokenTransportTypes['Settings'], - endpointName: string, - transportName: string, +import { BaseEndpointTypes } from '../endpoint/price' +import { BaseGlvTransport } from './base' +import { EndpointContext } from '@chainlink/external-adapter-framework/adapter' +import { TypeFromDefinition } from '@chainlink/external-adapter-framework/validation/input-params' +import { AdapterResponse, sleep } from '@chainlink/external-adapter-framework/util' + +export class GlvPriceTransport extends BaseGlvTransport { + async backgroundHandler( + context: EndpointContext, + entries: TypeFromDefinition[], ): Promise { - await super.initialize(dependencies, adapterSettings, endpointName, transportName) - this.settings = adapterSettings - this.provider = new ethers.providers.JsonRpcProvider( - adapterSettings.ARBITRUM_RPC_URL, - adapterSettings.ARBITRUM_CHAIN_ID, - ) - this.requester = dependencies.requester - - this.glvReaderContract = new ethers.Contract( - adapterSettings.GLV_READER_CONTRACT_ADDRESS, - glvAbi, - this.provider, - ) - - await this.tokenInfo() - await this.marketInfo() - - if (this.settings.METADATA_REFRESH_INTERVAL_MS > 0) { - setInterval(() => { - this.tokenInfo() - this.marketInfo() - }, this.settings.METADATA_REFRESH_INTERVAL_MS) - } - } - - async tokenInfo() { - const requestConfig = { - url: this.settings.TOKEN_INFO_API, - method: 'GET', - } - - const response = await this.requester.request<{ tokens: Token[] }>( - JSON.stringify(requestConfig), - requestConfig, - ) - - const data: Token[] = response.response.data.tokens - data.map((token) => { - this.tokensMap[token.address] = token - this.decimals[token.symbol] = token.decimals - }) - } - - async marketInfo() { - const requestConfig = { - url: this.settings.MARKET_INFO_API, - method: 'GET', - } - - const response = await this.requester.request<{ markets: Market[] }>( - JSON.stringify(requestConfig), - requestConfig, - ) - - const data: Market[] = response.response.data.markets - data.map((market) => { - this.marketsMap[market.marketToken] = market - }) - } - - async backgroundHandler(context: EndpointContext, entries: RequestParams[]) { await Promise.all(entries.map(async (param) => this.handleRequest(param))) await sleep(context.adapterSettings.BACKGROUND_EXECUTE_MS) } - async handleRequest(param: RequestParams) { + async handleRequest(param: TypeFromDefinition): Promise { let response: AdapterResponse try { response = await this._handleRequest(param) } catch (e) { - const errorMessage = e instanceof Error ? e.message : 'Unknown error occurred' - logger.error(e, errorMessage) - response = { - statusCode: 502, - errorMessage, - timestamps: { - providerDataRequestedUnixMs: 0, - providerDataReceivedUnixMs: 0, - providerIndicatedTimeUnixMs: undefined, - }, - } + response = this.handleError(e) } await this.responseCache.write(this.name, [{ params: param, response }]) } - async _handleRequest( - param: RequestParams, - ): Promise> { - const providerDataRequestedUnixMs = Date.now() - const glv_address = param.glv - - const glvInfo = await this.glvReaderContract.getGlvInfo( - this.settings.DATASTORE_CONTRACT_ADDRESS, - glv_address, - ) - - const glv: glvInformation = { - glvToken: glvInfo.glv.glvToken, - longToken: mapSymbol(glvInfo.glv.longToken, this.tokensMap), - shortToken: mapSymbol(glvInfo.glv.shortToken, this.tokensMap), - markets: {}, - } - - for (let i = 0; i < glvInfo.markets.length; i++) { - glv.markets[glvInfo.markets[i]] = mapSymbol(glvInfo.markets[i], this.marketsMap) - } - - const assets: Array = [glv.longToken.symbol, glv.shortToken.symbol] - Object.keys(glv.markets).forEach((m) => { - assets.push(mapSymbol(glv.markets[m].indexToken, this.tokensMap).symbol) - }) - - assets.sort() - const priceResult = await this.fetchPrices([...new Set(assets)], providerDataRequestedUnixMs) - - const indexTokensPrices: Array[] = [] - Object.keys(glv.markets).forEach((m) => { - const symbol = mapSymbol(glv.markets[m].indexToken, this.tokensMap).symbol - indexTokensPrices.push([priceResult.prices[symbol].ask, priceResult.prices[symbol].bid]) - }) - - const glvTokenPriceContractParams = [ - this.settings.DATASTORE_CONTRACT_ADDRESS, - glvInfo.markets, - indexTokensPrices, - [priceResult.prices[glv.longToken.symbol].ask, priceResult.prices[glv.longToken.symbol].bid], - [ - priceResult.prices[glv.shortToken.symbol].ask, - priceResult.prices[glv.shortToken.symbol].bid, - ], - glv_address, - ] - - // Prices have a spread from min to max. The last param (maximize-true/false) decides whether to maximize the market token price - // or not. We get both values and return the median. - - const [[maximizedValue], [minimizedValue]] = await Promise.all([ - this.glvReaderContract.getGlvTokenPrice(...glvTokenPriceContractParams, true), - this.glvReaderContract.getGlvTokenPrice(...glvTokenPriceContractParams, false), - ]) - - const maximizedPrice = Number(utils.formatUnits(maximizedValue, SIGNED_PRICE_DECIMALS)) - const minimizedPrice = Number(utils.formatUnits(minimizedValue, SIGNED_PRICE_DECIMALS)) - const result = median([minimizedPrice, maximizedPrice]) - + protected formatResponse( + result: number, + _minimizedValue: number, + _maximizedValue: number, + sources: Record, + timestamps: any, + ): AdapterResponse { return { - data: { - result: result, - sources: priceResult.sources, - }, + data: { result, sources }, statusCode: 200, - result: result, - timestamps: { - providerDataRequestedUnixMs, - providerDataReceivedUnixMs: Date.now(), - providerIndicatedTimeUnixMs: undefined, - }, - } - } - - // Fetches the lwba price info from multiple source EAs, calculates the median for bids and asks per asset and fixes the price precision - private async fetchPrices(assets: string[], dataRequestedTimestamp: number) { - // priceData holds raw bid/ask values per asset from source EAs response - const priceData = {} as PriceData - - const sources = [ - { url: this.settings.TIINGO_ADAPTER_URL, name: 'tiingo' }, - { url: this.settings.COINMETRICS_ADAPTER_URL, name: 'coinmetrics' }, - { url: this.settings.NCFX_ADAPTER_URL, name: 'ncfx' }, - ] - - //priceProviders contains assets with a list of sources where asset price was successfully fetched - const priceProviders: Record = {} - - const promises = [] - - for (let i = 0; i < sources.length; i++) { - const source = sources[i] - const assetPromises = assets.map(async (asset) => { - const mappedAsset = mapParameter(source.name, asset) - const base = this.unwrapAsset(mappedAsset) - const requestConfig = { - url: source.url, - method: 'POST', - data: { - data: { - endpoint: 'crypto-lwba', - base, - quote: 'USD', - }, - }, - } - - // try/catch is needed in a case if one of source EAs fails to return a response, - // we will still get and calculate the median price based on responses of remaining EAs (based on MIN_REQUIRED_SOURCE_SUCCESS setting) - try { - const response = await this.requester.request<{ data: LwbaResponseDataFields['Data'] }>( - JSON.stringify(requestConfig), - requestConfig, - ) - const { bid, ask } = response.response.data.data - - priceData[asset] = { - bids: [...(priceData[asset]?.bids || []), bid], - asks: [...(priceData[asset]?.asks || []), ask], - } - - priceProviders[asset] = priceProviders[asset] - ? [...new Set([...priceProviders[asset], source.name])] - : [source.name] - } catch (error) { - const e = error as Error - logger.error( - `Error fetching data for ${asset} from ${source.name}, url - ${source.url}: ${e.message}`, - ) - } - }) - - promises.push(...assetPromises) + result, + timestamps, } - - await Promise.all(promises) - - this.validateRequiredResponses(priceProviders, sources, assets, dataRequestedTimestamp) - - const medianValues = this.calculateMedian(assets, priceData) - - const prices: Record> = {} - - medianValues.map( - (v) => - (prices[v.asset] = { - ...v, - ask: toFixed(v.ask, this.decimals[v.asset as keyof typeof this.decimals]), - bid: toFixed(v.bid, this.decimals[v.asset as keyof typeof this.decimals]), - }), - ) - - return { - prices, - sources: priceProviders, - } - } - - private calculateMedian(assets: string[], priceData: PriceData) { - return assets.map((asset) => { - // Since most of the gm markets have the same long and index tokens, we need to remove duplicate values from duplicate requests - const medianBid = median([...new Set(priceData[asset].bids)]) - const medianAsk = median([...new Set(priceData[asset].asks)]) - return { asset, bid: medianBid, ask: medianAsk } - }) - } - - private unwrapAsset(asset: string) { - if (asset === 'WBTC.b') { - return 'BTC' - } - if (asset === 'WETH') { - return 'ETH' - } - return asset - } - - private validateRequiredResponses( - priceProviders: Record = {}, - sources: Source[], - assets: string[], - dataRequestedTimestamp: number, - ) { - const allSource = sources.map((s) => s.name) - if (!Object.entries(priceProviders)?.length) { - throw new AdapterDataProviderError( - { - statusCode: 502, - message: `Missing responses from '${allSource.join(',')}' for all assets.`, - }, - { - providerDataRequestedUnixMs: dataRequestedTimestamp, - providerDataReceivedUnixMs: Date.now(), - providerIndicatedTimeUnixMs: undefined, - }, - ) - } - - assets.forEach((asset) => { - const base = this.unwrapAsset(asset) - const respondedSources = priceProviders[base] - - if (respondedSources.length < this.settings.MIN_REQUIRED_SOURCE_SUCCESS) { - const missingSources = allSource.filter((s) => !respondedSources.includes(s)) - throw new AdapterDataProviderError( - { - statusCode: 502, - message: `Cannot calculate median price for '${asset}'. At least ${ - this.settings.MIN_REQUIRED_SOURCE_SUCCESS - } EAs are required to provide a response but response was received only from ${ - respondedSources.length - } EA(s). Missing responses from '${missingSources.join(',')}'.`, - }, - { - providerDataRequestedUnixMs: dataRequestedTimestamp, - providerDataReceivedUnixMs: Date.now(), - providerIndicatedTimeUnixMs: undefined, - }, - ) - } - }) - } - - getSubscriptionTtlFromConfig(adapterSettings: BaseEndpointTypes['Settings']): number { - return adapterSettings.WARMUP_SUBSCRIPTION_TTL } } -export const glvTokenTransport = new GlvTokenTransport() +export const glvPriceTransport = new GlvPriceTransport() From 1dd9ab13fab9787b0105cb00d42aedf55f8fe49e Mon Sep 17 00:00:00 2001 From: Alex Kuznicki Date: Mon, 3 Feb 2025 18:39:49 -0700 Subject: [PATCH 2/4] Refactor --- packages/composites/glv-token/src/endpoint/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/composites/glv-token/src/endpoint/index.ts b/packages/composites/glv-token/src/endpoint/index.ts index ac9492a769..11a44912b4 100644 --- a/packages/composites/glv-token/src/endpoint/index.ts +++ b/packages/composites/glv-token/src/endpoint/index.ts @@ -1,2 +1 @@ export { endpoint as price } from './price' -export { endpoint as lwba } from './lwba' From e017443c7b79f74f1db7037881de6b9fb31fca17 Mon Sep 17 00:00:00 2001 From: Alex Kuznicki Date: Tue, 4 Feb 2025 14:56:44 -0700 Subject: [PATCH 3/4] Adjust test data to use proper minimized/maximized glvTokenPrice result --- .../test/integration/__snapshots__/adapter.test.ts.snap | 4 ++-- .../composites/glv-token/test/integration/adapter.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/composites/glv-token/test/integration/__snapshots__/adapter.test.ts.snap b/packages/composites/glv-token/test/integration/__snapshots__/adapter.test.ts.snap index efd3687887..f4068dd3f8 100644 --- a/packages/composites/glv-token/test/integration/__snapshots__/adapter.test.ts.snap +++ b/packages/composites/glv-token/test/integration/__snapshots__/adapter.test.ts.snap @@ -3,7 +3,7 @@ exports[`execute price endpoint should return success 1`] = ` { "data": { - "result": 1.2895605309843814, + "result": 1.1471768303164502, "sources": { "ETH": [ "tiingo", @@ -17,7 +17,7 @@ exports[`execute price endpoint should return success 1`] = ` ], }, }, - "result": 1.2895605309843814, + "result": 1.1471768303164502, "statusCode": 200, "timestamps": { "providerDataReceivedUnixMs": 978347471111, diff --git a/packages/composites/glv-token/test/integration/adapter.test.ts b/packages/composites/glv-token/test/integration/adapter.test.ts index f3c56a696d..0c3959bf38 100644 --- a/packages/composites/glv-token/test/integration/adapter.test.ts +++ b/packages/composites/glv-token/test/integration/adapter.test.ts @@ -42,7 +42,7 @@ jest.mock('ethers', () => ({ if (maximize) { return [ { - _hex: '0x104681a4a04ccfed9f2e8d144e', + _hex: '0x0e7b25fe03f0eda42ead663c4f', _isBigNumber: true, }, { @@ -54,7 +54,7 @@ jest.mock('ethers', () => ({ } return [ { - _hex: '0x1047154bfc2ec20ad4c399c9cd', + _hex: '0x0e7a4edfc978cf077056d4bea6', _isBigNumber: true, }, { From 37b80b93fc63c1c649542c67cef0a826e871cd67 Mon Sep 17 00:00:00 2001 From: Alex Kuznicki Date: Tue, 4 Feb 2025 14:59:01 -0700 Subject: [PATCH 4/4] Adjust test data to use proper minimized/maximized glvTokenPrice result --- .../composites/glv-token/test/integration/adapter.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/composites/glv-token/test/integration/adapter.test.ts b/packages/composites/glv-token/test/integration/adapter.test.ts index 0c3959bf38..6c665080d6 100644 --- a/packages/composites/glv-token/test/integration/adapter.test.ts +++ b/packages/composites/glv-token/test/integration/adapter.test.ts @@ -42,7 +42,7 @@ jest.mock('ethers', () => ({ if (maximize) { return [ { - _hex: '0x0e7b25fe03f0eda42ead663c4f', + _hex: '0x0e7b25fe03f0eda42ead663c4f', // 1.1473068612168396 _isBigNumber: true, }, { @@ -54,7 +54,7 @@ jest.mock('ethers', () => ({ } return [ { - _hex: '0x0e7a4edfc978cf077056d4bea6', + _hex: '0x0e7a4edfc978cf077056d4bea6', // 1.1470467994160611 _isBigNumber: true, }, {