diff --git a/balancer-js/src/sor/pool-data/onChainData.ts b/balancer-js/src/sor/pool-data/onChainData.ts new file mode 100644 index 000000000..43ec71c70 --- /dev/null +++ b/balancer-js/src/sor/pool-data/onChainData.ts @@ -0,0 +1,211 @@ +import { formatFixed } from '@ethersproject/bignumber'; +import { Provider } from '@ethersproject/providers'; +import { PoolFilter, SubgraphPoolBase } from '@balancer-labs/sor'; +import { Multicaller } from '../../utils/multiCaller'; +import { isSameAddress } from '../../utils'; + +// TODO: decide whether we want to trim these ABIs down to the relevant functions +import vaultAbi from '../../abi/Vault.json'; +import aTokenRateProvider from '../../abi/StaticATokenRateProvider.json'; +import weightedPoolAbi from '../../abi/WeightedPool.json'; +import stablePoolAbi from '../../abi/StablePool.json'; +import elementPoolAbi from '../../abi/ConvergentCurvePool.json'; +import linearPoolAbi from '../../abi/LinearPool.json'; + +export async function getOnChainBalances( + subgraphPoolsOriginal: SubgraphPoolBase[], + multiAddress: string, + vaultAddress: string, + provider: Provider +): Promise { + if (subgraphPoolsOriginal.length === 0) return subgraphPoolsOriginal; + + const abis: any = Object.values( + // Remove duplicate entries using their names + Object.fromEntries( + [ + ...vaultAbi, + ...aTokenRateProvider, + ...weightedPoolAbi, + ...stablePoolAbi, + ...elementPoolAbi, + ...linearPoolAbi, + ].map((row) => [row.name, row]) + ) + ); + + const multiPool = new Multicaller(multiAddress, provider, abis); + + const supportedPoolTypes: string[] = Object.values(PoolFilter); + const subgraphPools: SubgraphPoolBase[] = []; + subgraphPoolsOriginal.forEach((pool) => { + if (!supportedPoolTypes.includes(pool.poolType)) { + console.error(`Unknown pool type: ${pool.poolType} ${pool.id}`); + return; + } + + subgraphPools.push(pool); + + multiPool.call(`${pool.id}.poolTokens`, vaultAddress, 'getPoolTokens', [ + pool.id, + ]); + multiPool.call(`${pool.id}.totalSupply`, pool.address, 'totalSupply'); + + // TO DO - Make this part of class to make more flexible? + if ( + pool.poolType === 'Weighted' || + pool.poolType === 'LiquidityBootstrapping' || + pool.poolType === 'Investment' + ) { + multiPool.call( + `${pool.id}.weights`, + pool.address, + 'getNormalizedWeights' + ); + multiPool.call( + `${pool.id}.swapFee`, + pool.address, + 'getSwapFeePercentage' + ); + } else if ( + pool.poolType === 'Stable' || + pool.poolType === 'MetaStable' || + pool.poolType === 'StablePhantom' + ) { + // MetaStable & StablePhantom is the same as Stable for multicall purposes + multiPool.call( + `${pool.id}.amp`, + pool.address, + 'getAmplificationParameter' + ); + multiPool.call( + `${pool.id}.swapFee`, + pool.address, + 'getSwapFeePercentage' + ); + } else if (pool.poolType === 'Element') { + multiPool.call(`${pool.id}.swapFee`, pool.address, 'percentFee'); + } else if (pool.poolType === 'AaveLinear') { + multiPool.call( + `${pool.id}.swapFee`, + pool.address, + 'getSwapFeePercentage' + ); + + multiPool.call(`${pool.id}.targets`, pool.address, 'getTargets'); + multiPool.call( + `${pool.id}.rate`, + pool.address, + 'getWrappedTokenRate' + ); + } + }); + + let pools = {} as Record< + string, + { + amp?: string[]; + swapFee: string; + weights?: string[]; + targets?: string[]; + poolTokens: { + tokens: string[]; + balances: string[]; + }; + rate?: string; + } + >; + + try { + pools = (await multiPool.execute()) as Record< + string, + { + amp?: string[]; + swapFee: string; + weights?: string[]; + poolTokens: { + tokens: string[]; + balances: string[]; + }; + rate?: string; + } + >; + } catch (err) { + throw `Issue with multicall execution.`; + } + + const onChainPools: SubgraphPoolBase[] = []; + + Object.entries(pools).forEach(([poolId, onchainData], index) => { + try { + const { poolTokens, swapFee, weights } = onchainData; + + if ( + subgraphPools[index].poolType === 'Stable' || + subgraphPools[index].poolType === 'MetaStable' || + subgraphPools[index].poolType === 'StablePhantom' + ) { + if (!onchainData.amp) { + console.error(`Stable Pool Missing Amp: ${poolId}`); + return; + } else { + // Need to scale amp by precision to match expected Subgraph scale + // amp is stored with 3 decimals of precision + subgraphPools[index].amp = formatFixed( + onchainData.amp[0], + 3 + ); + } + } + + if (subgraphPools[index].poolType === 'AaveLinear') { + if (!onchainData.targets) { + console.error(`Linear Pool Missing Targets: ${poolId}`); + return; + } else { + subgraphPools[index].lowerTarget = formatFixed( + onchainData.targets[0], + 18 + ); + subgraphPools[index].upperTarget = formatFixed( + onchainData.targets[1], + 18 + ); + } + + const wrappedIndex = subgraphPools[index].wrappedIndex; + if ( + wrappedIndex === undefined || + onchainData.rate === undefined + ) { + console.error( + `Linear Pool Missing WrappedIndex or PriceRate: ${poolId}` + ); + return; + } + // Update priceRate of wrappedToken + subgraphPools[index].tokens[wrappedIndex].priceRate = + formatFixed(onchainData.rate, 18); + } + + subgraphPools[index].swapFee = formatFixed(swapFee, 18); + + poolTokens.tokens.forEach((token, i) => { + const T = subgraphPools[index].tokens.find((t) => + isSameAddress(t.address, token) + ); + if (!T) throw `Pool Missing Expected Token: ${poolId} ${token}`; + T.balance = formatFixed(poolTokens.balances[i], T.decimals); + if (weights) { + // Only expected for WeightedPools + T.weight = formatFixed(weights[i], 18); + } + }); + onChainPools.push(subgraphPools[index]); + } catch (err) { + throw `Issue with pool onchain data: ${err}`; + } + }); + + return onChainPools; +} diff --git a/balancer-js/src/sor/pool-data/subgraphPoolDataService.ts b/balancer-js/src/sor/pool-data/subgraphPoolDataService.ts new file mode 100644 index 000000000..99e8df033 --- /dev/null +++ b/balancer-js/src/sor/pool-data/subgraphPoolDataService.ts @@ -0,0 +1,86 @@ +import { PoolDataService, SubgraphPoolBase } from '@balancer-labs/sor'; +import { + OrderDirection, + Pool_OrderBy, + SubgraphClient, +} from '../../subgraph/subgraph'; +import { parseInt } from 'lodash'; +import { getOnChainBalances } from './onChainData'; +import { Provider } from '@ethersproject/providers'; +import { Network } from '../../constants/network'; +import { BalancerNetworkConfig, BalancerSdkSorConfig } from '../../types'; + +const NETWORKS_WITH_LINEAR_POOLS = [ + Network.MAINNET, + Network.ROPSTEN, + Network.RINKEBY, + Network.GĂ–RLI, + Network.KOVAN, +]; + +export class SubgraphPoolDataService implements PoolDataService { + constructor( + private readonly client: SubgraphClient, + private readonly provider: Provider, + private readonly network: BalancerNetworkConfig, + private readonly sorConfig: BalancerSdkSorConfig + ) {} + + public async getPools(): Promise { + const pools = this.supportsLinearPools + ? await this.getLinearPools() + : await this.getNonLinearPools(); + + const mapped = pools.map((pool) => ({ + ...pool, + poolType: pool.poolType || '', + tokens: (pool.tokens || []).map((token) => ({ + ...token, + weight: token.weight || null, + })), + totalWeight: pool.totalWeight || undefined, + amp: pool.amp || undefined, + expiryTime: pool.expiryTime ? parseInt(pool.expiryTime) : undefined, + unitSeconds: pool.unitSeconds + ? parseInt(pool.unitSeconds) + : undefined, + principalToken: pool.principalToken || undefined, + baseToken: pool.baseToken || undefined, + })); + + if (this.sorConfig.fetchOnChainBalances === false) { + return mapped; + } + + return getOnChainBalances( + mapped, + this.network.multicall, + this.network.vault, + this.provider + ); + } + + private get supportsLinearPools() { + return NETWORKS_WITH_LINEAR_POOLS.includes(this.network.chainId); + } + + private async getLinearPools() { + const { pools } = await this.client.SubgraphPools({ + where: { swapEnabled: true }, + orderBy: Pool_OrderBy.TotalLiquidity, + orderDirection: OrderDirection.Desc, + }); + + return pools; + } + + private async getNonLinearPools() { + const { pools } = await this.client.SubgraphPoolsWithoutLinear({ + where: { swapEnabled: true }, + orderBy: Pool_OrderBy.TotalLiquidity, + orderDirection: OrderDirection.Desc, + }); + + return pools; + } +} diff --git a/balancer-js/src/sor/sorFactory.ts b/balancer-js/src/sor/sorFactory.ts new file mode 100644 index 000000000..9566c3808 --- /dev/null +++ b/balancer-js/src/sor/sorFactory.ts @@ -0,0 +1,61 @@ +import { SOR, TokenPriceService } from '@balancer-labs/sor'; +import { Provider } from '@ethersproject/providers'; +import { SubgraphPoolDataService } from './pool-data/subgraphPoolDataService'; +import { CoingeckoTokenPriceService } from './token-price/coingeckoTokenPriceService'; +import { SubgraphClient } from '../subgraph/subgraph'; +import { BalancerNetworkConfig, BalancerSdkSorConfig } from '../types'; +import { SubgraphTokenPriceService } from './token-price/subgraphTokenPriceService'; + +export class SorFactory { + public static createSor( + network: BalancerNetworkConfig, + sorConfig: BalancerSdkSorConfig, + provider: Provider, + subgraphClient: SubgraphClient + ): SOR { + const poolDataService = SorFactory.getPoolDataService( + network, + sorConfig, + provider, + subgraphClient + ); + + const tokenPriceService = SorFactory.getTokenPriceService( + network, + sorConfig, + subgraphClient + ); + + return new SOR(provider, network, poolDataService, tokenPriceService); + } + + private static getPoolDataService( + network: BalancerNetworkConfig, + sorConfig: BalancerSdkSorConfig, + provider: Provider, + subgraphClient: SubgraphClient + ) { + return typeof sorConfig.poolDataService === 'object' + ? sorConfig.poolDataService + : new SubgraphPoolDataService( + subgraphClient, + provider, + network, + sorConfig + ); + } + + private static getTokenPriceService( + network: BalancerNetworkConfig, + sorConfig: BalancerSdkSorConfig, + subgraphClient: SubgraphClient + ): TokenPriceService { + if (typeof sorConfig.tokenPriceService === 'object') { + return sorConfig.tokenPriceService; + } else if (sorConfig.tokenPriceService === 'subgraph') { + new SubgraphTokenPriceService(subgraphClient, network.weth); + } + + return new CoingeckoTokenPriceService(network.chainId); + } +} diff --git a/balancer-js/src/sor/token-price/coingeckoTokenPriceService.ts b/balancer-js/src/sor/token-price/coingeckoTokenPriceService.ts new file mode 100644 index 000000000..c5e1ffcd7 --- /dev/null +++ b/balancer-js/src/sor/token-price/coingeckoTokenPriceService.ts @@ -0,0 +1,70 @@ +import { TokenPriceService } from '@balancer-labs/sor'; +import axios from 'axios'; + +export class CoingeckoTokenPriceService implements TokenPriceService { + constructor(private readonly chainId: number) {} + + public async getNativeAssetPriceInToken( + tokenAddress: string + ): Promise { + const ethPerToken = await this.getTokenPriceInNativeAsset(tokenAddress); + + // We get the price of token in terms of ETH + // We want the price of 1 ETH in terms of the token base units + return `${1 / parseFloat(ethPerToken)}`; + } + + /** + * @dev Assumes that the native asset has 18 decimals + * @param tokenAddress - the address of the token contract + * @returns the price of 1 ETH in terms of the token base units + */ + async getTokenPriceInNativeAsset(tokenAddress: string): Promise { + const endpoint = `https://api.coingecko.com/api/v3/simple/token_price/${this.platformId}?contract_addresses=${tokenAddress}&vs_currencies=${this.nativeAssetId}`; + + const { data } = await axios.get(endpoint, { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + }); + + if ( + data[tokenAddress.toLowerCase()][this.nativeAssetId] === undefined + ) { + throw Error('No price returned from Coingecko'); + } + + return data[tokenAddress.toLowerCase()][this.nativeAssetId]; + } + + private get platformId(): string { + switch (this.chainId) { + case 1: + return 'ethereum'; + case 42: + return 'ethereum'; + case 137: + return 'polygon-pos'; + case 42161: + return 'arbitrum-one'; + } + + return '2'; + } + + private get nativeAssetId(): string { + switch (this.chainId) { + case 1: + return 'eth'; + case 42: + return 'eth'; + case 137: + return ''; + case 42161: + return 'eth'; + } + + return ''; + } +} diff --git a/balancer-js/src/sor/token-price/subgraphTokenPriceService.ts b/balancer-js/src/sor/token-price/subgraphTokenPriceService.ts new file mode 100644 index 000000000..94b764861 --- /dev/null +++ b/balancer-js/src/sor/token-price/subgraphTokenPriceService.ts @@ -0,0 +1,68 @@ +import { TokenPriceService } from '@balancer-labs/sor'; +import { SubgraphClient } from '../../subgraph/subgraph'; +import { keyBy } from 'lodash'; + +export class SubgraphTokenPriceService implements TokenPriceService { + private readonly weth: string; + + constructor(private readonly client: SubgraphClient, weth: string) { + //the subgraph addresses are all toLowerCase + this.weth = weth.toLowerCase(); + } + + public async getNativeAssetPriceInToken( + tokenAddress: string + ): Promise { + const ethPerToken = await this.getLatestPriceInEthFromSubgraph( + tokenAddress + ); + + if (!ethPerToken) { + throw Error('No price found in the subgraph'); + } + + // We want the price of 1 ETH in terms of the token base units + return `${1 / ethPerToken}`; + } + + public async getLatestPriceInEthFromSubgraph( + tokenAddress: string + ): Promise { + tokenAddress = tokenAddress.toLowerCase(); + + const { latestPrices } = await this.client.SubgraphTokenLatestPrices({ + where: { asset_in: [tokenAddress, this.weth] }, + }); + const pricesKeyedOnId = keyBy(latestPrices, 'id'); + + //the ids are set as ${asset}-${pricingAsset} + //first try to find an exact match + if (pricesKeyedOnId[`${tokenAddress}-${this.weth}`]) { + return parseFloat( + pricesKeyedOnId[`${tokenAddress}-${this.weth}`].price + ); + } + + //no exact match, try to traverse the path + const matchingLatestPrices = latestPrices.filter( + (price) => price.asset === tokenAddress + ); + + //pick the first one we match on. + //There is no timestamp on latestPrice, should get introduced to allow for sorting by latest + for (const tokenPrice of matchingLatestPrices) { + const pricingAssetPricedInEth = + pricesKeyedOnId[`${tokenPrice.pricingAsset}-${this.weth}`]; + + //1 BAL = 20 USDC, 1 USDC = 0.00025 ETH, 1 BAL = 20 * 0.00025 + if (pricingAssetPricedInEth) { + return ( + parseFloat(tokenPrice.price) * + parseFloat(pricingAssetPricedInEth.price) + ); + } + } + + return null; + } +} diff --git a/balancer-js/src/utils/multiCaller.ts b/balancer-js/src/utils/multiCaller.ts new file mode 100644 index 000000000..1a417e432 --- /dev/null +++ b/balancer-js/src/utils/multiCaller.ts @@ -0,0 +1,64 @@ +import { set } from 'lodash'; +import { Fragment, JsonFragment, Interface, Result } from '@ethersproject/abi'; +import { Contract } from '@ethersproject/contracts'; +import { Provider } from '@ethersproject/providers'; + +export class Multicaller { + private multiAddress: string; + private provider: Provider; + private interface: Interface; + public options: any = {}; + private calls: [string, string, any][] = []; + private paths: any[] = []; + + constructor( + multiAddress: string, + provider: Provider, + abi: string | Array, + options = {} + ) { + this.multiAddress = multiAddress; + this.provider = provider; + this.interface = new Interface(abi); + this.options = options; + } + + call(path: string, address: string, functionName: string, params?: any[]): Multicaller { + this.calls.push([address, functionName, params]); + this.paths.push(path); + return this; + } + + async execute(from: Record = {}): Promise> { + const obj = from; + const results = await this.executeMulticall(); + results.forEach((result, i) => + set(obj, this.paths[i], result.length > 1 ? result : result[0]) + ); + this.calls = []; + this.paths = []; + return obj; + } + + private async executeMulticall(): Promise { + const multi = new Contract( + this.multiAddress, + [ + 'function aggregate(tuple[](address target, bytes callData) memory calls) public view returns (uint256 blockNumber, bytes[] memory returnData)', + ], + this.provider + ); + + const [, res] = await multi.aggregate( + this.calls.map(([address, functionName, params]) => [ + address, + this.interface.encodeFunctionData(functionName, params), + ]), + this.options + ); + + return res.map((result: any, i: number) => + this.interface.decodeFunctionResult(this.calls[i][1], result) + ); + } +}