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: add fee detector #338

Closed
wants to merge 9 commits into from
Closed
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
133 changes: 133 additions & 0 deletions src/abis/TokenFeeDetector.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
[
{
"inputs": [
{
"internalType": "address",
"name": "_factoryV2",
"type": "address"
}
],
"stateMutability": "nonpayable",
"type": "constructor"
},
{
"inputs": [],
"name": "PairLookupFailed",
"type": "error"
},
{
"inputs": [],
"name": "SameToken",
"type": "error"
},
{
"inputs": [
{
"internalType": "address[]",
"name": "tokens",
"type": "address[]"
},
{
"internalType": "address",
"name": "baseToken",
"type": "address"
},
{
"internalType": "uint256",
"name": "amountToBorrow",
"type": "uint256"
}
],
"name": "batchValidate",
"outputs": [
{
"components": [
{
"internalType": "uint256",
"name": "buyFeeBps",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "sellFeeBps",
"type": "uint256"
}
],
"internalType": "struct TokenFees[]",
"name": "fotResults",
"type": "tuple[]"
}
],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "",
"type": "address"
},
{
"internalType": "uint256",
"name": "amount0",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "",
"type": "uint256"
},
{
"internalType": "bytes",
"name": "data",
"type": "bytes"
}
],
"name": "uniswapV2Call",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "token",
"type": "address"
},
{
"internalType": "address",
"name": "baseToken",
"type": "address"
},
{
"internalType": "uint256",
"name": "amountToBorrow",
"type": "uint256"
}
],
"name": "validate",
"outputs": [
{
"components": [
{
"internalType": "uint256",
"name": "buyFeeBps",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "sellFeeBps",
"type": "uint256"
}
],
"internalType": "struct TokenFees",
"name": "fotResult",
"type": "tuple"
}
],
"stateMutability": "nonpayable",
"type": "function"
}
]
1 change: 1 addition & 0 deletions src/providers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export * from './swap-router-provider';
export * from './tenderly-simulation-provider';
export * from './token-provider';
export * from './token-validator-provider';
export * from './token-fee-provider';
export * from './uri-subgraph-provider';
export * from './caching/route';
export * from './v2/caching-pool-provider';
Expand Down
164 changes: 164 additions & 0 deletions src/providers/token-fee-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { ChainId, Token } from '@uniswap/sdk-core';
import { BigNumber } from '@ethersproject/bignumber';
import _ from 'lodash';

import { TokenFeeDetector__factory } from '../types/other/factories/TokenFeeDetector__factory';
import { log, WRAPPED_NATIVE_CURRENCY } from '../util';

import { ICache } from './cache';
import { IMulticallProvider } from './multicall-provider';
import { ProviderConfig } from './provider';
import { DEFAULT_ALLOWLIST } from './token-validator-provider';

export type TokenFeeResult = {
buyFeeBps: BigNumber,
sellFeeBps: BigNumber,
}

// on detector failure, assume no fee
const DEFAULT_TOKEN_FEE_RESULT = {
buyFeeBps: BigNumber.from(0),
sellFeeBps: BigNumber.from(0),
};

export interface TokenFeeResults {
getFeesByToken(token: Token): TokenFeeResult | undefined;
}

// address at which the FeeDetector lens is deployed
const FEE_DETECTOR_ADDRESS = '0x57eC54d113719dDE9A90E6bE807524a86560E89D';
// Amount has to be big enough to avoid rounding errors, but small enough that
// most v2 pools will have at least this many token units
// 10000 is the smallest number that avoids rounding errors in bps terms
const AMOUNT_TO_FLASH_BORROW = '10000';
marktoda marked this conversation as resolved.
Show resolved Hide resolved
// 1M gas limit per validate call, should cover most swap cases
const GAS_LIMIT_PER_VALIDATE = 1_000_000;

/**
* Provider for getting token fee data.
*
* @export
* @interface ITokenFeeProvider
*/
export interface ITokenFeeProvider {
/**
* Gets the fees for the token at each address.
*
* @param tokens The token addresses to fetch fees for.
* @param [providerConfig] The provider config.
* @returns A token accessor with methods for accessing the tokens.
*/
validateTokens(
tokens: Token[],
providerConfig?: ProviderConfig
): Promise<TokenFeeResults>;
}

