Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Introduce GraphQLTokenFeeFetcher (no traffic - 4kb limit) #748

Merged
merged 2 commits into from
Jun 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ jobs:
test:
name: Run tests
runs-on: ubuntu-latest
env:
GQL_URL: ${{ secrets.UNI_GRAPHQL_ENDPOINT }}
GQL_H_ORGN: ${{ secrets.UNI_GRAPHQL_HEADER_ORIGIN }}

steps:
- name: Checkout Repo
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ The best way to develop and test the API is to deploy your own instance to AWS.
TENDERLY_PROJECT = '' # For enabling Tenderly simulations
TENDERLY_ACCESS_KEY = '' # For enabling Tenderly simulations
TENDERLY_NODE_API_KEY = '' # For enabling Tenderly node-level RPC access
ALCHEMY_QUERY_KEY = '' For Alchemy subgraph query access
ALCHEMY_QUERY_KEY = '' # For Alchemy subgraph query access
GQL_URL = '' # The GraphQL endpoint url, for Uniswap graphql query access
GQL_H_ORGN = '' # The GraphQL header origin, for Uniswap graphql query access
```
3. Install and build the package
```
Expand Down
12 changes: 12 additions & 0 deletions bin/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ export class RoutingAPIStage extends Stage {
unicornSecret: string
alchemyQueryKey?: string
decentralizedNetworkApiKey?: string
uniGraphQLEndpoint: string
uniGraphQLHeaderOrigin: string
}
) {
super(scope, id, props)
Expand All @@ -60,6 +62,8 @@ export class RoutingAPIStage extends Stage {
unicornSecret,
alchemyQueryKey,
decentralizedNetworkApiKey,
uniGraphQLEndpoint,
uniGraphQLHeaderOrigin,
} = props

const { url } = new RoutingAPIStack(this, 'RoutingAPI', {
Expand All @@ -80,6 +84,8 @@ export class RoutingAPIStage extends Stage {
unicornSecret,
alchemyQueryKey,
decentralizedNetworkApiKey,
uniGraphQLEndpoint,
uniGraphQLHeaderOrigin,
})
this.url = url
}
Expand Down Expand Up @@ -255,6 +261,8 @@ export class RoutingAPIPipeline extends Stack {
unicornSecret: unicornSecrets.secretValueFromJson('debug-config-unicorn-key').toString(),
alchemyQueryKey: routingApiNewSecrets.secretValueFromJson('alchemy-query-key').toString(),
decentralizedNetworkApiKey: routingApiNewSecrets.secretValueFromJson('decentralized-network-api-key').toString(),
uniGraphQLEndpoint: routingApiNewSecrets.secretValueFromJson('uni-graphql-endpoint').toString(),
uniGraphQLHeaderOrigin: routingApiNewSecrets.secretValueFromJson('uni-graphql-header-origin').toString(),
})

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

const prodUsEast2AppStage = pipeline.addStage(prodUsEast2Stage)
Expand Down Expand Up @@ -417,6 +427,8 @@ new RoutingAPIStack(app, 'RoutingAPIStack', {
tenderlyAccessKey: process.env.TENDERLY_ACCESS_KEY!,
tenderlyNodeApiKey: process.env.TENDERLY_NODE_API_KEY!,
unicornSecret: process.env.UNICORN_SECRET!,
uniGraphQLEndpoint: process.env.GQL_URL!,
uniGraphQLHeaderOrigin: process.env.GQL_H_ORGN!,
})

