diff --git a/src/dex/fluid-dex/constants.ts b/src/dex/fluid-dex/constants.ts new file mode 100644 index 000000000..da6d99534 --- /dev/null +++ b/src/dex/fluid-dex/constants.ts @@ -0,0 +1 @@ +export const MIN_SWAP_LIQUIDITY = 10n ** 4n; diff --git a/src/dex/fluid-dex/fluid-dex-e2e.test.ts b/src/dex/fluid-dex/fluid-dex-e2e.test.ts index a6873d07d..333aed44d 100644 --- a/src/dex/fluid-dex/fluid-dex-e2e.test.ts +++ b/src/dex/fluid-dex/fluid-dex-e2e.test.ts @@ -118,6 +118,23 @@ describe('FluidDex E2E', () => { describe('Mainnet', () => { const network = Network.MAINNET; + describe('ETH -> INST', () => { + const tokenASymbol: string = 'ETH'; + const tokenBSymbol: string = 'INST'; + + const tokenAAmount: string = '100000000000000'; + const tokenBAmount: string = '100000000000000'; + + testForNetwork( + network, + dexKey, + tokenASymbol, + tokenBSymbol, + tokenAAmount, + tokenBAmount, + ); + }); + describe('ETH -> wstETH', () => { const tokenASymbol: string = 'wstETH'; const tokenBSymbol: string = 'ETH'; diff --git a/src/dex/fluid-dex/fluid-dex-events.test.ts b/src/dex/fluid-dex/fluid-dex-events.test.ts index 19d6a89d7..974e8a506 100644 --- a/src/dex/fluid-dex/fluid-dex-events.test.ts +++ b/src/dex/fluid-dex/fluid-dex-events.test.ts @@ -10,6 +10,7 @@ import { FluidDexLiquidityProxyState } from './types'; import { FluidDexConfig } from './config'; import { FluidDexLiquidityProxy } from './fluid-dex-liquidity-proxy'; import { FluidDexFactory } from './fluid-dex-factory'; +import { FluidDexEventPool } from './fluid-dex-pool'; jest.setTimeout(50 * 1000); @@ -126,4 +127,15 @@ describe('FluidDex EventPool Mainnet', function () { }, ); }); + + describe('Pool events', () => { + let dexPool: FluidDexEventPool; + + const eventsToTest: Record = { + '0x8710039D5de6840EdE452A85672B32270a709aE2': { + LogPauseSwapAndArbitrage: [21337128], + LogUnpauseSwapAndArbitrage: [], + }, + }; + }); }); diff --git a/src/dex/fluid-dex/fluid-dex-liquidity-proxy.ts b/src/dex/fluid-dex/fluid-dex-liquidity-proxy.ts index ae61daa84..036eb43d1 100644 --- a/src/dex/fluid-dex/fluid-dex-liquidity-proxy.ts +++ b/src/dex/fluid-dex/fluid-dex-liquidity-proxy.ts @@ -5,6 +5,7 @@ import { bigIntify, catchParseLogError } from '../../utils'; import { StatefulEventSubscriber } from '../../stateful-event-subscriber'; import { IDexHelper } from '../../dex-helper/idex-helper'; import ResolverABI from '../../abi/fluid-dex/resolver.abi.json'; +import FluidDexPoolABI from '../../abi/fluid-dex/fluid-dex.abi.json'; import LiquidityABI from '../../abi/fluid-dex/liquidityUserModule.abi.json'; import { CommonAddresses, @@ -13,7 +14,13 @@ import { PoolReserveResponse, } from './types'; import { Address } from '../../types'; -import { Contract } from 'ethers'; +import { Contract, ethers } from 'ethers'; +import { uint256ToBigInt } from '../../lib/decoders'; +import { DecodedStateMultiCallResultWithRelativeBitmaps } from '../uniswap-v3/types'; + +const { + utils: { hexlify, hexZeroPad }, +} = ethers; export class FluidDexLiquidityProxy extends StatefulEventSubscriber { handlers: { @@ -28,7 +35,9 @@ export class FluidDexLiquidityProxy extends StatefulEventSubscriber ({ + // target: pool.pool, + // callData: this.poolIface.encodeFunctionData('readFromStorage', [ + // hexZeroPad(hexlify(1), 32), + // ]), + // decodeFunction: uint256ToBigInt, + // })); + // + // const storageResults = await this.dexHelper.multiWrapper.tryAggregate< + // bigint | DecodedStateMultiCallResultWithRelativeBitmaps + // >( + // false, + // multicallData, + // blockNumber, + // this.dexHelper.multiWrapper.defaultBatchSize, + // false, + // ); + // + // const poolsReserves = convertedResult.poolsReserves.map( + // (poolReserve, index) => { + // const isSwapAndArbitragePaused = + // BigInt(storageResults[index].returnData.toString()) >> BigInt(255) === + // BigInt(1); + // return { ...poolReserve, isSwapAndArbitragePaused }; + // }, + // ); + // this.logger.info(`${this.parentName}: ${this.name}: generating state...`); return convertedResult; diff --git a/src/dex/fluid-dex/fluid-dex-pool.ts b/src/dex/fluid-dex/fluid-dex-pool.ts new file mode 100644 index 000000000..2fbfa64c7 --- /dev/null +++ b/src/dex/fluid-dex/fluid-dex-pool.ts @@ -0,0 +1,138 @@ +import { StatefulEventSubscriber } from '../../stateful-event-subscriber'; +import { DeepReadonly } from 'ts-essentials'; +import { Address, Log, Logger } from '../../types'; +import { IDexHelper } from '../../dex-helper'; +import { Interface } from '@ethersproject/abi'; +import FluidDexPoolABI from '../../abi/fluid-dex/fluid-dex.abi.json'; +import { catchParseLogError } from '../../utils'; +import { ethers } from 'ethers'; +import { uint256ToBigInt } from '../../lib/decoders'; +import { DecodedStateMultiCallResultWithRelativeBitmaps } from '../uniswap-v3/types'; + +const { + utils: { hexlify, hexZeroPad }, +} = ethers; + +type PoolState = { + isSwapAndArbitragePaused: boolean; +}; + +export class FluidDexEventPool extends StatefulEventSubscriber { + handlers: { + [event: string]: ( + event: any, + state: DeepReadonly, + log: Readonly, + ) => DeepReadonly | null; + } = {}; + + logDecoder: (log: Log) => any; + addressesSubscribed: Address[]; + protected poolIface = new Interface(FluidDexPoolABI); + + constructor( + readonly parentName: string, + readonly poolAddress: string, + protected network: number, + protected dexHelper: IDexHelper, + logger: Logger, + ) { + super(parentName, 'pool', dexHelper, logger); + + this.logDecoder = (log: Log) => this.poolIface.parseLog(log); + this.addressesSubscribed = [poolAddress]; + + // Add handlers + this.handlers['LogPauseSwapAndArbitrage'] = + this.handleLogPauseSwapAndArbitrage.bind(this); + this.handlers['LogUnpauseSwapAndArbitrage'] = + this.handleLogUnpauseSwapAndArbitrage.bind(this); + } + + /** + * The function is called every time any of the subscribed + * addresses release log. The function accepts the current + * state, updates the state according to the log, and returns + * the updated state. + * @param state - Current state of event subscriber + * @param log - Log released by one of the subscribed addresses + * @returns Updates state of the event subscriber after the log + */ + async processLog( + state: DeepReadonly, + log: Readonly, + ): Promise | null> { + try { + let event; + try { + event = this.logDecoder(log); + } catch (e) { + return null; + } + if (event.name in this.handlers) { + return this.handlers[event.name](event, state, log); + } + } catch (e) { + catchParseLogError(e, this.logger); + } + + return null; + } + + handleLogPauseSwapAndArbitrage() { + return { isSwapAndArbitragePaused: true }; + } + + handleLogUnpauseSwapAndArbitrage() { + return { isSwapAndArbitragePaused: false }; + } + + async getStateOrGenerate( + blockNumber: number, + readonly: boolean = false, + ): Promise> { + let state = this.getState(blockNumber); + if (!state) { + state = await this.generateState(blockNumber); + if (!readonly) this.setState(state, blockNumber); + } + return state; + } + + /** + * The function generates state using on-chain calls. This + * function is called to regenerate state if the event based + * system fails to fetch events and the local state is no + * more correct. + * @param blockNumber - Blocknumber for which the state should + * should be generated + * @returns state of the event subscriber at blocknumber + */ + async generateState(blockNumber: number): Promise> { + const multicallData = [ + { + target: this.addressesSubscribed[0], + callData: this.poolIface.encodeFunctionData('readFromStorage', [ + hexZeroPad(hexlify(1), 32), + ]), + decodeFunction: uint256ToBigInt, + }, + ]; + + const storageResults = await this.dexHelper.multiWrapper.tryAggregate< + bigint | DecodedStateMultiCallResultWithRelativeBitmaps + >( + false, + multicallData, + blockNumber, + this.dexHelper.multiWrapper.defaultBatchSize, + false, + ); + + const isSwapAndArbitragePaused = + BigInt(storageResults[0].returnData.toString()) >> BigInt(255) === + BigInt(1); + + return { isSwapAndArbitragePaused }; + } +} diff --git a/src/dex/fluid-dex/fluid-dex.ts b/src/dex/fluid-dex/fluid-dex.ts index e8ea87ad6..a15894031 100644 --- a/src/dex/fluid-dex/fluid-dex.ts +++ b/src/dex/fluid-dex/fluid-dex.ts @@ -34,6 +34,8 @@ import { generalDecoder } from '../../lib/decoders'; import { BigNumber } from 'ethers'; import { sqrt } from './utils'; import { FluidDexLiquidityProxy } from './fluid-dex-liquidity-proxy'; +import { FluidDexEventPool } from './fluid-dex-pool'; +import { MIN_SWAP_LIQUIDITY } from './constants'; export class FluidDex extends SimpleExchange implements IDex { readonly hasConstantPriceLargeAmounts = false; @@ -46,14 +48,14 @@ export class FluidDex extends SimpleExchange implements IDex { pools: FluidDexPool[] = []; + eventPools: FluidDexEventPool[] = []; + readonly factory: FluidDexFactory; readonly liquidityProxy: FluidDexLiquidityProxy; readonly fluidDexPoolIface: Interface; - FEE_100_PERCENT = BigInt(1000000); - constructor( readonly network: Network, readonly dexKey: string, @@ -110,6 +112,20 @@ export class FluidDex extends SimpleExchange implements IDex { await this.factory.initialize(blockNumber); this.pools = await this.fetchFluidDexPools(blockNumber); + this.eventPools = await Promise.all( + this.pools.map(async pool => { + const eventPool = new FluidDexEventPool( + this.dexKey, + pool.address, + this.network, + this.dexHelper, + this.logger, + ); + await eventPool.initialize(blockNumber); + return eventPool; + }), + ); + await this.liquidityProxy.initialize(blockNumber); } @@ -147,29 +163,24 @@ export class FluidDex extends SimpleExchange implements IDex { side: SwapSide, blockNumber: number, ): Promise { - const pool = this.getPoolByTokenPair(srcToken.address, destToken.address); - return pool ? [pool.id] : []; + const pools = this.getPoolsByTokenPair(srcToken.address, destToken.address); + return pools.map(pool => pool.id); } - getPoolByTokenPair( - srcToken: Address, - destToken: Address, - ): FluidDexPool | null { + getPoolsByTokenPair(srcToken: Address, destToken: Address): FluidDexPool[] { const srcAddress = srcToken.toLowerCase(); const destAddress = destToken.toLowerCase(); // A pair must have 2 different tokens. - if (srcAddress === destAddress) return null; + if (srcAddress === destAddress) return []; - for (const pool of this.pools) { - if ( + const pools = this.pools.filter( + pool => (srcAddress === pool.token0 && destAddress === pool.token1) || - (srcAddress === pool.token1 && destAddress === pool.token0) - ) { - return pool; - } - } - return null; + (srcAddress === pool.token1 && destAddress === pool.token0), + ); + + return pools; } // Returns pool prices for amounts. @@ -187,61 +198,99 @@ export class FluidDex extends SimpleExchange implements IDex { try { if (srcToken.address.toLowerCase() === destToken.address.toLowerCase()) return null; - // Get the pool to use. - const pool = this.getPoolByTokenPair(srcToken.address, destToken.address); - if (!pool) return null; + + // Get the pools to use. + const pools = this.getPoolsByTokenPair( + srcToken.address, + destToken.address, + ); + + if (!pools.length) return null; + const poolIds = pools.map(pool => pool.id); // Make sure the pool meets the optional limitPools filter. - if (limitPools && !limitPools.includes(pool.id)) return null; + if ( + limitPools && + !limitPools.every(limitPool => poolIds.includes(limitPool)) + ) + return null; const liquidityProxyState = await this.liquidityProxy.getStateOrGenerate( blockNumber, ); - const currentPoolReserves = liquidityProxyState.poolsReserves.find( - poolReserve => - poolReserve.pool.toLowerCase() === pool.address.toLowerCase(), - ); - if (!currentPoolReserves) { - return null; - } - const prices = amounts.map(amount => { - if (side === SwapSide.SELL) { - return this.swapIn( - srcToken.address.toLowerCase() === pool.token0.toLowerCase(), - amount, - currentPoolReserves.collateralReserves, - currentPoolReserves.debtReserves, - srcToken.decimals, - destToken.decimals, - BigInt(currentPoolReserves.fee), - currentPoolReserves.dexLimits, - Math.floor(Date.now() / 1000), + const poolsPrices = await Promise.all( + pools.map(async pool => { + const currentPoolReserves = liquidityProxyState.poolsReserves.find( + poolReserve => + poolReserve.pool.toLowerCase() === pool.address.toLowerCase(), ); - } else { - return this.swapOut( - srcToken.address.toLowerCase() === pool.token0.toLowerCase(), - amount, - currentPoolReserves.collateralReserves, - currentPoolReserves.debtReserves, - srcToken.decimals, - destToken.decimals, - BigInt(currentPoolReserves.fee), - currentPoolReserves.dexLimits, - Math.floor(Date.now() / 1000), + + const eventPool = this.eventPools.find( + eventPool => + eventPool.poolAddress.toLowerCase() === + pool.address.toLowerCase(), ); - } - }); - return [ - { - prices: prices, - unit: getBigIntPow(destToken.decimals), - data: {}, - exchange: this.dexKey, - poolIdentifier: pool.id, - gasCost: FLUID_DEX_GAS_COST, - poolAddresses: [pool.address], - }, - ]; + + if (!eventPool) { + this.logger.warn( + `${this.dexKey}-${this.network}: Event pool ${pool.address} was not found...`, + ); + return null; + } + + const state = await eventPool.getStateOrGenerate(blockNumber); + + if (!currentPoolReserves || state.isSwapAndArbitragePaused === true) { + return null; + } + + const prices = amounts.map(amount => { + if (side === SwapSide.SELL) { + return this.swapIn( + srcToken.address.toLowerCase() === pool.token0.toLowerCase(), + amount, + currentPoolReserves.collateralReserves, + currentPoolReserves.debtReserves, + srcToken.decimals, + destToken.decimals, + BigInt(currentPoolReserves.fee), + currentPoolReserves.dexLimits, + Math.floor(Date.now() / 1000), + ); + } else { + return this.swapOut( + srcToken.address.toLowerCase() === pool.token0.toLowerCase(), + amount, + currentPoolReserves.collateralReserves, + currentPoolReserves.debtReserves, + srcToken.decimals, + destToken.decimals, + BigInt(currentPoolReserves.fee), + currentPoolReserves.dexLimits, + Math.floor(Date.now() / 1000), + ); + } + }); + + return { + prices: prices, + unit: getBigIntPow(destToken.decimals), + data: { + poolId: pool.id, + }, + exchange: this.dexKey, + poolIdentifier: pool.id, + gasCost: FLUID_DEX_GAS_COST, + poolAddresses: [pool.address], + }; + }), + ); + + const notNullResults = poolsPrices.filter( + res => res !== null, + ) as ExchangePrices; + + return notNullResults; } catch (e) { this.logger.error( `Error_getPricesVolume ${srcToken.address || srcToken.symbol}, ${ @@ -272,10 +321,9 @@ export class FluidDex extends SimpleExchange implements IDex { ): AdapterExchangeParam { // Encode here the payload for adapter const payload = ''; - const pool = this.getPoolByTokenPair(srcToken, destToken); return { - targetExchange: pool!.address, + targetExchange: '0x', payload, networkFee: '0', }; @@ -313,7 +361,11 @@ export class FluidDex extends SimpleExchange implements IDex { side === SwapSide.SELL ? 'amountOut_' : 'amountIn_', ); - const pool = this.getPoolByTokenPair(srcToken, destToken); + const pool = this.pools.find(pool => pool.id === data.poolId); + if (!pool) + throw new Error( + `${this.dexKey}-${this.network}: Pool with id: ${data.poolId} was not found`, + ); if (side === SwapSide.SELL) { if (pool!.token0.toLowerCase() !== srcToken.toLowerCase()) { @@ -584,11 +636,84 @@ export class FluidDex extends SimpleExchange implements IDex { if (priceDiff > maxAllowedDiff) { return 0n; } + + if (amountInCollateral > 0) { + let reservesRatioValid = swap0To1 + ? this.verifyToken1Reserves( + colReserveIn + amountInCollateral, + colReserveOut - amountOutCollateral, + oldPrice, + ) + : this.verifyToken0Reserves( + colReserveOut - amountOutCollateral, + colReserveIn + amountInCollateral, + oldPrice, + ); + if (!reservesRatioValid) { + return 0n; + } + } + + if (amountInDebt > 0) { + let reservesRatioValid = swap0To1 + ? this.verifyToken1Reserves( + debtReserveIn + amountInDebt, + debtReserveOut - amountOutDebt, + oldPrice, + ) + : this.verifyToken0Reserves( + debtReserveOut - amountOutDebt, + debtReserveIn + amountInDebt, + oldPrice, + ); + if (!reservesRatioValid) { + return 0n; + } + } + const totalAmountOut = amountOutCollateral + amountOutDebt; return totalAmountOut; } + /** + * Checks if token0 reserves are sufficient compared to token1 reserves. + * This helps prevent edge cases and ensures high precision in calculations. + * @param {number} token0Reserves - The reserves of token0. + * @param {number} token1Reserves - The reserves of token1. + * @param {number} price - The current price used for calculation. + * @returns {boolean} - Returns false if token0 reserves are too low, true otherwise. + */ + protected verifyToken0Reserves( + token0Reserves: bigint, + token1Reserves: bigint, + price: bigint, + ): boolean { + return ( + token0Reserves >= + (token1Reserves * 10n ** 27n) / (price * MIN_SWAP_LIQUIDITY) + ); + } + + /** + * Checks if token1 reserves are sufficient compared to token0 reserves. + * This helps prevent edge cases and ensures high precision in calculations. + * @param {number} token0Reserves - The reserves of token0. + * @param {number} token1Reserves - The reserves of token1. + * @param {number} price - The current price used for calculation. + * @returns {boolean} - Returns false if token1 reserves are too low, true otherwise. + */ + protected verifyToken1Reserves( + token0Reserves: bigint, + token1Reserves: bigint, + price: bigint, + ): boolean { + return ( + token1Reserves >= + (token0Reserves * price) / (10n ** 27n * MIN_SWAP_LIQUIDITY) + ); + } + /** * Calculates the currently available swappable amount for a token limit considering expansion since last syncTime. * @param syncTime - timestamp in seconds when the limits were synced @@ -765,7 +890,7 @@ export class FluidDex extends SimpleExchange implements IDex { syncTime, ); - if (amountIn == 2n ** 256n - 1n) { + if (amountIn === 2n ** 256n - 1n) { return amountIn; } const ans = (amountIn * BigInt(10 ** inDecimals)) / BigInt(10 ** 12); @@ -985,6 +1110,39 @@ export class FluidDex extends SimpleExchange implements IDex { return 2n ** 256n - 1n; } + if (amountInCollateral > 0) { + let reservesRatioValid = swap0to1 + ? this.verifyToken1Reserves( + colReserveIn + amountInCollateral, + colReserveOut - amountOutCollateral, + oldPrice, + ) + : this.verifyToken0Reserves( + colReserveOut - amountOutCollateral, + colReserveIn + amountInCollateral, + oldPrice, + ); + if (!reservesRatioValid) { + return 0n; + } + } + if (amountInDebt > 0) { + let reservesRatioValid = swap0to1 + ? this.verifyToken1Reserves( + debtReserveIn + amountInDebt, + debtReserveOut - amountOutDebt, + oldPrice, + ) + : this.verifyToken0Reserves( + debtReserveOut - amountOutDebt, + debtReserveIn + amountInDebt, + oldPrice, + ); + if (!reservesRatioValid) { + return 0n; + } + } + const totalAmountIn = amountInCollateral + amountInDebt; return totalAmountIn; diff --git a/src/dex/fluid-dex/types.ts b/src/dex/fluid-dex/types.ts index cffe433ce..0fb982e07 100644 --- a/src/dex/fluid-dex/types.ts +++ b/src/dex/fluid-dex/types.ts @@ -72,7 +72,9 @@ export interface PoolWithReserves { debtReserves: DebtReserves; } -export type FluidDexData = {}; +export type FluidDexData = { + poolId: string; +}; // Each pool has a contract address and token pairs. export type FluidDexPool = { diff --git a/tests/constants-e2e.ts b/tests/constants-e2e.ts index a924a7192..bd2c3934e 100644 --- a/tests/constants-e2e.ts +++ b/tests/constants-e2e.ts @@ -131,6 +131,10 @@ export const Tokens: { addBalance: balancesFn, addAllowance: allowedFn, }, + INST: { + address: '0x6f40d4A6237C257fff2dB00FA0510DeEECd303eb', + decimals: 18, + }, aEthUSDC: { address: '0x98c23e9d8f34fefb1b7bd6a91b7ff122f4e16f5c', decimals: 6, @@ -1681,6 +1685,7 @@ export const Holders: { SKY: '0x0ddda327A6614130CCb20bc0097313A282176A01', MKR: '0xe9aAA7A9DDc0877626C1779AbC29993aD89A6c1f', ETHx: '0xFCC1A2c71F01B7f58Ed538a6B4AAa5A0724eB5A6', + INST: '0xe2Dd506477D4792A7E811D2E93D44CeBa82c668B', // Idle tokens AA_wstETH: '0xd7C1b48877A7dFA7D51cf1144c89C0A3F134F935', 'AA_idle_cpPOR-USDC': '0x085c8eaccA6911fE60aE3f8FbAe5F3012E3A05Ec',