diff --git a/src/abi/rubicon-rfq/rubicon-rfq.abi.json b/src/abi/rubicon-rfq/rubicon-rfq.abi.json new file mode 100644 index 000000000..0736e1227 --- /dev/null +++ b/src/abi/rubicon-rfq/rubicon-rfq.abi.json @@ -0,0 +1,629 @@ +[ + { + "inputs": [ + { + "internalType": "address", + "name": "reactor", + "type": "address" + }, + { + "internalType": "address", + "name": "admin", + "type": "address" + }, + { + "internalType": "address", + "name": "rfqSgn", + "type": "address" + }, + { + "internalType": "string", + "name": "name_EIP712", + "type": "string" + }, + { + "internalType": "string", + "name": "version_EIP712", + "type": "string" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [], + "name": "InvalidShortString", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidSignature", + "type": "error" + }, + { + "inputs": [], + "name": "ResponseExpired", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "string", + "name": "str", + "type": "string" + } + ], + "name": "StringTooLong", + "type": "error" + }, + { + "inputs": [], + "name": "UnableToCall", + "type": "error" + }, + { + "inputs": [], + "name": "Unauthorized", + "type": "error" + }, + { + "inputs": [], + "name": "Unprofitable", + "type": "error" + }, + { + "inputs": [], + "name": "ZeroAddress", + "type": "error" + }, + { + "anonymous": false, + "inputs": [], + "name": "EIP712DomainChanged", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + } + ], + "name": "LogSetOwner", + "type": "event" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "t", + "type": "address" + }, + { + "internalType": "bool", + "name": "enableExec", + "type": "bool" + }, + { + "internalType": "bool", + "name": "enableFill", + "type": "bool" + } + ], + "name": "addTagger", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "eip712Domain", + "outputs": [ + { + "internalType": "bytes1", + "name": "fields", + "type": "bytes1" + }, + { + "internalType": "string", + "name": "name", + "type": "string" + }, + { + "internalType": "string", + "name": "version", + "type": "string" + }, + { + "internalType": "uint256", + "name": "chainId", + "type": "uint256" + }, + { + "internalType": "address", + "name": "verifyingContract", + "type": "address" + }, + { + "internalType": "bytes32", + "name": "salt", + "type": "bytes32" + }, + { + "internalType": "uint256[]", + "name": "extensions", + "type": "uint256[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "internalType": "bytes", + "name": "order", + "type": "bytes" + }, + { + "internalType": "bytes", + "name": "sig", + "type": "bytes" + } + ], + "internalType": "struct SignedOrder", + "name": "order", + "type": "tuple" + } + ], + "name": "execute", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "internalType": "bytes", + "name": "order", + "type": "bytes" + }, + { + "internalType": "bytes", + "name": "sig", + "type": "bytes" + } + ], + "internalType": "struct SignedOrder", + "name": "order", + "type": "tuple" + }, + { + "internalType": "uint256", + "name": "quantity", + "type": "uint256" + } + ], + "name": "execute", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "internalType": "bytes", + "name": "order", + "type": "bytes" + }, + { + "internalType": "bytes", + "name": "sig", + "type": "bytes" + } + ], + "internalType": "struct SignedOrder[]", + "name": "orders", + "type": "tuple[]" + } + ], + "name": "executeBatch", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "internalType": "bytes", + "name": "order", + "type": "bytes" + }, + { + "internalType": "bytes", + "name": "sig", + "type": "bytes" + } + ], + "internalType": "struct SignedOrder[]", + "name": "orders", + "type": "tuple[]" + }, + { + "internalType": "uint256[]", + "name": "quantities", + "type": "uint256[]" + } + ], + "name": "executeBatch", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "internalType": "bytes", + "name": "order", + "type": "bytes" + }, + { + "internalType": "bytes", + "name": "sig", + "type": "bytes" + } + ], + "internalType": "struct SignedOrder[]", + "name": "orders", + "type": "tuple[]" + }, + { + "internalType": "bytes", + "name": "callbackData", + "type": "bytes" + } + ], + "name": "executeBatchWithCallback", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "internalType": "bytes", + "name": "order", + "type": "bytes" + }, + { + "internalType": "bytes", + "name": "sig", + "type": "bytes" + } + ], + "internalType": "struct SignedOrder[]", + "name": "orders", + "type": "tuple[]" + }, + { + "internalType": "uint256[]", + "name": "quantities", + "type": "uint256[]" + }, + { + "internalType": "bytes", + "name": "callbackData", + "type": "bytes" + } + ], + "name": "executeBatchWithCallback", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "internalType": "bytes", + "name": "order", + "type": "bytes" + }, + { + "internalType": "bytes", + "name": "sig", + "type": "bytes" + } + ], + "internalType": "struct SignedOrder", + "name": "order", + "type": "tuple" + }, + { + "internalType": "bytes", + "name": "callbackData", + "type": "bytes" + } + ], + "name": "executeWithCallback", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "internalType": "bytes", + "name": "order", + "type": "bytes" + }, + { + "internalType": "bytes", + "name": "sig", + "type": "bytes" + } + ], + "internalType": "struct SignedOrder", + "name": "order", + "type": "tuple" + }, + { + "internalType": "uint256", + "name": "quantity", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "callbackData", + "type": "bytes" + } + ], + "name": "executeWithCallback", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "internalType": "address", + "name": "sellToken", + "type": "address" + }, + { + "internalType": "address", + "name": "buyToken", + "type": "address" + }, + { + "internalType": "uint256", + "name": "sellAmt", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "buyAmt", + "type": "uint256" + } + ], + "internalType": "struct Quote", + "name": "q", + "type": "tuple" + }, + { + "components": [ + { + "components": [ + { + "internalType": "bytes", + "name": "order", + "type": "bytes" + }, + { + "internalType": "bytes", + "name": "sig", + "type": "bytes" + } + ], + "internalType": "struct SignedOrder[]", + "name": "orders", + "type": "tuple[]" + }, + { + "internalType": "uint256[]", + "name": "quantities", + "type": "uint256[]" + }, + { + "internalType": "uint256", + "name": "deadline", + "type": "uint256" + } + ], + "internalType": "struct Response", + "name": "r", + "type": "tuple" + }, + { + "internalType": "bytes", + "name": "signature", + "type": "bytes" + } + ], + "name": "fill", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "gladiusReactor", + "outputs": [ + { + "internalType": "contract IGladiusReactor", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "owner", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "rfqSigner", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "t", + "type": "address" + } + ], + "name": "rmTagger", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "t", + "type": "address" + }, + { + "internalType": "bool", + "name": "enable", + "type": "bool" + } + ], + "name": "setExecutePermission", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "t", + "type": "address" + }, + { + "internalType": "bool", + "name": "enable", + "type": "bool" + } + ], + "name": "setFillPermission", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address payable", + "name": "gr", + "type": "address" + } + ], + "name": "setGladiusReactor", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner_", + "type": "address" + } + ], + "name": "setOwner", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "rs", + "type": "address" + } + ], + "name": "setRfqSigner", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "taggers", + "outputs": [ + { + "internalType": "bool", + "name": "execute", + "type": "bool" + }, + { + "internalType": "bool", + "name": "fill", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + } +] diff --git a/src/config.ts b/src/config.ts index f8b2a02fd..bcdf618a4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -34,6 +34,7 @@ type BaseConfig = { idleDaoAuthToken?: string; swaapV2AuthToken?: string; dexalotAuthToken?: string; + rubiconRfqAuthToken?: string; forceRpcFallbackDexs: string[]; }; @@ -287,6 +288,7 @@ const baseConfigs: { [network: number]: BaseConfig } = { privateHttpProvider: process.env.HTTP_PROVIDER_42161, hashFlowAuthToken: process.env.API_KEY_HASHFLOW_AUTH_TOKEN || '', swaapV2AuthToken: process.env.API_KEY_SWAAP_V2_AUTH_TOKEN || '', + rubiconRfqAuthToken: process.env.API_KEY_RUBICON_RFQ_AUTH_TOKEN || '', hashFlowDisabledMMs: process.env[`HASHFLOW_DISABLED_MMS_42161`]?.split(',') || [], augustusV6Address: '0x6a000f20005980200259b80c5102003040001068', @@ -331,6 +333,7 @@ const baseConfigs: { [network: number]: BaseConfig } = { }, hashFlowAuthToken: process.env.API_KEY_HASHFLOW_AUTH_TOKEN || '', swaapV2AuthToken: process.env.API_KEY_SWAAP_V2_AUTH_TOKEN || '', + rubiconRfqAuthToken: process.env.API_KEY_RUBICON_RFQ_AUTH_TOKEN || '', hashFlowDisabledMMs: process.env[`HASHFLOW_DISABLED_MMS_10`]?.split(',') || [], adapterAddresses: { @@ -396,6 +399,7 @@ const baseConfigs: { [network: number]: BaseConfig } = { privateHttpProvider: process.env.HTTP_PROVIDER_8453, dexalotAuthToken: process.env.API_KEY_DEXALOT_AUTH_TOKEN || '', hashFlowAuthToken: process.env.API_KEY_HASHFLOW_AUTH_TOKEN || '', + rubiconRfqAuthToken: process.env.API_KEY_RUBICON_RFQ_AUTH_TOKEN || '', swaapV2AuthToken: process.env.API_KEY_SWAAP_V2_AUTH_TOKEN || '', hashFlowDisabledMMs: [], augustusV6Address: '0x6a000f20005980200259b80c5102003040001068', @@ -462,6 +466,7 @@ export function generateConfig(network: number): Config { idleDaoAuthToken: baseConfig.idleDaoAuthToken, swaapV2AuthToken: baseConfig.swaapV2AuthToken, dexalotAuthToken: baseConfig.dexalotAuthToken, + rubiconRfqAuthToken: baseConfig.rubiconRfqAuthToken, hashFlowDisabledMMs: baseConfig.hashFlowDisabledMMs, forceRpcFallbackDexs: baseConfig.forceRpcFallbackDexs, apiKeyTheGraph: process.env.API_KEY_THE_GRAPH || '', diff --git a/src/dex/index.ts b/src/dex/index.ts index f1eea3fa9..e42b39bdf 100644 --- a/src/dex/index.ts +++ b/src/dex/index.ts @@ -91,6 +91,7 @@ import { LitePsm } from './lite-psm/lite-psm'; import { UsualBond } from './usual-bond/usual-bond'; import { StkGHO } from './stkgho/stkgho'; import { SkyConverter } from './sky-converter/sky-converter'; +import { RubiconRfq } from './rubicon-rfq/rubicon-rfq'; const LegacyDexes = [ CurveV2, @@ -176,7 +177,9 @@ const Dexes = [ LitePsm, UsualBond, StkGHO, + RubiconRfq, SkyConverter, + RubiconRfq, ]; export type LegacyDexConstructor = new (dexHelper: IDexHelper) => IDexTxBuilder< diff --git a/src/dex/rubicon-rfq/config.ts b/src/dex/rubicon-rfq/config.ts new file mode 100644 index 000000000..180cf00a0 --- /dev/null +++ b/src/dex/rubicon-rfq/config.ts @@ -0,0 +1,17 @@ +import { DexConfigMap } from '../../types'; +import { Network, SwapSide } from '../../constants'; +import { DexParams } from './types'; + +export const RubiconRfqConfig: DexConfigMap = { + RubiconRfq: { + [Network.ARBITRUM]: { + rfqAddress: '0x7988F58d6708AD5FA7597e0d19Be59Ed75027555', + }, + [Network.OPTIMISM]: { + rfqAddress: '0x0218D22B2f134C5b3000DBcB768f71693238c856', + }, + [Network.BASE]: { + rfqAddress: '0x6B49A0bD2744ACbDB2a4A901A3D5655323BD567E', + }, + }, +}; diff --git a/src/dex/rubicon-rfq/constants.ts b/src/dex/rubicon-rfq/constants.ts new file mode 100644 index 000000000..40b39533a --- /dev/null +++ b/src/dex/rubicon-rfq/constants.ts @@ -0,0 +1,42 @@ +import BigNumber from 'bignumber.js'; + +// TODO: update +export const RUBICON_RFQ_API_URL = 'https://rfq.rubicon.finance'; +export const RUBICON_RFQ_CLIENT_TAG = 'paraswap'; + +export const RUBICON_RFQ_MARKETS_ENDPOINT = '/markets'; +export const RUBICON_RFQ_LIQ_ENDPOINT = '/liquidity'; +export const RUBICON_RFQ_MARKET_MATCH_ENDPOINT = '/market-match'; +export const RUBICON_RFQ_MARKET_MATCH_TIMEOUT_MS = 3_228; + +export const RUBICON_RFQ_PARTIAL_FILL = 'partial'; +export const RUBICON_RFQ_FULL_FILL = 'full'; + +export const RUBICON_RFQ_MARKETS_CACHE_TTL_S = 3; +export const RUBICON_RFQ_MARKETS_POLL_INTERVAL_MS = 1000; + +export const RUBICON_RFQ_LIQ_CACHE_TTL_S = 8; +export const RUBICON_RFQ_LIQ_POLL_INTERVAL_MS = 5 * 1000; + +export const RUBICON_RFQ_GAS_COST = 550_000; +export const MARKET_SPLIT = '_'; + +export const RUBICON_RFQ_MIN_SLIPPAGE_FACTOR_THRESHOLD_FOR_RESTRICTION = + new BigNumber('0.001'); + +export const RESTRICT_UNKNOWN_TTL_MS = 60 * 60 * 1000; // 60 mins +export const RESTRICT_INTERNAL_SERVER_ERR = 60 * 20 * 1000; // 20 mins +export const RESTRICT_NO_SIGNATURE_TTL_MS = 60 * 10 * 1000; // 10 mins +export const RESTRICT_NO_MATCH_TTL_MS = 60 * 10 * 1000; // 10 mins +export const RESTRICT_SLIPPAGE_TTL_MS = 60 * 30 * 1000; // 10 mins +export const RESTRICT_PARTIAL_FLL_TTL_MS = 1 * 30 * 1000; // 1 min. + +export const UNKNOWN_ERROR_CODE = 'UNKNOWN'; +export const ERROR_CODE_TO_RESTRICT_TTL = { + [UNKNOWN_ERROR_CODE]: RESTRICT_UNKNOWN_TTL_MS, + ERR_NO_SIGNATURE: RESTRICT_NO_SIGNATURE_TTL_MS, + ERR_NO_MATCH: RESTRICT_NO_MATCH_TTL_MS, + ERR_PARTIAL_FILL: RESTRICT_PARTIAL_FLL_TTL_MS, + ERR_BAD_SERVER: RESTRICT_INTERNAL_SERVER_ERR, + SLIPPAGE: RESTRICT_SLIPPAGE_TTL_MS, +}; diff --git a/src/dex/rubicon-rfq/rate-fetcher.ts b/src/dex/rubicon-rfq/rate-fetcher.ts new file mode 100644 index 000000000..2a03bb067 --- /dev/null +++ b/src/dex/rubicon-rfq/rate-fetcher.ts @@ -0,0 +1,123 @@ +import { Network } from '../../constants'; +import { IDexHelper } from '../../dex-helper'; +import { Fetcher, SkippingRequest } from '../../lib/fetcher/fetcher'; +import { validateAndCast } from '../../lib/validators'; +import { Logger } from '../../types'; +import { + RubiconRfqMarketsResponse, + RubiconRfqLiquidityResponse, + RubiconRfqRateFetcherConfig, +} from './types'; +import { + marketsResponseValidator, + liquidityResponseValidator, +} from './validators'; + +export class RateFetcher { + private liquidityFetcher: Fetcher; + private rateFetcher: Fetcher; + + private marketsCacheKey: string; + private marketsCacheTTL: number; + + private liquidityCacheKey: string; + private liquidityCacheTTL: number; + + constructor( + private dexHelper: IDexHelper, + private dexKey: string, + private network: Network, + private logger: Logger, + config: RubiconRfqRateFetcherConfig, + ) { + this.marketsCacheKey = config.rateConfig.marketsCacheKey; + this.marketsCacheTTL = config.rateConfig.marketsCacheTTLSecs; + + this.liquidityCacheKey = config.rateConfig.liquidityCacheKey; + this.liquidityCacheTTL = config.rateConfig.liquidityCacheTTLSecs; + + this.liquidityFetcher = new Fetcher( + dexHelper.httpRequest, + { + info: { + requestOptions: config.rateConfig.marketsReqParams, + requestFunc: async options => { + const { liquidityReqParams } = config.rateConfig; + + options.url = liquidityReqParams.url; + options.params = liquidityReqParams.params; + + const liquidity = await dexHelper.httpRequest.request(options); + + return liquidity; + }, + caster: (data: unknown) => { + return validateAndCast( + data, + liquidityResponseValidator, + ); + }, + }, + handler: this.handleLiquidityResponse.bind(this), + }, + config.rateConfig.marketsIntervalMs, + logger, + ); + + this.rateFetcher = new Fetcher( + dexHelper.httpRequest, + { + info: { + requestOptions: config.rateConfig.marketsReqParams, + requestFunc: async options => { + const { marketsReqParams } = config.rateConfig; + + options.url = marketsReqParams.url; + options.params = marketsReqParams.params; + + const markets = await dexHelper.httpRequest.request(options); + + return markets; + }, + caster: (data: unknown) => { + return validateAndCast( + data, + marketsResponseValidator, + ); + }, + }, + handler: this.handleMarketsResponse.bind(this), + }, + config.rateConfig.marketsIntervalMs, + logger, + ); + } + + start() { + this.liquidityFetcher.startPolling(); + this.rateFetcher.startPolling(); + } + + stop() { + this.liquidityFetcher.stopPolling(); + this.rateFetcher.stopPolling(); + } + + private handleMarketsResponse(resp: RubiconRfqMarketsResponse): void { + const { markets } = resp; + this.dexHelper.cache.rawset( + this.marketsCacheKey, + JSON.stringify(markets), + this.marketsCacheTTL, + ); + } + + private handleLiquidityResponse(resp: RubiconRfqLiquidityResponse): void { + const { liquidityUsd } = resp; + this.dexHelper.cache.rawset( + this.liquidityCacheKey, + JSON.stringify(liquidityUsd), + this.liquidityCacheTTL, + ); + } +} diff --git a/src/dex/rubicon-rfq/rubicon-rfq-e2e.test.ts b/src/dex/rubicon-rfq/rubicon-rfq-e2e.test.ts new file mode 100644 index 000000000..547889f55 --- /dev/null +++ b/src/dex/rubicon-rfq/rubicon-rfq-e2e.test.ts @@ -0,0 +1,87 @@ +/* eslint-disable no-console */ +import dotenv from 'dotenv'; +dotenv.config(); + +import { StaticJsonRpcProvider } from '@ethersproject/providers'; +import { Tokens, Holders } from '../../../tests/constants-e2e'; +import { testE2E } from '../../../tests/utils-e2e'; +import { generateConfig } from '../../config'; +import { Network, ContractMethod, SwapSide } from '../../constants'; + +const sleepMs = 3000; + +function testForNetwork( + network: Network, + dexKey: string, + tokenASymbol: string, + tokenBSymbol: string, + tokenAAmount: string, + tokenBAmount: string, + nativeTokenAmount: string, +) { + const provider = new StaticJsonRpcProvider( + generateConfig(network).privateHttpProvider, + network, + ); + const tokens = Tokens[network]; + const holders = Holders[network]; + + const sideToContractMethods = new Map([ + [SwapSide.SELL, [ContractMethod.swapExactAmountIn]], + [SwapSide.BUY, [ContractMethod.swapExactAmountOut]], + ]); + + describe(`${network}`, () => { + sideToContractMethods.forEach((contractMethods, side) => + describe(`${side}`, () => { + contractMethods.forEach((contractMethod: ContractMethod) => { + describe(`${contractMethod}`, () => { + it(`${tokenASymbol} -> ${tokenBSymbol}`, async () => { + await testE2E( + tokens[tokenASymbol], + tokens[tokenBSymbol], + holders[tokenASymbol], + side === SwapSide.SELL ? tokenAAmount : tokenBAmount, + side, + dexKey, + contractMethod, + network, + provider, + undefined, + undefined, + undefined, + undefined, + sleepMs, + ); + }); + }); + }); + }), + ); + }); +} + +describe('RubiconRfq E2E', () => { + const dexKey = 'RubiconRfq'; + + describe('Arbitrum', () => { + const network = Network.ARBITRUM; + + const tokenASymbol: string = 'WETH'; + const tokenBSymbol: string = 'USDC'; + + const tokenAAmount: string = '700000000000000'; + const tokenBAmount: string = '2000000'; + const nativeTokenAmount = ''; + + testForNetwork( + network, + dexKey, + tokenASymbol, + tokenBSymbol, + tokenAAmount, + tokenBAmount, + nativeTokenAmount, + ); + }); +}); diff --git a/src/dex/rubicon-rfq/rubicon-rfq-integration.test.ts b/src/dex/rubicon-rfq/rubicon-rfq-integration.test.ts new file mode 100644 index 000000000..61fa4482b --- /dev/null +++ b/src/dex/rubicon-rfq/rubicon-rfq-integration.test.ts @@ -0,0 +1,161 @@ +/* eslint-disable no-console */ +import dotenv from 'dotenv'; +dotenv.config(); + +import { Interface, Result } from '@ethersproject/abi'; +import { DummyDexHelper } from '../../dex-helper/index'; +import { Network, SwapSide } from '../../constants'; +import { BI_POWS } from '../../bigint-constants'; +import { RubiconRfq } from './rubicon-rfq'; +import { + checkPoolPrices, + checkPoolsLiquidity, + checkConstantPoolPrices, + sleep, +} from '../../../tests/utils'; +import { Tokens } from '../../../tests/constants-e2e'; + +async function testPricingOnNetwork( + rubiconRfq: RubiconRfq, + network: Network, + dexKey: string, + blockNumber: number, + srcTokenSymbol: string, + destTokenSymbol: string, + side: SwapSide, + amounts: bigint[], +) { + const networkTokens = Tokens[network]; + + const pools = await rubiconRfq.getPoolIdentifiers( + networkTokens[srcTokenSymbol], + networkTokens[destTokenSymbol], + side, + blockNumber, + ); + console.log( + `${srcTokenSymbol} <> ${destTokenSymbol} Pool Identifiers: `, + pools, + ); + + expect(pools.length).toBeGreaterThan(0); + + const poolPrices = await rubiconRfq.getPricesVolume( + networkTokens[srcTokenSymbol], + networkTokens[destTokenSymbol], + amounts, + side, + blockNumber, + pools, + ); + console.log( + `${srcTokenSymbol} <> ${destTokenSymbol} Pool Prices: `, + poolPrices, + ); + + expect(poolPrices).not.toBeNull(); + if (rubiconRfq.hasConstantPriceLargeAmounts) { + checkConstantPoolPrices(poolPrices!, amounts, dexKey); + } else { + checkPoolPrices(poolPrices!, amounts, side, dexKey); + } +} + +describe('RubiconRfq', function () { + const dexKey = 'RubiconRfq'; + let blockNumber: number; + let rubiconRfq: RubiconRfq; + + describe('Arbitrum', () => { + const network = Network.ARBITRUM; + const dexHelper = new DummyDexHelper(network); + + const tokens = Tokens[network]; + + const srcTokenSymbol = 'WETH'; + const destTokenSymbol = 'USDC'; + + // Test with small amounts. + const amountsForSell = [ + 0n, + 1n * BI_POWS[tokens[srcTokenSymbol].decimals - 2], + 2n * BI_POWS[tokens[srcTokenSymbol].decimals - 2], + 3n * BI_POWS[tokens[srcTokenSymbol].decimals - 2], + 4n * BI_POWS[tokens[srcTokenSymbol].decimals - 2], + 5n * BI_POWS[tokens[srcTokenSymbol].decimals - 2], + 6n * BI_POWS[tokens[srcTokenSymbol].decimals - 2], + 7n * BI_POWS[tokens[srcTokenSymbol].decimals - 2], + 8n * BI_POWS[tokens[srcTokenSymbol].decimals - 2], + 9n * BI_POWS[tokens[srcTokenSymbol].decimals - 2], + 10n * BI_POWS[tokens[srcTokenSymbol].decimals - 2], + ]; + + const amountsForBuy = [ + 0n, + 1n * BI_POWS[tokens[destTokenSymbol].decimals], + 2n * BI_POWS[tokens[destTokenSymbol].decimals], + 3n * BI_POWS[tokens[destTokenSymbol].decimals], + 4n * BI_POWS[tokens[destTokenSymbol].decimals], + 5n * BI_POWS[tokens[destTokenSymbol].decimals], + 6n * BI_POWS[tokens[destTokenSymbol].decimals], + 7n * BI_POWS[tokens[destTokenSymbol].decimals], + 8n * BI_POWS[tokens[destTokenSymbol].decimals], + 9n * BI_POWS[tokens[destTokenSymbol].decimals], + 10n * BI_POWS[tokens[destTokenSymbol].decimals], + ]; + + beforeAll(async () => { + blockNumber = await dexHelper.web3Provider.eth.getBlockNumber(); + rubiconRfq = new RubiconRfq(network, dexKey, dexHelper); + if (rubiconRfq.initializePricing) { + await rubiconRfq.initializePricing(blockNumber); + await sleep(5000); + } + }); + + it('getPoolIdentifiers and getPricesVolume SELL', async function () { + await testPricingOnNetwork( + rubiconRfq, + network, + dexKey, + blockNumber, + srcTokenSymbol, + destTokenSymbol, + SwapSide.SELL, + amountsForSell, + ); + }); + + it('getPoolIdentifiers and getPricesVolume BUY', async function () { + await testPricingOnNetwork( + rubiconRfq, + network, + dexKey, + blockNumber, + srcTokenSymbol, + destTokenSymbol, + SwapSide.BUY, + amountsForBuy, + ); + }); + + it('getTopPoolsForToken', async function () { + // We have to check without calling initializePricing, because + // pool-tracker is not calling that function + const newRubiconRfq = new RubiconRfq(network, dexKey, dexHelper); + const poolLiquidity = await newRubiconRfq.getTopPoolsForToken( + tokens[srcTokenSymbol].address, + 10, + ); + console.log(`${srcTokenSymbol} Top Pools:`, poolLiquidity); + + if (!newRubiconRfq.hasConstantPriceLargeAmounts) { + checkPoolsLiquidity( + poolLiquidity, + Tokens[network][srcTokenSymbol].address, + dexKey, + ); + } + }); + }); +}); diff --git a/src/dex/rubicon-rfq/rubicon-rfq.ts b/src/dex/rubicon-rfq/rubicon-rfq.ts new file mode 100644 index 000000000..4e1531961 --- /dev/null +++ b/src/dex/rubicon-rfq/rubicon-rfq.ts @@ -0,0 +1,671 @@ +import { AsyncOrSync } from 'ts-essentials'; +import { + Token, + Logger, + Address, + PoolPrices, + PoolLiquidity, + ExchangeTxInfo, + ExchangePrices, + NumberAsString, + DexExchangeParam, + OptimalSwapExchange, + SimpleExchangeParam, + AdapterExchangeParam, + PreprocessTransactionOptions, +} from '../../types'; +import { + SlippageCheckError, + TooStrictSlippageCheckError, +} from '../generic-rfq/types'; +import { SwapSide, Network, CACHE_PREFIX } from '../../constants'; +import { assert } from 'ts-essentials'; +import { + MARKET_SPLIT, + RUBICON_RFQ_API_URL, + RUBICON_RFQ_GAS_COST, + RUBICON_RFQ_CLIENT_TAG, + RUBICON_RFQ_PARTIAL_FILL, + RUBICON_RFQ_LIQ_ENDPOINT, + RUBICON_RFQ_LIQ_CACHE_TTL_S, + RUBICON_RFQ_MARKETS_ENDPOINT, + RUBICON_RFQ_MARKETS_CACHE_TTL_S, + RUBICON_RFQ_LIQ_POLL_INTERVAL_MS, + RUBICON_RFQ_MARKET_MATCH_ENDPOINT, + RUBICON_RFQ_MARKET_MATCH_TIMEOUT_MS, + RUBICON_RFQ_MARKETS_POLL_INTERVAL_MS, + RUBICON_RFQ_MIN_SLIPPAGE_FACTOR_THRESHOLD_FOR_RESTRICTION, +} from './constants'; +import * as CALLDATA_GAS_COST from '../../calldata-gas-cost'; +import { BI_MAX_UINT256 } from '../../bigint-constants'; +import { BN_0, BN_1, getBigNumberPow } from '../../bignumber-constants'; +import { getDexKeysWithNetwork } from '../../utils'; +import { IDex } from '../../dex/idex'; +import BigNumber from 'bignumber.js'; +import rubiconRfqABI from '../../abi/rubicon-rfq/rubicon-rfq.abi.json'; +import { Interface } from 'ethers/lib/utils'; +import { IDexHelper } from '../../dex-helper/idex-helper'; +import { + Quote, + RfqError, + PriceLevel, + RubiconRfqData, + RubiconRfqMatchResponse, + RubiconRfqMarketsResponse, + RubiconRfqLiquidityResponse, +} from './types'; +import { SimpleExchange } from '../simple-exchange'; +import { RubiconRfqConfig } from './config'; +import { RateFetcher } from './rate-fetcher'; +import { SpecialDex } from '../../executor/types'; + +export class RubiconRfq extends SimpleExchange implements IDex { + readonly isStatePollingDex = true; + readonly hasConstantPriceLargeAmounts = false; + readonly needWrapNative = true; + readonly needsSequentialPreprocessing = false; + readonly isFeeOnTransferSupported = false; + + private rateFetcher: RateFetcher; + private rubiconRfqAuthToken: string; + + private marketsCacheKey: string; + private liquidityCacheKey: string; + + public static dexKeysWithNetwork: { key: string; networks: Network[] }[] = + getDexKeysWithNetwork(RubiconRfqConfig); + + logger: Logger; + + constructor( + readonly network: Network, + readonly dexKey: string, + readonly dexHelper: IDexHelper, + readonly rfqAddress: string = RubiconRfqConfig['RubiconRfq'][network] + .rfqAddress, + protected rubiconRfqInterface = new Interface(rubiconRfqABI), + ) { + super(dexHelper, dexKey); + this.logger = dexHelper.getLogger(dexKey); + + const authToken = dexHelper.config.data.rubiconRfqAuthToken; + assert( + authToken !== undefined, + 'RubiconRFQ auth token is not specified with env variable', + ); + + this.rubiconRfqAuthToken = authToken; + + this.marketsCacheKey = `${CACHE_PREFIX}_${this.dexHelper.config.data.network}_${this.dexKey}_markets`; + this.liquidityCacheKey = `${CACHE_PREFIX}_${this.dexHelper.config.data.network}_${this.dexKey}_liquidity`; + + this.rateFetcher = new RateFetcher( + this.dexHelper, + this.dexKey, + this.network, + this.logger, + { + rateConfig: { + marketsIntervalMs: RUBICON_RFQ_MARKETS_POLL_INTERVAL_MS, + liquidityIntervalMs: RUBICON_RFQ_LIQ_POLL_INTERVAL_MS, + marketsReqParams: { + url: `${RUBICON_RFQ_API_URL}${RUBICON_RFQ_MARKETS_ENDPOINT}`, + params: { + tag: RUBICON_RFQ_CLIENT_TAG, + chainId: this.network, + }, + headers: { 'x-api-key': this.rubiconRfqAuthToken }, + }, + liquidityReqParams: { + url: `${RUBICON_RFQ_API_URL}${RUBICON_RFQ_LIQ_ENDPOINT}`, + params: { + tag: RUBICON_RFQ_CLIENT_TAG, + chainId: this.network, + }, + headers: { 'x-api-key': this.rubiconRfqAuthToken }, + }, + marketsCacheKey: this.marketsCacheKey, + marketsCacheTTLSecs: RUBICON_RFQ_MARKETS_CACHE_TTL_S, + liquidityCacheKey: this.liquidityCacheKey, + liquidityCacheTTLSecs: RUBICON_RFQ_LIQ_CACHE_TTL_S, + }, + }, + ); + } + + async initializePricing(blockNumber: number): Promise { + if (!this.dexHelper.config.isSlave) { + this.rateFetcher.start(); + } + + return; + } + + getAdapters(side: SwapSide): { name: string; index: number }[] | null { + return null; + } + + getPoolIdentifier(marketId: string) { + return `${this.dexKey}_${marketId.toLowerCase()}`; + } + + pairToMarketId(srcToken: Token, destToken: Token) { + return (srcToken.address + MARKET_SPLIT + destToken.address).toLowerCase(); + } + + marketIdToPair(marketId: string) { + return marketId.split(MARKET_SPLIT); + } + + getTokenFromAddress?(address: Address): Token { + return { address, decimals: 0 }; + } + + extractQuoteToken(marketId: string, tokenAddress: Address): Token { + const pair = this.marketIdToPair(marketId); + // We don't store decimals and symbols, so idk if it's + // acceptable to return tokens without those fields. + return pair[0] === tokenAddress + ? { address: pair[1], decimals: 0, symbol: '' } + : { address: pair[0], decimals: 0, symbol: '' }; + } + + isOppositeMarket( + srcToken: Token, + destToken: Token, + marketId: string, + ): boolean { + if (this.pairToMarketId(destToken, srcToken) === marketId) { + return true; + } + return false; + } + + async getPoolIdentifiers( + srcToken: Token, + destToken: Token, + side: SwapSide, + blockNumber: number, + ): Promise { + const markets = (await this.getCachedMarkets()) || {}; + + // It may return (src/dest)-priced market + // and (dest/src)-priced market. + return Object.keys(markets) + .filter( + marketId => + marketId === this.pairToMarketId(srcToken, destToken) || + marketId === this.pairToMarketId(destToken, srcToken), + ) + .map(marketId => this.getPoolIdentifier(marketId)); + } + + async getPricesVolume( + srcToken: Token, + destToken: Token, + amounts: bigint[], + side: SwapSide, + blockNumber: number, + limitPools?: string[], + ): Promise> { + try { + const pools = + limitPools ?? + (await this.getPoolIdentifiers(srcToken, destToken, side, blockNumber)); + + const marketIdsToUse = pools.map(p => p.split(`${this.dexKey}_`).pop()); + const markets = (await this.getCachedMarkets()) || {}; + + const prices = marketIdsToUse.map(id => { + if (!id) return null; + + const market = markets[id]; + if (!market) return null; + + const levelsMap: string[][] = + side === SwapSide.SELL ? market.bids : market.asks; + + const div0 = getBigNumberPow( + side === SwapSide.SELL ? srcToken.decimals : destToken.decimals, + ); + const div1 = getBigNumberPow( + side === SwapSide.SELL ? destToken.decimals : srcToken.decimals, + ); + + const amountsRaw = amounts.map(a => + new BigNumber(a.toString()).dividedBy(div0), + ); + + const isOpposite = + side === SwapSide.SELL + ? this.isOppositeMarket(srcToken, destToken, id) + : this.isOppositeMarket(destToken, srcToken, id); + + // Inverts market's prices for an opposite market id. + const levels: PriceLevel[] = levelsMap.map(([price, quantity]) => ({ + price: !isOpposite + ? new BigNumber(price) + : BN_1.dividedBy(new BigNumber(price)), + quantity: !isOpposite + ? new BigNumber(quantity) + : new BigNumber(quantity).multipliedBy(new BigNumber(price)), + })); + + if (levels.length === 0) return null; + + const prices = this.match(amountsRaw, levels, div1, false); + const unit = this.match([BN_1], levels, div1, true)[0]; + + if (!prices) return null; + + return { + gasCost: RUBICON_RFQ_GAS_COST, + exchange: this.dexKey, + data: {}, + prices: prices, + unit: unit, + poolIdentifier: this.getPoolIdentifier(id), + poolAddresses: [this.rfqAddress], + } as PoolPrices; + }); + return prices.filter((p): p is PoolPrices => !!p); + } catch (e: unknown) { + this.logger.error( + `Error_getPricesVolume ${srcToken.symbol || srcToken.address}, ${ + destToken.symbol || destToken.address + }, ${side}:`, + e, + ); + return null; + } + } + + match( + amounts: BigNumber[], + levels: PriceLevel[], + div: BigNumber, + unit: boolean, + ): bigint[] { + const outputs = new Array(amounts.length).fill(BN_0); + + // For a price computation. + if (unit) { + levels.unshift({ price: levels[0].price, quantity: BN_1 }); + } + + // Calculate fill for each amount. + for (let i = 0; i < amounts.length; i++) { + let amt = amounts[i]; + + for (let j = 0; j < levels.length; j++) { + let levelQty: BigNumber = levels[j].quantity; + const levelPrice: BigNumber = levels[j].price; + + const fill = BigNumber.minimum(amt, levelQty); + + outputs[i] = outputs[i].plus(fill.multipliedBy(levelPrice)); + + amt = amt.minus(fill); + levelQty = levelQty.minus(fill); + + if (amt.isZero() || !levelQty.isZero()) break; + } + // Amount wasn't filled fully. + //if (!amt.isZero()) outputs[i] = BN_0; + } + + return outputs.map(o => BigInt(o.multipliedBy(div).toFixed(0))); + } + + getCalldataGasCost( + poolPrices: PoolPrices, + ): number | number[] { + // Size of dynamic data to use will be known + // only after a request to '/*match' endpoints. + // Thus this approximation assumes that the quote + // will be matched against 1 order :/ + return ( + CALLDATA_GAS_COST.DEX_OVERHEAD + + CALLDATA_GAS_COST.OFFSET_SMALL * 7 + + // All large offsets to tails of dynamic types + nonce. + CALLDATA_GAS_COST.OFFSET_LARGE * 15 + + // addresses: sellToken, buyToken ('Quote'), + // input.token, reactor, swapper, + // output.token, out.recipient + CALLDATA_GAS_COST.ADDRESS * 7 + + CALLDATA_GAS_COST.TIMESTAMP * 6 + + // uint256: q.sellAmt, q.buyAmt, fillThreshold, + // input.start/end, output.start/end + CALLDATA_GAS_COST.AMOUNT * 7 + + // signatures: rfq signature, order's signature. + (CALLDATA_GAS_COST.FULL_WORD * 2 + CALLDATA_GAS_COST.OFFSET_SMALL) * 2 + ); + } + + async preProcessTransaction( + optimalSwapExchange: OptimalSwapExchange, + srcToken: Token, + destToken: Token, + side: SwapSide, + options: PreprocessTransactionOptions, + ): Promise<[OptimalSwapExchange, ExchangeTxInfo]> { + try { + const isSell = side === SwapSide.SELL; + const isBuy = side === SwapSide.BUY; + + const q: Quote = { + tag: RUBICON_RFQ_CLIENT_TAG, + chainId: this.network, + sellToken: srcToken.address, + buyToken: destToken.address, + // Prepare either market buy or market sell request. + sellAmt: isSell ? optimalSwapExchange.srcAmount.toString() : undefined, + buyAmt: isBuy ? optimalSwapExchange.destAmount.toString() : undefined, + }; + + const queryParams = Object.keys(q) + .map(k => { + const kk = k as keyof Quote; + const v = q[kk]; + + return `${encodeURIComponent(String(kk))}=${encodeURIComponent( + String(v ? v : ''), + )}`; + }) + .join('&'); + + const url = new URL( + `${RUBICON_RFQ_API_URL}${RUBICON_RFQ_MARKET_MATCH_ENDPOINT}?${queryParams}`, + ).toString(); + + const match: RubiconRfqMatchResponse = + await this.dexHelper.httpRequest.get( + url, + RUBICON_RFQ_MARKET_MATCH_TIMEOUT_MS, + { 'x-api-Key': this.rubiconRfqAuthToken }, + ); + + if (!match) { + const message = `${this.dexKey}-${ + this.network + }: Failed to get a match for ${this.pairToMarketId( + srcToken, + destToken, + )}: ${JSON.stringify(q)}`; + this.logger.warn(message); + throw new RfqError(message); + } + + if (match.status !== 'success') { + const message = `${this.dexKey}-${ + this.network + }: Failed to get a match for ${this.pairToMarketId( + srcToken, + destToken, + )}: ${JSON.stringify(q)}`; + this.logger.warn(message); + throw new RfqError(message, 'ERR_BAD_SERVER'); + } + + if (!match.rfqsig) { + const message = `${this.dexKey}-${ + this.network + }: Failed to fetch RFQ for ${this.pairToMarketId( + srcToken, + destToken, + )}. Missing signature`; + this.logger.warn(message); + throw new RfqError(message, 'ERR_NO_SIGNATURE'); + } + + if (!match.response) { + const message = `${this.dexKey}-${ + this.network + }: Failed to fetch RFQ for ${this.pairToMarketId( + srcToken, + destToken, + )}. Missing match data`; + this.logger.warn(message); + throw new RfqError(message, 'ERR_NO_MATCH'); + } + + // Idk if that's an error, but I assume + // only full fills are needed. + if (match.fillType === RUBICON_RFQ_PARTIAL_FILL) { + const message = `${this.dexKey}-${ + this.network + }: Failed to fetch RFQ for ${this.pairToMarketId( + srcToken, + destToken, + )}. Order can be filled only partially`; + this.logger.warn(message); + throw new RfqError(message, 'ERR_PARTIAL_FILL'); + } + + assert( + match.pair.sellToken === destToken.address, + `Match sellToken=${match.pair.sellToken} is different from destToken=${destToken.address}`, + ); + assert( + match.pair.buyToken === srcToken.address, + `QuoteData buyToken=${match.pair.buyToken} is different from srcToken=${srcToken.address}`, + ); + + const deadlineBigInt = BigInt(match.response.deadline); + const deadline = deadlineBigInt > 0 ? deadlineBigInt : BI_MAX_UINT256; + + const matchSellAmt = BigInt(match.amounts.sellAmt); + const matchBuyAmt = BigInt(match.amounts.buyAmt); + + const srcAmount = BigInt(optimalSwapExchange.srcAmount); + const destAmount = BigInt(optimalSwapExchange.destAmount); + + const slippageFactor = options.slippageFactor; + + let isFailOnSlippage = false; + let slippageErrorMessage = ''; + + if (isSell) { + if ( + matchSellAmt < + BigInt( + new BigNumber(destAmount.toString()) + .times(slippageFactor) + .toFixed(0), + ) + ) { + isFailOnSlippage = true; + const message = `${this.dexKey}-${this.network}: too much slippage on quote ${side} matchSellAmt ${matchSellAmt} / destAmount ${destAmount} < ${slippageFactor}`; + slippageErrorMessage = message; + this.logger.warn(message); + } + } + + if (isBuy) { + if ( + matchBuyAmt > + BigInt( + slippageFactor + .times(optimalSwapExchange.srcAmount.toString()) + .toFixed(0), + ) + ) { + isFailOnSlippage = true; + + const message = `${this.dexKey}-${this.network}: too much slippage on quote ${side} matchBuyAmt ${matchBuyAmt} > srcAmount ${srcAmount}`; + slippageErrorMessage = message; + this.logger.warn(message); + } + } + + let isTooStrictSlippage = false; + if ( + isFailOnSlippage && + isSell && + new BigNumber(1) + .minus(slippageFactor) + .lt(RUBICON_RFQ_MIN_SLIPPAGE_FACTOR_THRESHOLD_FOR_RESTRICTION) + ) { + isTooStrictSlippage = true; + } else if ( + isFailOnSlippage && + isBuy && + slippageFactor + .minus(1) + .lt(RUBICON_RFQ_MIN_SLIPPAGE_FACTOR_THRESHOLD_FOR_RESTRICTION) + ) { + isTooStrictSlippage = true; + } + + if (isFailOnSlippage && isTooStrictSlippage) { + throw new TooStrictSlippageCheckError(slippageErrorMessage); + } else if (isFailOnSlippage && !isTooStrictSlippage) { + throw new SlippageCheckError(slippageErrorMessage); + } + + return [ + { + ...optimalSwapExchange, + data: { + q: { + sellToken: srcToken.address, + buyToken: destToken.address, + sellAmt: srcAmount, + buyAmt: destAmount > matchSellAmt ? matchSellAmt : destAmount, + }, + r: { + orders: match.response.orders, + quantities: match.response.quantities.map(q => BigInt(q)), + deadline: Number(deadline), + }, + signature: match.rfqsig, + }, + }, + { deadline: deadline }, + ]; + } catch (e) { + if (e instanceof TooStrictSlippageCheckError) { + this.logger.warn( + `${this.dexKey}-${this.network}: Failed to build transaction on side ${side} with too strict slippage. Skipping restriction`, + ); + } else { + this.logger.warn( + `${this.dexKey}-${this.network} unknown preprocess transaction error: ${e}`, + ); + } + + throw e; + } + } + + getDexParam( + srcToken: Address, + destToken: Address, + srcAmount: NumberAsString, + destAmount: NumberAsString, + recipient: Address, + data: RubiconRfqData, + side: SwapSide, + ): DexExchangeParam { + const { q, r, signature } = data; + + assert(q !== undefined, `${this.dexKey}-${this.network}: q undefined`); + assert(r !== undefined, `${this.dexKey}-${this.network}: r undefined`); + assert( + signature !== undefined, + `${this.dexKey}-${this.network}: signature undefined`, + ); + + const exchangeData = this.rubiconRfqInterface.encodeFunctionData('fill', [ + q, + r, + signature, + ]); + + return { + needWrapNative: this.needWrapNative, + dexFuncHasRecipient: false, + exchangeData, + targetExchange: this.rfqAddress, + returnAmountPos: undefined, + }; + } + + getAdapterParam( + srcToken: string, + destToken: string, + srcAmount: string, + destAmount: string, + data: RubiconRfqData, + side: SwapSide, + ): AdapterExchangeParam { + return { + targetExchange: this.rfqAddress, + payload: '', + networkFee: '0', + }; + } + + async getTopPoolsForToken( + tokenAddress: Address, + limit: number, + ): Promise { + const liquidity = (await this.getCachedLiquidity()) || {}; + const token = tokenAddress.toLowerCase(); + + const marketIds = Object.keys(liquidity).filter(id => + this.marketIdToPair(id).includes(token), + ); + if (marketIds.length === 0) { + return []; + } + + const pools = marketIds.map( + id => + ({ + exchange: this.dexKey, + address: this.rfqAddress, + connectorTokens: [this.extractQuoteToken(id, token)], + liquidityUSD: +liquidity[id], + } as PoolLiquidity), + ); + + return pools + .sort((a, b) => b.liquidityUSD - a.liquidityUSD) + .slice(0, limit); + } + + async getCachedMarkets(): Promise< + RubiconRfqMarketsResponse['markets'] | null + > { + const cachedMarkets = await this.dexHelper.cache.rawget( + this.marketsCacheKey, + ); + + if (cachedMarkets) { + return JSON.parse(cachedMarkets) as RubiconRfqMarketsResponse['markets']; + } + + return null; + } + + async getCachedLiquidity(): Promise< + RubiconRfqLiquidityResponse['liquidityUsd'] | null + > { + const cachedLiq = await this.dexHelper.cache.rawget(this.liquidityCacheKey); + + if (cachedLiq) { + return JSON.parse( + cachedLiq, + ) as RubiconRfqLiquidityResponse['liquidityUsd']; + } + + return null; + } + + releaseResources(): void { + if (this.rateFetcher) { + this.rateFetcher.stop(); + } + } +} diff --git a/src/dex/rubicon-rfq/types.ts b/src/dex/rubicon-rfq/types.ts new file mode 100644 index 000000000..c4446a355 --- /dev/null +++ b/src/dex/rubicon-rfq/types.ts @@ -0,0 +1,129 @@ +import { ERROR_CODE_TO_RESTRICT_TTL, UNKNOWN_ERROR_CODE } from './constants'; +import { RequestHeaders } from '../../dex-helper'; +import { Address } from '../../types'; +import { Network } from '../../constants'; +import BigNumber from 'bignumber.js'; + +export type ErrorCode = keyof typeof ERROR_CODE_TO_RESTRICT_TTL; + +export class SlippageCheckError extends Error { + code: ErrorCode = 'SLIPPAGE'; +} + +export class RfqError extends Error { + code: ErrorCode; + constructor(message: string, code: ErrorCode = UNKNOWN_ERROR_CODE) { + super(message); + this.code = code; + } +} + +// Arguments to RFQ's 'fill()' function. +export type RubiconRfqData = { + q: { + sellToken: string; + buyToken: string; + sellAmt: bigint; + buyAmt: bigint; + }; + r: { + orders: SignedOrder[]; + quantities: bigint[]; + deadline: number; + }; + signature: string; +}; + +export type SignedOrder = { + order: string; + sig: string; +}; + +export type DexParams = { + rfqAddress: Address; +}; + +export interface PriceLevel { + price: BigNumber; + quantity: BigNumber; +} + +export interface Market { + // [[price_0, quantity_0], ..., [price_n, quantity_n]] + asks: string[][]; + bids: string[][]; +} + +export interface RubiconRfqMarketsResponse { + status: string; + chainId: string; + // marketId => asks/bids + markets: Record; +} + +export interface RubiconRfqLiquidityResponse { + status: string; + chainId: string; + liquidityUsd: Record; +} + +export interface MatchResponse { + orders: SignedOrder[]; + quantities: string[]; + deadline: string; +} + +export interface Pair { + sellToken: string; + buyToken: string; +} + +export interface Amounts { + sellAmt: string; + buyAmt: string; +} + +export interface RubiconRfqMatchResponse { + status: string; + chainId: string; + response: MatchResponse; + pair: Pair; + amounts: Amounts; + fillType: string; + rfqsig: string; +} + +export type Quote = { + tag: string; + chainId: Network; + + // Optional for '/markets' request. + sellToken?: Address; + buyToken?: Address; + deadline?: number; + + // Used only to get a match. + sellAmt?: string; + buyAmt?: string; +}; + +export type RubiconRfqRateFetcherConfig = { + rateConfig: { + marketsReqParams: { + url: string; + headers: RequestHeaders; + params: Quote; + }; + liquidityReqParams: { + url: string; + headers: RequestHeaders; + params: Quote; + }; + marketsIntervalMs: number; + liquidityIntervalMs: number; + marketsCacheKey: string; + marketsCacheTTLSecs: number; + liquidityCacheKey: string; + liquidityCacheTTLSecs: number; + }; +}; diff --git a/src/dex/rubicon-rfq/validators.ts b/src/dex/rubicon-rfq/validators.ts new file mode 100644 index 000000000..19e4f92f2 --- /dev/null +++ b/src/dex/rubicon-rfq/validators.ts @@ -0,0 +1,25 @@ +import joi from 'joi'; + +const numberSchema = joi.string().pattern(/^\d+(\.\d+)?$/); + +const pqSchema = joi + .array() + .items(joi.string().required(), joi.string().required()) + .length(2); + +const marketSchema = joi.object({ + asks: joi.array().items(pqSchema).required(), + bids: joi.array().items(pqSchema).required(), +}); + +export const marketsResponseValidator = joi.object({ + status: joi.string().valid('success', 'fail').required(), + chainId: joi.string().pattern(/^\d+$/).required(), + markets: joi.object().pattern(joi.string().min(1), marketSchema), +}); + +export const liquidityResponseValidator = joi.object({ + status: joi.string().valid('success', 'fail').required(), + chainId: joi.string().pattern(/^\d+$/).required(), + liquidityUsd: joi.object().pattern(joi.string().min(1), numberSchema), +}); diff --git a/src/types.ts b/src/types.ts index ac3ffb0ea..686d264d7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -305,6 +305,7 @@ export type Config = { swaapV2AuthToken?: string; dexalotAuthToken?: string; idleDaoAuthToken?: string; + rubiconRfqAuthToken?: string; forceRpcFallbackDexs: string[]; apiKeyTheGraph: string; };