Skip to content

Commit 11c143f

Browse files
Refactors GM-token to support Botanix and dynamic fetching of tokens (#4077)
* Refactors to add Botanix chain and dynamic fetching of tokens
1 parent 60ad2f0 commit 11c143f

File tree

10 files changed

+462
-123
lines changed

10 files changed

+462
-123
lines changed

.changeset/popular-otters-brush.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@chainlink/gm-token-adapter': minor
3+
---
4+
5+
Support Botanix specific tokens

packages/composites/gm-token/src/config/index.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,57 @@ export const config = new AdapterConfig(
1414
required: true,
1515
default: 42161,
1616
},
17+
BOTANIX_RPC_URL: {
18+
description: 'RPC url of Botanix node',
19+
type: 'string',
20+
},
21+
BOTANIX_CHAIN_ID: {
22+
description: 'The chain id to connect to',
23+
type: 'number',
24+
default: 3637,
25+
},
1726
DATASTORE_CONTRACT_ADDRESS: {
1827
description: 'Address of Data Store contract',
1928
type: 'string',
2029
required: true,
2130
default: '0xFD70de6b91282D8017aA4E741e9Ae325CAb992d8',
2231
},
32+
BOTANIX_DATASTORE_CONTRACT_ADDRESS: {
33+
description: 'Address of Data Store contract',
34+
type: 'string',
35+
required: true,
36+
default: '0xA23B81a89Ab9D7D89fF8fc1b5d8508fB75Cc094d',
37+
},
2338
READER_CONTRACT_ADDRESS: {
2439
description: 'Address of Reader contract',
2540
type: 'string',
2641
required: true,
2742
default: '0xf60becbba223EEA9495Da3f606753867eC10d139',
2843
},
44+
BOTANIX_READER_CONTRACT_ADDRESS: {
45+
description: 'Address of Reader contract',
46+
type: 'string',
47+
required: true,
48+
default: '0xa254B60cbB85a92F6151B10E1233639F601f2F0F',
49+
},
50+
ARBITRUM_TOKENS_INFO_URL: {
51+
description: 'URL to token meta data supported by GMX on Arbitrum',
52+
type: 'string',
53+
required: true,
54+
default: 'https://arbitrum-api.gmxinfra.io/tokens',
55+
},
56+
BOTANIX_TOKENS_INFO_URL: {
57+
description: 'URL to token meta data supported by GMX on Botanix',
58+
type: 'string',
59+
required: true,
60+
default: 'https://botanix-api.gmxinfra.io/tokens',
61+
},
62+
GMX_TOKENS_CACHE_MS: {
63+
description: 'TTL in milliseconds for GMX tokens cache',
64+
type: 'number',
65+
required: true,
66+
default: 300_000,
67+
},
2968
PNL_FACTOR_TYPE: {
3069
description:
3170
'PnL factor type. See https://github.com/gmx-io/gmx-synthetics#market-token-price',

packages/composites/gm-token/src/endpoint/price.ts

Lines changed: 11 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import { AdapterEndpoint } from '@chainlink/external-adapter-framework/adapter'
22
import { InputParameters } from '@chainlink/external-adapter-framework/validation'
33
import { config } from '../config'
44
import { gmTokenTransport } from '../transport/price'
5-
import { tokenAddresses } from '../transport/utils'
6-
import { AdapterInputError } from '@chainlink/external-adapter-framework/validation/error'
5+
6+
export const CHAIN_OPTIONS = ['arbitrum', 'botanix']
77

88
export const inputParameters = new InputParameters(
99
{
@@ -28,17 +28,26 @@ export const inputParameters = new InputParameters(
2828
type: 'string',
2929
description: 'Market address of the market pool.',
3030
},
31+
chain: {
32+
description: 'Target chain for GM market',
33+
type: 'string',
34+
options: [...CHAIN_OPTIONS],
35+
default: 'arbitrum',
36+
},
3137
},
3238
[
3339
{
3440
index: 'LINK',
3541
long: 'LINK',
3642
short: 'USDC',
3743
market: '0x7f1fa204bb700853D36994DA19F830b6Ad18455C',
44+
chain: 'arbitrum',
3845
},
3946
],
4047
)
4148

49+
export type ChainKey = (typeof inputParameters.validated)['chain']
50+
4251
export type BaseEndpointTypes = {
4352
Parameters: typeof inputParameters.definition
4453
Response: {
@@ -55,26 +64,4 @@ export const endpoint = new AdapterEndpoint({
5564
name: 'price',
5665
transport: gmTokenTransport,
5766
inputParameters,
58-
customInputValidation: (req): AdapterInputError | undefined => {
59-
const { index, long, short } = req.requestContext.data
60-
const indexToken = tokenAddresses.arbitrum[index as keyof typeof tokenAddresses.arbitrum]
61-
const longToken = tokenAddresses.arbitrum[long as keyof typeof tokenAddresses.arbitrum]
62-
const shortToken = tokenAddresses.arbitrum[short as keyof typeof tokenAddresses.arbitrum]
63-
let invalidTokens = ''
64-
if (!indexToken) {
65-
invalidTokens += 'indexToken,'
66-
}
67-
if (!longToken) {
68-
invalidTokens += 'longToken,'
69-
}
70-
if (!shortToken) {
71-
invalidTokens += 'shortToken,'
72-
}
73-
if (invalidTokens.length) {
74-
throw new AdapterInputError({
75-
message: `Invalid ${invalidTokens} Must be one of ${Object.keys(tokenAddresses.arbitrum)}`,
76-
})
77-
}
78-
return
79-
},
8067
})

packages/composites/gm-token/src/transport/price.ts

Lines changed: 78 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,19 @@
1-
import { TransportDependencies } from '@chainlink/external-adapter-framework/transports'
21
import { ResponseCache } from '@chainlink/external-adapter-framework/cache/response'
2+
import { TransportDependencies } from '@chainlink/external-adapter-framework/transports'
33
import { Requester } from '@chainlink/external-adapter-framework/util/requester'
44

5-
import { BaseEndpointTypes, inputParameters } from '../endpoint/price'
6-
import { ethers, utils } from 'ethers'
7-
import { SubscriptionTransport } from '@chainlink/external-adapter-framework/transports/abstract/subscription'
85
import {
96
EndpointContext,
107
LwbaResponseDataFields,
118
} from '@chainlink/external-adapter-framework/adapter'
9+
import { SubscriptionTransport } from '@chainlink/external-adapter-framework/transports/abstract/subscription'
1210
import { AdapterResponse, makeLogger, sleep } from '@chainlink/external-adapter-framework/util'
13-
import {
14-
decimals,
15-
toFixed,
16-
median,
17-
PriceData,
18-
SIGNED_PRICE_DECIMALS,
19-
tokenAddresses,
20-
Source,
21-
} from './utils'
22-
import abi from './../config/readerAbi.json'
2311
import { AdapterDataProviderError } from '@chainlink/external-adapter-framework/validation/error'
12+
import { ethers, utils } from 'ethers'
13+
import { BaseEndpointTypes, ChainKey, inputParameters } from '../endpoint/price'
14+
import abi from './../config/readerAbi.json'
15+
import { TokenResolver } from './token-resolver'
16+
import { median, PriceData, SIGNED_PRICE_DECIMALS, Source, toFixed, unwrapAsset } from './utils'
2417

2518
const logger = makeLogger('GMToken')
2619

@@ -32,10 +25,11 @@ export class GmTokenTransport extends SubscriptionTransport<GmTokenTransportType
3225
name!: string
3326
responseCache!: ResponseCache<GmTokenTransportTypes>
3427
requester!: Requester
35-
provider!: ethers.providers.JsonRpcProvider
36-
readerContract!: ethers.Contract
3728
abiEncoder!: utils.AbiCoder
3829
settings!: GmTokenTransportTypes['Settings']
30+
tokenResolver!: TokenResolver
31+
private providers: Partial<Record<ChainKey, ethers.providers.JsonRpcProvider>> = {}
32+
private readers: Partial<Record<ChainKey, ethers.Contract>> = {}
3933

4034
async initialize(
4135
dependencies: TransportDependencies<GmTokenTransportTypes>,
@@ -45,16 +39,8 @@ export class GmTokenTransport extends SubscriptionTransport<GmTokenTransportType
4539
): Promise<void> {
4640
await super.initialize(dependencies, adapterSettings, endpointName, transportName)
4741
this.settings = adapterSettings
48-
this.provider = new ethers.providers.JsonRpcProvider(
49-
adapterSettings.ARBITRUM_RPC_URL,
50-
adapterSettings.ARBITRUM_CHAIN_ID,
51-
)
52-
this.readerContract = new ethers.Contract(
53-
adapterSettings.READER_CONTRACT_ADDRESS,
54-
abi,
55-
this.provider,
56-
)
5742
this.requester = dependencies.requester
43+
this.tokenResolver = new TokenResolver(this.requester, this.settings)
5844
}
5945

6046
async backgroundHandler(context: EndpointContext<BaseEndpointTypes>, entries: RequestParams[]) {
@@ -85,35 +71,40 @@ export class GmTokenTransport extends SubscriptionTransport<GmTokenTransportType
8571
async _handleRequest(
8672
param: RequestParams,
8773
): Promise<AdapterResponse<GmTokenTransportTypes['Response']>> {
88-
const { index, long, short, market } = param
74+
const { index, long, short, market, chain } = param
8975

9076
const assets = [index, long, short]
9177

9278
const providerDataRequestedUnixMs = Date.now()
9379

80+
const [indexToken, longToken, shortToken] = await Promise.all([
81+
this.tokenResolver.getToken(chain, index),
82+
this.tokenResolver.getToken(chain, long),
83+
this.tokenResolver.getToken(chain, short),
84+
])
85+
const decimalsMap = new Map(
86+
[indexToken, longToken, shortToken].map((t) => [t.symbol, t.decimals]),
87+
)
88+
9489
const {
9590
prices: [indexPrices, longPrices, shortPrices],
9691
sources,
97-
} = await this.fetchPrices(assets, providerDataRequestedUnixMs)
98-
99-
const indexToken = tokenAddresses.arbitrum[index as keyof typeof tokenAddresses.arbitrum]
100-
const longToken = tokenAddresses.arbitrum[long as keyof typeof tokenAddresses.arbitrum]
101-
const shortToken = tokenAddresses.arbitrum[short as keyof typeof tokenAddresses.arbitrum]
92+
} = await this.fetchPrices(assets, providerDataRequestedUnixMs, decimalsMap)
10293

10394
const tokenPriceContractParams = [
104-
this.settings.DATASTORE_CONTRACT_ADDRESS,
105-
[market, indexToken, longToken, shortToken],
95+
this.getDatastoreContractAddress(chain as ChainKey),
96+
[market, indexToken.address, longToken.address, shortToken.address],
10697
[indexPrices.ask, indexPrices.bid],
10798
[longPrices.ask, longPrices.bid],
10899
[shortPrices.ask, shortPrices.bid],
109100
utils.keccak256(utils.defaultAbiCoder.encode(['string'], [this.settings.PNL_FACTOR_TYPE])),
110101
]
111-
102+
const readerContract = this.getReaderContract(chain)
112103
// Prices have a spread from min to max. The last param (maximize-true/false) decides whether to maximize the market token price
113104
// or not. We get both values and return the median.
114105
const [[maximizedValue], [minimizedValue]] = await Promise.all([
115-
this.readerContract.getMarketTokenPrice(...tokenPriceContractParams, true),
116-
this.readerContract.getMarketTokenPrice(...tokenPriceContractParams, false),
106+
readerContract.getMarketTokenPrice(...tokenPriceContractParams, true),
107+
readerContract.getMarketTokenPrice(...tokenPriceContractParams, false),
117108
])
118109

119110
const maximizedPrice = Number(utils.formatUnits(maximizedValue, SIGNED_PRICE_DECIMALS))
@@ -136,7 +127,11 @@ export class GmTokenTransport extends SubscriptionTransport<GmTokenTransportType
136127
}
137128

138129
// Fetches the lwba price info from multiple source EAs, calculates the median for bids and asks per asset and fixes the price precision
139-
private async fetchPrices(assets: string[], dataRequestedTimestamp: number) {
130+
private async fetchPrices(
131+
assets: string[],
132+
dataRequestedTimestamp: number,
133+
decimals: Map<string, number>,
134+
) {
140135
// priceData holds raw bid/ask values per asset from source EAs response
141136
const priceData = {} as PriceData
142137

@@ -155,7 +150,7 @@ export class GmTokenTransport extends SubscriptionTransport<GmTokenTransportType
155150
const source = sources[i]
156151

157152
const assetPromises = assets.map(async (asset) => {
158-
const base = this.unwrapAsset(asset)
153+
const base = unwrapAsset(asset)
159154
const requestConfig = {
160155
url: source.url,
161156
method: 'POST',
@@ -202,11 +197,17 @@ export class GmTokenTransport extends SubscriptionTransport<GmTokenTransportType
202197

203198
const medianValues = this.calculateMedian(assets, priceData)
204199

205-
const prices = medianValues.map((v) => ({
206-
...v,
207-
ask: toFixed(v.ask, decimals[v.asset as keyof typeof decimals]),
208-
bid: toFixed(v.bid, decimals[v.asset as keyof typeof decimals]),
209-
}))
200+
const prices = medianValues.map((v) => {
201+
const decimal = decimals.get(v.asset)
202+
if (!decimal) {
203+
throw new Error(`Missing token decimals for '${v.asset}'`)
204+
}
205+
return {
206+
...v,
207+
ask: toFixed(v.ask, decimal),
208+
bid: toFixed(v.bid, decimal),
209+
}
210+
})
210211

211212
return {
212213
prices,
@@ -223,16 +224,6 @@ export class GmTokenTransport extends SubscriptionTransport<GmTokenTransportType
223224
})
224225
}
225226

226-
private unwrapAsset(asset: string) {
227-
if (asset === 'WBTC.b') {
228-
return 'BTC'
229-
}
230-
if (asset === 'WETH') {
231-
return 'ETH'
232-
}
233-
return asset
234-
}
235-
236227
/*
237228
For every asset check that we received responses from the required number of source EAs to accurately calculate the median price of the asset.
238229
*/
@@ -258,7 +249,7 @@ export class GmTokenTransport extends SubscriptionTransport<GmTokenTransportType
258249
}
259250

260251
assets.forEach((asset) => {
261-
const base = this.unwrapAsset(asset)
252+
const base = unwrapAsset(asset)
262253
const respondedSources = priceProviders[base]
263254

264255
if (respondedSources.length < this.settings.MIN_REQUIRED_SOURCE_SUCCESS) {
@@ -282,6 +273,39 @@ export class GmTokenTransport extends SubscriptionTransport<GmTokenTransportType
282273
})
283274
}
284275

276+
private getProvider(chain: ChainKey): ethers.providers.JsonRpcProvider {
277+
if (this.providers[chain]) return this.providers[chain]!
278+
const p =
279+
chain === 'botanix'
280+
? new ethers.providers.JsonRpcProvider(
281+
this.settings.BOTANIX_RPC_URL,
282+
this.settings.BOTANIX_CHAIN_ID,
283+
)
284+
: new ethers.providers.JsonRpcProvider(
285+
this.settings.ARBITRUM_RPC_URL,
286+
this.settings.ARBITRUM_CHAIN_ID,
287+
)
288+
this.providers[chain] = p
289+
return p
290+
}
291+
292+
private getReaderContract(chain: ChainKey): ethers.Contract {
293+
if (this.readers[chain]) return this.readers[chain]!
294+
const addr =
295+
chain === 'botanix'
296+
? this.settings.BOTANIX_READER_CONTRACT_ADDRESS
297+
: this.settings.READER_CONTRACT_ADDRESS
298+
const reader = new ethers.Contract(addr, abi, this.getProvider(chain))
299+
this.readers[chain] = reader
300+
return reader
301+
}
302+
303+
private getDatastoreContractAddress(chain: ChainKey): string {
304+
return chain === 'botanix'
305+
? this.settings.BOTANIX_DATASTORE_CONTRACT_ADDRESS
306+
: this.settings.DATASTORE_CONTRACT_ADDRESS
307+
}
308+
285309
getSubscriptionTtlFromConfig(adapterSettings: BaseEndpointTypes['Settings']): number {
286310
return adapterSettings.WARMUP_SUBSCRIPTION_TTL
287311
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { Requester } from '@chainlink/external-adapter-framework/util/requester'
2+
import { ChainKey } from '../endpoint/price'
3+
import { GmTokenTransportTypes } from './price'
4+
5+
export type TokenMeta = { address: string; decimals: number; symbol: string }
6+
type TokensResp = { tokens: Array<{ symbol: string; address: string; decimals: number }> }
7+
8+
/**
9+
* Fetches token metadata (address/decimals) per chain from GMX API.
10+
*/
11+
export class TokenResolver {
12+
private readonly urls: Record<ChainKey, string>
13+
14+
constructor(private readonly requester: Requester, settings: GmTokenTransportTypes['Settings']) {
15+
this.urls = {
16+
arbitrum: settings.ARBITRUM_TOKENS_INFO_URL,
17+
botanix: settings.BOTANIX_TOKENS_INFO_URL,
18+
}
19+
}
20+
21+
async getToken(chain: ChainKey, symbol: string): Promise<TokenMeta> {
22+
const req = { url: this.urls[chain], method: 'GET' as const }
23+
const { response } = await this.requester.request<TokensResp>(JSON.stringify(req), req)
24+
const target = symbol.toUpperCase()
25+
const token = response.data.tokens.find((x) => x.symbol.toUpperCase() === target)
26+
if (!token) {
27+
throw new Error(`Token with symbol "${symbol}" not found on ${chain}`)
28+
}
29+
return { symbol: token.symbol, address: token.address, decimals: token.decimals }
30+
}
31+
}

0 commit comments

Comments
 (0)