export class TokenFeeProvider implements ITokenFeeProvider {
private CACHE_KEY = (chainId: ChainId, address: string) =>
`token-${chainId}-${address}`;

private BASE_TOKEN: string;

constructor(
private chainId: ChainId,
private multicall2Provider: IMulticallProvider,
private tokenFeeCache: ICache<TokenFeeResult>,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I'm working on the router backend support for FOT, I realized that this TokenFeeProvider is tightly coupled with the in-memory ICache. We will need both in-memory memory (for client-side fallback), as well as dynamo caching (for routing-api distributed caches).

I think I will create another PR, meanwhile leveraging the insights and feedbacks from this PR.

private tokenValidatorAddress = FEE_DETECTOR_ADDRESS,
private gasLimitPerCall = GAS_LIMIT_PER_VALIDATE,
private amountToFlashBorrow = AMOUNT_TO_FLASH_BORROW,
private allowList = DEFAULT_ALLOWLIST
) {
this.BASE_TOKEN = WRAPPED_NATIVE_CURRENCY[this.chainId]!.address;
}

public async validateTokens(
tokens: Token[],
marktoda marked this conversation as resolved.
Show resolved Hide resolved
providerConfig?: ProviderConfig
): Promise<TokenFeeResults> {
const tokenAddressToToken = _.keyBy(tokens, (t) => t.address.toLowerCase());
const addressesRaw = _(tokens)
.map((token) => token.address.toLowerCase())
.uniq()
.value();

const addresses: string[] = [];
const tokenToResult: { [tokenAddress: string]: TokenFeeResult } = {};

// Check if we have cached token validation results for any tokens.
for (const address of addressesRaw) {
const cachedValue = await this.tokenFeeCache.get(this.CACHE_KEY(this.chainId, address));
if (cachedValue) {
tokenToResult[address] = cachedValue
} else if (this.allowList.has(address)) {
tokenToResult[address] = DEFAULT_TOKEN_FEE_RESULT;
} else {
addresses.push(address);
}
}

log.info(
`Got token fee results for ${addressesRaw.length - addresses.length
} tokens from cache. Getting ${addresses.length} on-chain.`
);

if (addresses.length > 0) {
const functionParams = addresses
.map((address) => [address, this.BASE_TOKEN, this.amountToFlashBorrow]) as [string, string, string][];

// We use the validate function instead of batchValidate to avoid poison pill problem.
// One token that consumes too much gas could cause the entire batch to fail.
const multicallResult =
await this.multicall2Provider.callSameFunctionOnContractWithMultipleParams<
[string, string, string], // address, base token address, amount to borrow
[TokenFeeResult]
>({
address: this.tokenValidatorAddress,
contractInterface: TokenFeeDetector__factory.createInterface(),
functionName: 'validate',
functionParams: functionParams,
providerConfig,
additionalConfig: {
gasLimitPerCallOverride: this.gasLimitPerCall,
},
});

for (let i = 0; i < multicallResult.results.length; i++) {
const resultWrapper = multicallResult.results[i]!;
const tokenAddress = addresses[i]!;
const token = tokenAddressToToken[tokenAddress]!;

// Could happen if the tokens transfer consumes too much gas so we revert. Just
// drop the token in that case.
if (!resultWrapper.success || resultWrapper.result.length < 1) {
log.warn(
{ result: resultWrapper },
`Failed to validate token ${token.symbol}`
);

continue;
}
if (resultWrapper.result.length > 1) {
log.warn(
{ result: resultWrapper },
`Unexpected result length: ${resultWrapper.result.length}`
);
}
const validationResult = resultWrapper.result[0]!;

tokenToResult[tokenAddress] =
validationResult as TokenFeeResult;

await this.tokenFeeCache.set(
this.CACHE_KEY(this.chainId, tokenAddress),
tokenToResult[tokenAddress]!
);
}
}

return {
marktoda marked this conversation as resolved.
Show resolved Hide resolved
getFeesByToken: (token: Token) =>
tokenToResult[token.address.toLowerCase()],
};
}
Comment on lines +159 to +163
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm also not a fan of returning anonymous functions as part of the response rather than objects of a specific class.

Could we instead do:

export class TokenFeeResults {
  constructor(private readonly tokenToResult: { [tokenAddress: string]: TokenFeeResult }) {}
  
  getFeesByToken(token: Token): TokenFeeResult | undefined {
    return this.tokenToResult[token.address.toLowerCase()];
  }
}

and here return:

  return new TokenFeeResults(tokenToResult);

}
2 changes: 1 addition & 1 deletion src/providers/token-validator-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { ICache } from './cache';
import { IMulticallProvider } from './multicall-provider';
import { ProviderConfig } from './provider';

const DEFAULT_ALLOWLIST = new Set<string>([
export const DEFAULT_ALLOWLIST = new Set<string>([
// RYOSHI. Does not allow transfers between contracts so fails validation.
'0x777E2ae845272a2F540ebf6a3D03734A5a8f618e'.toLowerCase(),
]);
Expand Down
Loading
Loading