Skip to content

Commit 3a30016

Browse files
authored
feat: Introduce GraphQLTokenFeeFetcher (no traffic - 4kb limit) (Uniswap#748)
1 parent bc70ff7 commit 3a30016

13 files changed

+479
-1
lines changed

.github/workflows/test.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ jobs:
1010
test:
1111
name: Run tests
1212
runs-on: ubuntu-latest
13+
env:
14+
GQL_URL: ${{ secrets.UNI_GRAPHQL_ENDPOINT }}
15+
GQL_H_ORGN: ${{ secrets.UNI_GRAPHQL_HEADER_ORIGIN }}
1316

1417
steps:
1518
- name: Checkout Repo

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,9 @@ The best way to develop and test the API is to deploy your own instance to AWS.
3838
TENDERLY_PROJECT = '' # For enabling Tenderly simulations
3939
TENDERLY_ACCESS_KEY = '' # For enabling Tenderly simulations
4040
TENDERLY_NODE_API_KEY = '' # For enabling Tenderly node-level RPC access
41-
ALCHEMY_QUERY_KEY = '' For Alchemy subgraph query access
41+
ALCHEMY_QUERY_KEY = '' # For Alchemy subgraph query access
42+
GQL_URL = '' # The GraphQL endpoint url, for Uniswap graphql query access
43+
GQL_H_ORGN = '' # The GraphQL header origin, for Uniswap graphql query access
4244
```
4345
3. Install and build the package
4446
```

bin/app.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ export class RoutingAPIStage extends Stage {
3939
unicornSecret: string
4040
alchemyQueryKey?: string
4141
decentralizedNetworkApiKey?: string
42+
uniGraphQLEndpoint: string
43+
uniGraphQLHeaderOrigin: string
4244
}
4345
) {
4446
super(scope, id, props)
@@ -60,6 +62,8 @@ export class RoutingAPIStage extends Stage {
6062
unicornSecret,
6163
alchemyQueryKey,
6264
decentralizedNetworkApiKey,
65+
uniGraphQLEndpoint,
66+
uniGraphQLHeaderOrigin,
6367
} = props
6468

6569
const { url } = new RoutingAPIStack(this, 'RoutingAPI', {
@@ -80,6 +84,8 @@ export class RoutingAPIStage extends Stage {
8084
unicornSecret,
8185
alchemyQueryKey,
8286
decentralizedNetworkApiKey,
87+
uniGraphQLEndpoint,
88+
uniGraphQLHeaderOrigin,
8389
})
8490
this.url = url
8591
}
@@ -255,6 +261,8 @@ export class RoutingAPIPipeline extends Stack {
255261
unicornSecret: unicornSecrets.secretValueFromJson('debug-config-unicorn-key').toString(),
256262
alchemyQueryKey: routingApiNewSecrets.secretValueFromJson('alchemy-query-key').toString(),
257263
decentralizedNetworkApiKey: routingApiNewSecrets.secretValueFromJson('decentralized-network-api-key').toString(),
264+
uniGraphQLEndpoint: routingApiNewSecrets.secretValueFromJson('uni-graphql-endpoint').toString(),
265+
uniGraphQLHeaderOrigin: routingApiNewSecrets.secretValueFromJson('uni-graphql-header-origin').toString(),
258266
})
259267

260268
const betaUsEast2AppStage = pipeline.addStage(betaUsEast2Stage)
@@ -281,6 +289,8 @@ export class RoutingAPIPipeline extends Stack {
281289
unicornSecret: unicornSecrets.secretValueFromJson('debug-config-unicorn-key').toString(),
282290
alchemyQueryKey: routingApiNewSecrets.secretValueFromJson('alchemy-query-key').toString(),
283291
decentralizedNetworkApiKey: routingApiNewSecrets.secretValueFromJson('decentralized-network-api-key').toString(),
292+
uniGraphQLEndpoint: routingApiNewSecrets.secretValueFromJson('uni-graphql-endpoint').toString(),
293+
uniGraphQLHeaderOrigin: routingApiNewSecrets.secretValueFromJson('uni-graphql-header-origin').toString(),
284294
})
285295

286296
const prodUsEast2AppStage = pipeline.addStage(prodUsEast2Stage)
@@ -417,6 +427,8 @@ new RoutingAPIStack(app, 'RoutingAPIStack', {
417427
tenderlyAccessKey: process.env.TENDERLY_ACCESS_KEY!,
418428
tenderlyNodeApiKey: process.env.TENDERLY_NODE_API_KEY!,
419429
unicornSecret: process.env.UNICORN_SECRET!,
430+
uniGraphQLEndpoint: process.env.GQL_URL!,
431+
uniGraphQLHeaderOrigin: process.env.GQL_H_ORGN!,
420432
})
421433

