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 8 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
169 changes: 169 additions & 0 deletions src/providers/token-fee-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
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;
}

const FEE_DETECTOR_ADDRESS = '0x57eC54d113719dDE9A90E6bE807524a86560E89D';
const AMOUNT_TO_FLASH_BORROW = '10000';
marktoda marked this conversation as resolved.
Show resolved Hide resolved
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(
protected chainId: ChainId,
marktoda marked this conversation as resolved.
Show resolved Hide resolved
marktoda marked this conversation as resolved.
Show resolved Hide resolved
marktoda marked this conversation as resolved.
Show resolved Hide resolved
protected 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, 'address');
const addressesRaw = _(tokens)
.map((token) => token.address)
marktoda marked this conversation as resolved.
Show resolved Hide resolved
.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) {
if (
marktoda marked this conversation as resolved.
Show resolved Hide resolved
await this.tokenFeeCache.has(
this.CACHE_KEY(this.chainId, address)
)
) {
tokenToResult[address.toLowerCase()] =
(await this.tokenFeeCache.get(
this.CACHE_KEY(this.chainId, address)
))!;
} else {
addresses.push(address);
}
}

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

const functionParams = _(addresses)
marktoda marked this conversation as resolved.
Show resolved Hide resolved
.map((address) => [address, this.BASE_TOKEN, this.amountToFlashBorrow])
.value() 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.
marktoda marked this conversation as resolved.
Show resolved Hide resolved
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]!;

if (this.allowList.has(token.address.toLowerCase())) {
marktoda marked this conversation as resolved.
Show resolved Hide resolved
tokenToResult[token.address.toLowerCase()] = DEFAULT_TOKEN_FEE_RESULT;

await this.tokenFeeCache.set(
this.CACHE_KEY(this.chainId, token.address.toLowerCase()),
tokenToResult[token.address.toLowerCase()]!
);

continue;
}

// Could happen if the tokens transfer consumes too much gas so we revert. Just
// drop the token in that case.
if (!resultWrapper.success) {
log.info(
marktoda marked this conversation as resolved.
Show resolved Hide resolved
{ result: resultWrapper },
`Failed to validate token ${token.symbol}`
);

continue;
}

const validationResult = resultWrapper.result[0]!;
marktoda marked this conversation as resolved.
Show resolved Hide resolved

tokenToResult[token.address.toLowerCase()] =
validationResult as TokenFeeResult;

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

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