new RoutingAPIPipeline(app, 'RoutingAPIPipelineStack', {
Expand Down
6 changes: 6 additions & 0 deletions bin/stacks/routing-api-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ export class RoutingAPIStack extends cdk.Stack {
unicornSecret: string
alchemyQueryKey?: string
decentralizedNetworkApiKey?: string
uniGraphQLEndpoint: string
uniGraphQLHeaderOrigin: string
}
) {
super(parent, name, props)
Expand All @@ -72,6 +74,8 @@ export class RoutingAPIStack extends cdk.Stack {
unicornSecret,
alchemyQueryKey,
decentralizedNetworkApiKey,
uniGraphQLEndpoint,
uniGraphQLHeaderOrigin,
} = props

const {
Expand Down Expand Up @@ -129,6 +133,8 @@ export class RoutingAPIStack extends cdk.Stack {
tokenPropertiesCachingDynamoDb,
rpcProviderHealthStateDynamoDb,
unicornSecret,
uniGraphQLEndpoint,
uniGraphQLHeaderOrigin,
})

const accessLogGroup = new aws_logs.LogGroup(this, 'RoutingAPIGAccessLogs')
Expand Down
6 changes: 6 additions & 0 deletions bin/stacks/routing-lambda-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ export interface RoutingLambdaStackProps extends cdk.NestedStackProps {
tokenPropertiesCachingDynamoDb: aws_dynamodb.Table
rpcProviderHealthStateDynamoDb: aws_dynamodb.Table
unicornSecret: string
uniGraphQLEndpoint: string
uniGraphQLHeaderOrigin: string
}
export class RoutingLambdaStack extends cdk.NestedStack {
public readonly routingLambda: aws_lambda_nodejs.NodejsFunction
Expand Down Expand Up @@ -68,6 +70,8 @@ export class RoutingLambdaStack extends cdk.NestedStack {
tokenPropertiesCachingDynamoDb,
rpcProviderHealthStateDynamoDb,
unicornSecret,
uniGraphQLEndpoint,
uniGraphQLHeaderOrigin,
} = props

new CfnOutput(this, 'jsonRpcProviders', {
Expand Down Expand Up @@ -147,6 +151,8 @@ export class RoutingLambdaStack extends cdk.NestedStack {
// we will start using the correct ones going forward
TOKEN_PROPERTIES_CACHING_TABLE_NAME: tokenPropertiesCachingDynamoDb.tableName,
UNICORN_SECRET: unicornSecret,
GQL_URL: uniGraphQLEndpoint,
GQL_H_ORGN: uniGraphQLHeaderOrigin,
...jsonRpcProviders,
},
layers: [
Expand Down
38 changes: 38 additions & 0 deletions lib/graphql/graphql-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import axios, { AxiosRequestConfig, AxiosResponse } from 'axios'

import { GraphQLResponse } from './graphql-schemas'

/* Interface for accessing any GraphQL API */
export interface IGraphQLClient {
fetchData<T>(query: string, variables?: { [key: string]: any }): Promise<T>
}

/* Implementation of the IGraphQLClient interface to give access to any GraphQL API */
export class GraphQLClient implements IGraphQLClient {
constructor(private readonly endpoint: string, private readonly headers: Record<string, string>) {}

async fetchData<T>(query: string, variables: { [key: string]: any } = {}): Promise<T> {
const requestConfig: AxiosRequestConfig = {
method: 'POST',
url: this.endpoint,
headers: this.headers,
data: { query, variables },
}

try {
const response: AxiosResponse<GraphQLResponse<T>> = await axios.request(requestConfig)
const responseBody = response.data
if (responseBody.errors) {
throw new Error(`GraphQL error! ${JSON.stringify(responseBody.errors)}`)
}

return responseBody.data
} catch (error) {
if (axios.isAxiosError(error)) {
throw new Error(`HTTP error! status: ${error.response?.status}`)
} else {
throw new Error(`Unexpected error: ${error}`)
}
}
}
}
61 changes: 61 additions & 0 deletions lib/graphql/graphql-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { ChainId } from '@uniswap/sdk-core'

import { GraphQLClient, IGraphQLClient } from './graphql-client'
import {
GRAPHQL_QUERY_MULTIPLE_TOKEN_INFO_BY_CONTRACTS,
GRAPHQL_QUERY_TOKEN_INFO_BY_ADDRESS_CHAIN,
} from './graphql-queries'
import { TokenInfoResponse, TokensInfoResponse } from './graphql-schemas'

/* Interface for accessing Uniswap GraphQL API */
export interface IUniGraphQLProvider {
/* Fetch token info for a given chain and address */
getTokenInfo(chainId: ChainId, address: string): Promise<TokenInfoResponse>
/* Fetch token info for multiple tokens given a chain and addresses */
getTokensInfo(chainId: ChainId, addresses: string[]): Promise<TokensInfoResponse>
// Add more methods here as needed.
// - more details: https://github.com/Uniswap/data-api-graphql/blob/main/graphql/schema.graphql
}

/* Implementation of the UniGraphQLProvider interface to give access to Uniswap GraphQL API */
export class UniGraphQLProvider implements IUniGraphQLProvider {
private readonly endpoint = process.env.GQL_URL!
private readonly headers = {
Origin: process.env.GQL_H_ORGN!,
'Content-Type': 'application/json',
}
private client: IGraphQLClient

constructor() {
this.client = new GraphQLClient(this.endpoint, this.headers)
}

/* Convert ChainId to a string recognized by data-graph-api graphql endpoint.
* GraphQL Chain Enum located here: https://github.com/Uniswap/data-api-graphql/blob/main/graphql/schema.graphql#L155
* */
private _chainIdToGraphQLChainName(chainId: ChainId): string | undefined {
// TODO: add complete list / use data-graphql-api to populate. Only MAINNET for now.
switch (chainId) {
case ChainId.MAINNET:
return 'ETHEREUM'
default:
throw new Error(`UniGraphQLProvider._chainIdToGraphQLChainName unsupported ChainId: ${chainId}`)
}
}

async getTokenInfo(chainId: ChainId, address: string): Promise<TokenInfoResponse> {
const query = GRAPHQL_QUERY_TOKEN_INFO_BY_ADDRESS_CHAIN
const variables = { chain: this._chainIdToGraphQLChainName(chainId), address: address }
return this.client.fetchData<TokenInfoResponse>(query, variables)
}

async getTokensInfo(chainId: ChainId, addresses: string[]): Promise<TokensInfoResponse> {
const query = GRAPHQL_QUERY_MULTIPLE_TOKEN_INFO_BY_CONTRACTS
const contracts = addresses.map((address) => ({
chain: this._chainIdToGraphQLChainName(chainId),
address: address,
}))
const variables = { contracts: contracts }
return this.client.fetchData<TokensInfoResponse>(query, variables)
}
}
35 changes: 35 additions & 0 deletions lib/graphql/graphql-queries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/* Query to get the token info by address and chain */
export const GRAPHQL_QUERY_TOKEN_INFO_BY_ADDRESS_CHAIN = `
query Token($chain: Chain!, $address: String!) {
token(chain: $chain, address: $address) {
name
chain
address
decimals
symbol
standard
feeData {
buyFeeBps
sellFeeBps
}
}
}
`

/* Query to get the token info by multiple addresses and chain */
export const GRAPHQL_QUERY_MULTIPLE_TOKEN_INFO_BY_CONTRACTS = `
query Tokens($contracts: [ContractInput!]!) {
tokens(contracts: $contracts) {
name
chain
address
decimals
symbol
standard
feeData {
buyFeeBps
sellFeeBps
}
}
}
`
25 changes: 25 additions & 0 deletions lib/graphql/graphql-schemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export interface GraphQLResponse<T> {
data: T
errors?: Array<{ message: string }>
}

export interface TokenInfoResponse {
token: TokenInfo
}

export interface TokensInfoResponse {
tokens: TokenInfo[]
}

export interface TokenInfo {
name: string
chain: string
address: string
decimals: number
symbol: string
standard: string
feeData: {
buyFeeBps: string
sellFeeBps: string
}
}
75 changes: 75 additions & 0 deletions lib/graphql/graphql-token-fee-fetcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { ITokenFeeFetcher } from '@uniswap/smart-order-router/build/main/providers/token-fee-fetcher'
import { IUniGraphQLProvider } from './graphql-provider'
import { TokenFeeMap } from '@uniswap/smart-order-router/build/main/providers/token-fee-fetcher'
import { ProviderConfig } from '@uniswap/smart-order-router/build/main/providers/provider'
import { TokensInfoResponse } from './graphql-schemas'
import { BigNumber } from 'ethers'
import { ChainId } from '@uniswap/sdk-core'
import { metric } from '@uniswap/smart-order-router/build/main/util/metric'
import { log, MetricLoggerUnit } from '@uniswap/smart-order-router'

/* Implementation of the ITokenFeeFetcher interface to give access to Uniswap GraphQL API token fee data.
* This fetcher is used to get token fees from GraphQL API and fallback to OnChainTokenFeeFetcher if GraphQL API fails
* or not all addresses could be fetched.
* Note: OnChainTokenFeeFetcher takes into account the provided blocknumber when retrieving token fees (through providerConfig),
* but GraphQLTokenFeeFetcher always returns the latest token fee (GraphQl doesn't keep historical data).
* FOT tax doesn't change often, hence ok to not use blocknumber here.
* */
export class GraphQLTokenFeeFetcher implements ITokenFeeFetcher {
private readonly graphQLProvider: IUniGraphQLProvider
private readonly onChainFeeFetcherFallback: ITokenFeeFetcher
private readonly chainId: ChainId

constructor(
graphQLProvider: IUniGraphQLProvider,
onChainTokenFeeFetcherFallback: ITokenFeeFetcher,
chainId: ChainId
) {
this.graphQLProvider = graphQLProvider
this.onChainFeeFetcherFallback = onChainTokenFeeFetcherFallback
this.chainId = chainId
}

async fetchFees(addresses: string[], providerConfig?: ProviderConfig): Promise<TokenFeeMap> {
let tokenFeeMap: TokenFeeMap = {}

try {
const tokenFeeResponse: TokensInfoResponse = await this.graphQLProvider.getTokensInfo(this.chainId, addresses)
tokenFeeResponse.tokens.forEach((token) => {
if (token.feeData.buyFeeBps || token.feeData.sellFeeBps) {
const buyFeeBps = token.feeData.buyFeeBps ? BigNumber.from(token.feeData.buyFeeBps) : undefined
const sellFeeBps = token.feeData.sellFeeBps ? BigNumber.from(token.feeData.sellFeeBps) : undefined
tokenFeeMap[token.address] = { buyFeeBps, sellFeeBps }
}
})

metric.putMetric('GraphQLTokenFeeFetcherFetchFeesSuccess', 1, MetricLoggerUnit.Count)
} catch (err) {
log.error({ err }, `Error calling GraphQLTokenFeeFetcher for tokens: ${addresses}`)

metric.putMetric('GraphQLTokenFeeFetcherFetchFeesFailure', 1, MetricLoggerUnit.Count)
}

// If we couldn't fetch all addresses from GraphQL then use fallback on chain fetcher for the rest.
const addressesToFetchFeesWithFallbackFetcher = addresses.filter((address) => !tokenFeeMap[address])
if (addressesToFetchFeesWithFallbackFetcher.length > 0) {
try {
const tokenFeeMapFromFallback = await this.onChainFeeFetcherFallback.fetchFees(
addressesToFetchFeesWithFallbackFetcher,
providerConfig
)
tokenFeeMap = {
...tokenFeeMap,
...tokenFeeMapFromFallback,
}
} catch (err) {
log.error(
{ err },
`Error fetching fees for tokens ${addressesToFetchFeesWithFallbackFetcher} using onChain fallback`
)
}
}

return tokenFeeMap
}
}
Loading
Loading