422434
new RoutingAPIPipeline(app, 'RoutingAPIPipelineStack', {

bin/stacks/routing-api-stack.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ export class RoutingAPIStack extends cdk.Stack {
4949
unicornSecret: string
5050
alchemyQueryKey?: string
5151
decentralizedNetworkApiKey?: string
52+
uniGraphQLEndpoint: string
53+
uniGraphQLHeaderOrigin: string
5254
}
5355
) {
5456
super(parent, name, props)
@@ -72,6 +74,8 @@ export class RoutingAPIStack extends cdk.Stack {
7274
unicornSecret,
7375
alchemyQueryKey,
7476
decentralizedNetworkApiKey,
77+
uniGraphQLEndpoint,
78+
uniGraphQLHeaderOrigin,
7579
} = props
7680

7781
const {
@@ -129,6 +133,8 @@ export class RoutingAPIStack extends cdk.Stack {
129133
tokenPropertiesCachingDynamoDb,
130134
rpcProviderHealthStateDynamoDb,
131135
unicornSecret,
136+
uniGraphQLEndpoint,
137+
uniGraphQLHeaderOrigin,
132138
})
133139

134140
const accessLogGroup = new aws_logs.LogGroup(this, 'RoutingAPIGAccessLogs')

bin/stacks/routing-lambda-stack.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ export interface RoutingLambdaStackProps extends cdk.NestedStackProps {
3838
tokenPropertiesCachingDynamoDb: aws_dynamodb.Table
3939
rpcProviderHealthStateDynamoDb: aws_dynamodb.Table
4040
unicornSecret: string
41+
uniGraphQLEndpoint: string
42+
uniGraphQLHeaderOrigin: string
4143
}
4244
export class RoutingLambdaStack extends cdk.NestedStack {
4345
public readonly routingLambda: aws_lambda_nodejs.NodejsFunction
@@ -68,6 +70,8 @@ export class RoutingLambdaStack extends cdk.NestedStack {
6870
tokenPropertiesCachingDynamoDb,
6971
rpcProviderHealthStateDynamoDb,
7072
unicornSecret,
73+
uniGraphQLEndpoint,
74+
uniGraphQLHeaderOrigin,
7175
} = props
7276

7377
new CfnOutput(this, 'jsonRpcProviders', {
@@ -147,6 +151,8 @@ export class RoutingLambdaStack extends cdk.NestedStack {
147151
// we will start using the correct ones going forward
148152
TOKEN_PROPERTIES_CACHING_TABLE_NAME: tokenPropertiesCachingDynamoDb.tableName,
149153
UNICORN_SECRET: unicornSecret,
154+
GQL_URL: uniGraphQLEndpoint,
155+
GQL_H_ORGN: uniGraphQLHeaderOrigin,
150156
...jsonRpcProviders,
151157
},
152158
layers: [

lib/graphql/graphql-client.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import axios, { AxiosRequestConfig, AxiosResponse } from 'axios'
2+
3+
import { GraphQLResponse } from './graphql-schemas'
4+
5+
/* Interface for accessing any GraphQL API */
6+
export interface IGraphQLClient {
7+
fetchData<T>(query: string, variables?: { [key: string]: any }): Promise<T>
8+
}
9+
10+
/* Implementation of the IGraphQLClient interface to give access to any GraphQL API */
11+
export class GraphQLClient implements IGraphQLClient {
12+
constructor(private readonly endpoint: string, private readonly headers: Record<string, string>) {}
13+
14+
async fetchData<T>(query: string, variables: { [key: string]: any } = {}): Promise<T> {
15+
const requestConfig: AxiosRequestConfig = {
16+
method: 'POST',
17+
url: this.endpoint,
18+
headers: this.headers,
19+
data: { query, variables },
20+
}
21+
22+
try {
23+
const response: AxiosResponse<GraphQLResponse<T>> = await axios.request(requestConfig)
24+
const responseBody = response.data
25+
if (responseBody.errors) {
26+
throw new Error(`GraphQL error! ${JSON.stringify(responseBody.errors)}`)
27+
}
28+
29+
return responseBody.data
30+
} catch (error) {
31+
if (axios.isAxiosError(error)) {
32+
throw new Error(`HTTP error! status: ${error.response?.status}`)
33+
} else {
34+
throw new Error(`Unexpected error: ${error}`)
35+
}
36+
}
37+
}
38+
}

lib/graphql/graphql-provider.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { ChainId } from '@uniswap/sdk-core'
2+
3+
import { GraphQLClient, IGraphQLClient } from './graphql-client'
4+
import {
5+
GRAPHQL_QUERY_MULTIPLE_TOKEN_INFO_BY_CONTRACTS,
6+
GRAPHQL_QUERY_TOKEN_INFO_BY_ADDRESS_CHAIN,
7+
} from './graphql-queries'
8+
import { TokenInfoResponse, TokensInfoResponse } from './graphql-schemas'
9+
10+
/* Interface for accessing Uniswap GraphQL API */
11+
export interface IUniGraphQLProvider {
12+
/* Fetch token info for a given chain and address */
13+
getTokenInfo(chainId: ChainId, address: string): Promise<TokenInfoResponse>
14+
/* Fetch token info for multiple tokens given a chain and addresses */
15+
getTokensInfo(chainId: ChainId, addresses: string[]): Promise<TokensInfoResponse>
16+
// Add more methods here as needed.
17+
// - more details: https://github.com/Uniswap/data-api-graphql/blob/main/graphql/schema.graphql
18+
}
19+
20+
/* Implementation of the UniGraphQLProvider interface to give access to Uniswap GraphQL API */
21+
export class UniGraphQLProvider implements IUniGraphQLProvider {
22+
private readonly endpoint = process.env.GQL_URL!
23+
private readonly headers = {
24+
Origin: process.env.GQL_H_ORGN!,
25+
'Content-Type': 'application/json',
26+
}
27+
private client: IGraphQLClient
28+
29+
constructor() {
30+
this.client = new GraphQLClient(this.endpoint, this.headers)
31+
}
32+
33+
/* Convert ChainId to a string recognized by data-graph-api graphql endpoint.
34+
* GraphQL Chain Enum located here: https://github.com/Uniswap/data-api-graphql/blob/main/graphql/schema.graphql#L155
35+
* */
36+
private _chainIdToGraphQLChainName(chainId: ChainId): string | undefined {
37+
// TODO: add complete list / use data-graphql-api to populate. Only MAINNET for now.
38+
switch (chainId) {
39+
case ChainId.MAINNET:
40+
return 'ETHEREUM'
41+
default:
42+
throw new Error(`UniGraphQLProvider._chainIdToGraphQLChainName unsupported ChainId: ${chainId}`)
43+
}
44+
}
45+
46+
async getTokenInfo(chainId: ChainId, address: string): Promise<TokenInfoResponse> {
47+
const query = GRAPHQL_QUERY_TOKEN_INFO_BY_ADDRESS_CHAIN
48+
const variables = { chain: this._chainIdToGraphQLChainName(chainId), address: address }
49+
return this.client.fetchData<TokenInfoResponse>(query, variables)
50+
}
51+
52+
async getTokensInfo(chainId: ChainId, addresses: string[]): Promise<TokensInfoResponse> {
53+
const query = GRAPHQL_QUERY_MULTIPLE_TOKEN_INFO_BY_CONTRACTS
54+
const contracts = addresses.map((address) => ({
55+
chain: this._chainIdToGraphQLChainName(chainId),
56+
address: address,
57+
}))
58+
const variables = { contracts: contracts }
59+
return this.client.fetchData<TokensInfoResponse>(query, variables)
60+
}
61+
}

lib/graphql/graphql-queries.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/* Query to get the token info by address and chain */
2+
export const GRAPHQL_QUERY_TOKEN_INFO_BY_ADDRESS_CHAIN = `
3+
query Token($chain: Chain!, $address: String!) {
4+
token(chain: $chain, address: $address) {
5+
name
6+
chain
7+
address
8+
decimals
9+
symbol
10+
standard
11+
feeData {
12+
buyFeeBps
13+
sellFeeBps
14+
}
15+
}
16+
}
17+
`
18+
19+
/* Query to get the token info by multiple addresses and chain */
20+
export const GRAPHQL_QUERY_MULTIPLE_TOKEN_INFO_BY_CONTRACTS = `
21+
query Tokens($contracts: [ContractInput!]!) {
22+
tokens(contracts: $contracts) {
23+
name
24+
chain
25+
address
26+
decimals
27+
symbol
28+
standard
29+
feeData {
30+
buyFeeBps
31+
sellFeeBps
32+
}
33+
}
34+
}
35+
`

lib/graphql/graphql-schemas.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
export interface GraphQLResponse<T> {
2+
data: T
3+
errors?: Array<{ message: string }>
4+
}
5+
6+
export interface TokenInfoResponse {
7+
token: TokenInfo
8+
}
9+
10+
export interface TokensInfoResponse {
11+
tokens: TokenInfo[]
12+
}
13+
14+
export interface TokenInfo {
15+
name: string
16+
chain: string
17+
address: string
18+
decimals: number
19+
symbol: string
20+
standard: string
21+
feeData: {
22+
buyFeeBps: string
23+
sellFeeBps: string
24+
}
25+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { ITokenFeeFetcher } from '@uniswap/smart-order-router/build/main/providers/token-fee-fetcher'
2+
import { IUniGraphQLProvider } from './graphql-provider'
3+
import { TokenFeeMap } from '@uniswap/smart-order-router/build/main/providers/token-fee-fetcher'
4+
import { ProviderConfig } from '@uniswap/smart-order-router/build/main/providers/provider'
5+
import { TokensInfoResponse } from './graphql-schemas'
6+
import { BigNumber } from 'ethers'
7+
import { ChainId } from '@uniswap/sdk-core'
8+
import { metric } from '@uniswap/smart-order-router/build/main/util/metric'
9+
import { log, MetricLoggerUnit } from '@uniswap/smart-order-router'
10+
11+
/* Implementation of the ITokenFeeFetcher interface to give access to Uniswap GraphQL API token fee data.
12+
* This fetcher is used to get token fees from GraphQL API and fallback to OnChainTokenFeeFetcher if GraphQL API fails
13+
* or not all addresses could be fetched.
14+
* Note: OnChainTokenFeeFetcher takes into account the provided blocknumber when retrieving token fees (through providerConfig),
15+
* but GraphQLTokenFeeFetcher always returns the latest token fee (GraphQl doesn't keep historical data).
16+
* FOT tax doesn't change often, hence ok to not use blocknumber here.
17+
* */
18+
export class GraphQLTokenFeeFetcher implements ITokenFeeFetcher {
19+
private readonly graphQLProvider: IUniGraphQLProvider
20+
private readonly onChainFeeFetcherFallback: ITokenFeeFetcher
21+
private readonly chainId: ChainId
22+
23+
constructor(
24+
graphQLProvider: IUniGraphQLProvider,
25+
onChainTokenFeeFetcherFallback: ITokenFeeFetcher,
26+
chainId: ChainId
27+
) {
28+
this.graphQLProvider = graphQLProvider
29+
this.onChainFeeFetcherFallback = onChainTokenFeeFetcherFallback
30+
this.chainId = chainId
31+
}
32+
33+
async fetchFees(addresses: string[], providerConfig?: ProviderConfig): Promise<TokenFeeMap> {
34+
let tokenFeeMap: TokenFeeMap = {}
35+
36+
try {
37+
const tokenFeeResponse: TokensInfoResponse = await this.graphQLProvider.getTokensInfo(this.chainId, addresses)
38+
tokenFeeResponse.tokens.forEach((token) => {
39+
if (token.feeData.buyFeeBps || token.feeData.sellFeeBps) {
40+
const buyFeeBps = token.feeData.buyFeeBps ? BigNumber.from(token.feeData.buyFeeBps) : undefined
41+
const sellFeeBps = token.feeData.sellFeeBps ? BigNumber.from(token.feeData.sellFeeBps) : undefined
42+
tokenFeeMap[token.address] = { buyFeeBps, sellFeeBps }
43+
}
44+
})
45+
46+
metric.putMetric('GraphQLTokenFeeFetcherFetchFeesSuccess', 1, MetricLoggerUnit.Count)
47+
} catch (err) {
48+
log.error({ err }, `Error calling GraphQLTokenFeeFetcher for tokens: ${addresses}`)
49+
50+
metric.putMetric('GraphQLTokenFeeFetcherFetchFeesFailure', 1, MetricLoggerUnit.Count)
51+
}
52+
53+
// If we couldn't fetch all addresses from GraphQL then use fallback on chain fetcher for the rest.
54+
const addressesToFetchFeesWithFallbackFetcher = addresses.filter((address) => !tokenFeeMap[address])
55+
if (addressesToFetchFeesWithFallbackFetcher.length > 0) {
56+
try {
57+
const tokenFeeMapFromFallback = await this.onChainFeeFetcherFallback.fetchFees(
58+
addressesToFetchFeesWithFallbackFetcher,
59+
providerConfig
60+
)
61+
tokenFeeMap = {
62+
...tokenFeeMap,
63+
...tokenFeeMapFromFallback,
64+
}
65+
} catch (err) {
66+
log.error(
67+
{ err },
68+
`Error fetching fees for tokens ${addressesToFetchFeesWithFallbackFetcher} using onChain fallback`
69+
)
70+
}
71+
}
72+
73+
return tokenFeeMap
74+
}
75+
}

0 commit comments

Comments
 (0)