diff --git a/.env.example b/.env.example index 5fda3a3bf..0bdc347cb 100644 --- a/.env.example +++ b/.env.example @@ -11,4 +11,6 @@ API_KEY_BEBOP_AUTH_NAME=paraswap API_KEY_BEBOP_AUTH_TOKEN=test API_KEY_HASHFLOW_AUTH_TOKEN=test API_KEY_SWAAP_V2_AUTH_TOKEN=test -API_KEY_DEXALOT_AUTH_TOKEN=test \ No newline at end of file +API_KEY_DEXALOT_AUTH_TOKEN=test +API_KEY_RENEGADE_AUTH_API_SECRET=test +API_KEY_RENEGADE_AUTH_API_KEY=test \ No newline at end of file diff --git a/src/config.ts b/src/config.ts index 83de0d16b..fa8e40900 100644 --- a/src/config.ts +++ b/src/config.ts @@ -36,6 +36,8 @@ type BaseConfig = { bebopAuthName?: string; bebopAuthToken?: string; nativeApiKey?: string; + renegadeAuthApiKey?: string; + renegadeAuthApiSecret?: string; forceRpcFallbackDexs: string[]; }; @@ -299,6 +301,8 @@ const baseConfigs: { [network: number]: BaseConfig } = { hashFlowAuthToken: process.env.API_KEY_HASHFLOW_AUTH_TOKEN || '', swaapV2AuthToken: process.env.API_KEY_SWAAP_V2_AUTH_TOKEN || '', nativeApiKey: process.env.API_KEY_NATIVE || '', + renegadeAuthApiKey: process.env.API_KEY_RENEGADE_AUTH_API_KEY || '', + renegadeAuthApiSecret: process.env.API_KEY_RENEGADE_AUTH_API_SECRET || '', hashFlowDisabledMMs: process.env[`HASHFLOW_DISABLED_MMS_42161`]?.split(',') || [], augustusV6Address: '0x6a000f20005980200259b80c5102003040001068', @@ -411,6 +415,8 @@ const baseConfigs: { [network: number]: BaseConfig } = { hashFlowAuthToken: process.env.API_KEY_HASHFLOW_AUTH_TOKEN || '', swaapV2AuthToken: process.env.API_KEY_SWAAP_V2_AUTH_TOKEN || '', nativeApiKey: process.env.API_KEY_NATIVE || '', + renegadeAuthApiKey: process.env.API_KEY_RENEGADE_AUTH_API_KEY || '', + renegadeAuthApiSecret: process.env.API_KEY_RENEGADE_AUTH_API_SECRET || '', hashFlowDisabledMMs: [], augustusV6Address: '0x6a000f20005980200259b80c5102003040001068', executorsAddresses: { @@ -565,6 +571,8 @@ export function generateConfig(network: number): Config { bebopAuthName: baseConfig.bebopAuthName, bebopAuthToken: baseConfig.bebopAuthToken, nativeApiKey: baseConfig.nativeApiKey, + renegadeAuthApiKey: baseConfig.renegadeAuthApiKey, + renegadeAuthApiSecret: baseConfig.renegadeAuthApiSecret, 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 b41d8d34a..5fbd70b3e 100644 --- a/src/dex/index.ts +++ b/src/dex/index.ts @@ -97,6 +97,7 @@ import { UsdcTransmuter } from './usdc-transmuter/usdc-transmuter'; import { Blackhole } from './solidly/forks-override/blackhole'; import { BlackholeCL } from './algebra-integral/forks/blackhole-cl'; import { Cap } from './cap/cap'; +import { Renegade } from './renegade/renegade'; const LegacyDexes = [ CurveV2, @@ -187,6 +188,7 @@ const Dexes = [ Blackhole, BlackholeCL, Cap, + Renegade, ]; export type LegacyDexConstructor = new (dexHelper: IDexHelper) => IDexTxBuilder< diff --git a/src/dex/renegade/api/auth.ts b/src/dex/renegade/api/auth.ts new file mode 100644 index 000000000..2a62bd2c5 --- /dev/null +++ b/src/dex/renegade/api/auth.ts @@ -0,0 +1,101 @@ +// Authentication helper for Renegade DEX integration. +// Implements HMAC-SHA256 authentication signing requests over path, sorted x-renegade-* headers, and body. + +import { hmac } from '@noble/hashes/hmac'; +import { sha256 } from '@noble/hashes/sha256'; +import { Buffer } from 'buffer'; +import { + RENEGADE_HEADER_PREFIX, + RENEGADE_API_KEY_HEADER, + RENEGADE_AUTH_HEADER, + RENEGADE_AUTH_EXPIRATION_HEADER, + REQUEST_SIGNATURE_DURATION_MS, +} from '../constants'; + +// Generate authentication headers for Renegade API requests. +// Adds API key header, generates HMAC-SHA256 signature over path + headers + body, and sets expiration timestamp. +export function generateRenegadeAuthHeaders( + path: string, + body: string, + existingHeaders: Record, + apiKey: string, + apiSecret: string, +): Record { + // Clone existing headers to avoid mutation + const signedHeaders: Record = { ...existingHeaders }; + + // Add timestamp and expiry + const now = Date.now(); + const expiry = now + REQUEST_SIGNATURE_DURATION_MS; + signedHeaders[RENEGADE_AUTH_EXPIRATION_HEADER] = expiry.toString(); + + // Add API key + signedHeaders[RENEGADE_API_KEY_HEADER] = apiKey; + + // Decode API secret from base64 to Uint8Array + const apiSecretBytes = decodeBase64(apiSecret); + + // Compute the MAC signature using the headers with expiry + const signature = computeHmacSignature( + path, + signedHeaders, + body, + apiSecretBytes, + ); + signedHeaders[RENEGADE_AUTH_HEADER] = signature; + + // Return new headers object with both auth headers + return signedHeaders; +} + +// Compute the HMAC-SHA256 signature for a Renegade API request. +// Returns base64-encoded HMAC signature without padding. +function computeHmacSignature( + path: string, + headers: Record, + body: string, + apiSecret: Uint8Array, +): string { + // Filter and sort x-renegade-* headers (excluding auth header itself) + const candidateHeaderEntries = Object.entries(headers); + const renegadeHeaderEntries = candidateHeaderEntries + .filter(([key]) => key.toLowerCase().startsWith(RENEGADE_HEADER_PREFIX)) + .filter( + ([key]) => key.toLowerCase() !== RENEGADE_AUTH_HEADER.toLowerCase(), + ); + + // Canonicalize header order (lexicographic by header name, case-insensitive) + const canonicalHeaderEntries = renegadeHeaderEntries.sort(([a], [b]) => + a.toLowerCase().localeCompare(b.toLowerCase()), + ); + + // Prepare crypto primitives + const encoder = new TextEncoder(); + const mac = hmac.create(sha256, apiSecret); + + // Add path to signature + mac.update(encoder.encode(path)); + + // Add Renegade headers to signature + for (const [key, value] of canonicalHeaderEntries) { + mac.update(encoder.encode(key)); + mac.update(encoder.encode(value.toString())); + } + + // Add stringified body to signature + mac.update(encoder.encode(body)); + + // Generate signature and return as base64 without padding + const digest = mac.digest(); + return encodeBase64(digest); +} + +// Decode a base64 string to a Uint8Array. +function decodeBase64(base64: string): Uint8Array { + return new Uint8Array(Buffer.from(base64, 'base64')); +} + +// Encode a Uint8Array to a base64 string without trailing '=' padding. +function encodeBase64(data: Uint8Array): string { + return Buffer.from(data).toString('base64').replace(/=+$/, ''); +} diff --git a/src/dex/renegade/api/renegade-client.ts b/src/dex/renegade/api/renegade-client.ts new file mode 100644 index 000000000..711aeeca1 --- /dev/null +++ b/src/dex/renegade/api/renegade-client.ts @@ -0,0 +1,92 @@ +import { Network } from '../../../constants'; +import { IDexHelper } from '../../../dex-helper/idex-helper'; +import { Logger } from '../../../types'; +import { generateRenegadeAuthHeaders } from './auth'; +import { + buildRenegadeApiUrl, + RENEGADE_API_TIMEOUT_MS, + RENEGADE_ASSEMBLE_ENDPOINT, +} from '../constants'; +import { + AssembleExternalMatchRequest, + ExternalOrder, + SponsoredMatchResponse, +} from './types'; + +const DEFAULT_RENEGADE_HEADERS: Record = { + 'content-type': 'application/json', + accept: 'application/json', +}; + +export class RenegadeClient { + private readonly baseUrl: string; + + constructor( + private readonly dexHelper: IDexHelper, + private readonly network: Network, + private readonly apiKey: string, + private readonly apiSecret: string, + private readonly logger: Logger, + ) { + this.baseUrl = buildRenegadeApiUrl(this.network); + } + + async requestExternalMatch( + externalOrder: ExternalOrder, + ): Promise { + const requestBody: AssembleExternalMatchRequest = { + do_gas_estimation: false, + order: { + type: 'direct-order', + external_order: externalOrder, + }, + }; + + const { url: assembleUrl, path } = this.buildUrl( + RENEGADE_ASSEMBLE_ENDPOINT, + ); + + const headers = generateRenegadeAuthHeaders( + path, + JSON.stringify(requestBody), + DEFAULT_RENEGADE_HEADERS, + this.apiKey, + this.apiSecret, + ); + + try { + const response = await this.dexHelper.httpRequest.request({ + url: assembleUrl, + method: 'POST', + data: requestBody, + timeout: RENEGADE_API_TIMEOUT_MS, + headers, + }); + + if (response.status === 204 || !response.data) { + const err: any = new Error('No match available'); + err.isNoMatchError = true; + throw err; + } + + return response.data; + } catch (e: any) { + if (e.isNoMatchError) throw e; + this.logger.error('Renegade direct assemble request failed', { + url: assembleUrl, + requestBody, + status: e?.response?.status, + responseData: e?.response?.data, + }); + throw e; + } + } + + private buildUrl(endpoint: string): { url: string; path: string } { + const url = new URL(endpoint, this.baseUrl); + return { + url: url.toString(), + path: url.pathname, + }; + } +} diff --git a/src/dex/renegade/api/types.ts b/src/dex/renegade/api/types.ts new file mode 100644 index 000000000..1649aafb8 --- /dev/null +++ b/src/dex/renegade/api/types.ts @@ -0,0 +1,47 @@ +export type ExternalOrder = { + input_mint: string; + output_mint: string; + input_amount: string; + output_amount: string; + use_exact_output_amount: boolean; + min_fill_size: string; +}; + +type AssetTransfer = { + mint: string; + amount: string; +}; + +type DirectOrderAssembly = { + type: 'direct-order'; + external_order: ExternalOrder; +}; + +export type AssembleExternalMatchRequest = { + do_gas_estimation?: boolean; + order: DirectOrderAssembly; +}; + +export type SponsoredMatchResponse = { + match_bundle: { + min_receive: AssetTransfer; + max_receive: AssetTransfer; + min_send: AssetTransfer; + max_send: AssetTransfer; + deadline: number | string; + settlement_tx: SettlementTxResponse; + }; + input_amount?: string | null; + gas_sponsorship_info?: { + refund_amount: string; + refund_native_eth: boolean; + refund_address: string | null; + } | null; +}; + +type SettlementTxResponse = { + to: string; + data?: string; + input?: string; + value?: string; +}; diff --git a/src/dex/renegade/config.ts b/src/dex/renegade/config.ts new file mode 100644 index 000000000..f21972517 --- /dev/null +++ b/src/dex/renegade/config.ts @@ -0,0 +1,15 @@ +import { DexParams } from './types'; +import { DexConfigMap } from '../../types'; +import { Network } from '../../constants'; +import { RENEGADE_NAME } from './constants'; + +export const RenegadeConfig: DexConfigMap = { + [RENEGADE_NAME]: { + [Network.ARBITRUM]: { + usdcAddress: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + }, + [Network.BASE]: { + usdcAddress: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', + }, + }, +}; diff --git a/src/dex/renegade/constants.ts b/src/dex/renegade/constants.ts new file mode 100644 index 000000000..2392496c7 --- /dev/null +++ b/src/dex/renegade/constants.ts @@ -0,0 +1,55 @@ +// Constants for Renegade DEX integration +import { Network } from '../../constants'; + +export const RENEGADE_NAME = 'Renegade'; + +// Renegade API hosts. +export const RENEGADE_ARBITRUM_API_BASE_URL = + 'https://arbitrum-one.v2.auth-server.renegade.fi'; +export const RENEGADE_BASE_API_BASE_URL = + 'https://base-mainnet.v2.auth-server.renegade.fi'; + +export const RENEGADE_MARKETS_DEPTH_ENDPOINT = '/v2/markets/depth'; +export const RENEGADE_ASSEMBLE_ENDPOINT = + '/v2/external-matches/assemble-match-bundle'; + +export function buildRenegadeApiUrl(network: Network): string { + switch (network) { + case Network.ARBITRUM: + return RENEGADE_ARBITRUM_API_BASE_URL; + case Network.BASE: + return RENEGADE_BASE_API_BASE_URL; + default: + throw new Error(`Network ${network} is not supported by Renegade`); + } +} + +// Caching constants +export const RENEGADE_LEVELS_CACHE_TTL_SECONDS = 30; +export const RENEGADE_LEVELS_POLLING_INTERVAL = 15_000; +export const RENEGADE_LEVELS_CACHE_KEY = 'renegade_levels'; + +export const RENEGADE_TOKEN_METADATA_CACHE_TTL_SECONDS = 24 * 60 * 60; // 24 hours +export const RENEGADE_TOKEN_METADATA_POLLING_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours +export const RENEGADE_TOKEN_METADATA_CACHE_KEY = 'renegade_token_metadata'; + +// API timeout settings +export const RENEGADE_API_TIMEOUT_MS = 10_000; + +// Authentication constants +export const RENEGADE_HEADER_PREFIX = 'x-renegade'; +export const RENEGADE_API_KEY_HEADER = 'x-renegade-api-key'; +export const RENEGADE_AUTH_HEADER = 'x-renegade-auth'; +export const RENEGADE_AUTH_EXPIRATION_HEADER = 'x-renegade-auth-expiration'; +export const REQUEST_SIGNATURE_DURATION_MS = 10_000; + +// Gas cost estimation +export const RENEGADE_GAS_COST = 3_000_000; + +// Calldata / selector constants +export const RENEGADE_SETTLEMENT_BUNDLE_DATA_WORDS = 33; +export const RENEGADE_SETTLE_EXTERNAL_MATCH_AMOUNT_IN_POS = 4; + +// Token metadata API constants +export const RENEGADE_TOKEN_MAPPINGS_BASE_URL = + 'https://raw.githubusercontent.com/renegade-fi/token-mappings/main/'; diff --git a/src/dex/renegade/rate-fetcher.ts b/src/dex/renegade/rate-fetcher.ts new file mode 100644 index 000000000..b261c8416 --- /dev/null +++ b/src/dex/renegade/rate-fetcher.ts @@ -0,0 +1,264 @@ +import BigNumber from 'bignumber.js'; +import { Network } from '../../constants'; +import { IDexHelper } from '../../dex-helper'; +import { RequestConfig } from '../../dex-helper/irequest-wrapper'; +import { Fetcher, RequestInfo } from '../../lib/fetcher/fetcher'; +import { Logger, Token } from '../../types'; +import { generateRenegadeAuthHeaders } from './api/auth'; +import { + buildRenegadeApiUrl, + RENEGADE_MARKETS_DEPTH_ENDPOINT, + RENEGADE_LEVELS_POLLING_INTERVAL, + RENEGADE_TOKEN_MAPPINGS_BASE_URL, + RENEGADE_TOKEN_METADATA_POLLING_INTERVAL, +} from './constants'; +import { RenegadeLevelsResponse } from './renegade-levels-response'; +import { + RenegadeDepth, + RenegadeMarketSideDepth, + RenegadeMarketDepthsResponse, + RenegadeRateFetcherConfig, + RenegadeTokenRemap, +} from './types'; + +export class RateFetcher { + private levelsFetcher: Fetcher; + private levelsCacheKey: string; + private levelsCacheTTL: number; + + private tokenMetadataFetcher!: Fetcher; + private tokenMetadataCacheKey: string; + private tokenMetadataCacheTTL: number; + + constructor( + private dexHelper: IDexHelper, + private dexKey: string, + private network: Network, + private logger: Logger, + private config: RenegadeRateFetcherConfig, + ) { + this.levelsCacheKey = config.levelsCacheKey; + this.levelsCacheTTL = config.levelsCacheTTL; + this.tokenMetadataCacheKey = config.tokenMetadataCacheKey; + this.tokenMetadataCacheTTL = config.tokenMetadataCacheTTL; + const baseUrl = buildRenegadeApiUrl(this.network); + const url = `${baseUrl}${RENEGADE_MARKETS_DEPTH_ENDPOINT}`; + + const authenticate = (options: RequestConfig): RequestConfig => { + const authenticatedHeaders = generateRenegadeAuthHeaders( + RENEGADE_MARKETS_DEPTH_ENDPOINT, + options.data ? JSON.stringify(options.data) : '', + this.stringifyHeaders(options.headers), + this.config.apiKey, + this.config.apiSecret, + ); + + options.headers = authenticatedHeaders; + + return options; + }; + + const caster = (data: unknown): RenegadeLevelsResponse => { + if (typeof data !== 'object' || data === null) { + throw new Error('Invalid response format from Renegade API'); + } + + const response = data as RenegadeMarketDepthsResponse; + return new RenegadeLevelsResponse(this.toPairDepthMap(response)); + }; + + const requestInfo: RequestInfo = { + requestOptions: { + url, + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json;number=string', + }, + }, + caster, + authenticate, + }; + + this.levelsFetcher = new Fetcher( + this.dexHelper.httpRequest, + { + info: requestInfo, + handler: this.handleLevelsResponse.bind(this), + }, + RENEGADE_LEVELS_POLLING_INTERVAL, + this.logger, + ); + + const chainName = this.getChainName(); + const tokenUrl = `${RENEGADE_TOKEN_MAPPINGS_BASE_URL}${chainName}.json`; + const tokenCaster = (data: unknown): RenegadeTokenRemap => { + if (typeof data !== 'object' || data === null) { + throw new Error('Invalid token metadata response format'); + } + return data as RenegadeTokenRemap; + }; + const tokenRequestInfo: RequestInfo = { + requestOptions: { + url: tokenUrl, + method: 'GET', + }, + caster: tokenCaster, + }; + this.tokenMetadataFetcher = new Fetcher( + this.dexHelper.httpRequest, + { + info: tokenRequestInfo, + handler: this.handleTokenMetadataResponse.bind(this), + }, + RENEGADE_TOKEN_METADATA_POLLING_INTERVAL, + this.logger, + ); + } + + // Get chain name for token mappings URL. + private getChainName(): string { + switch (this.network) { + case Network.ARBITRUM: + return 'arbitrum-one'; + case Network.BASE: + return 'base-mainnet'; + default: + throw new Error( + `Network ${this.network} is not supported for token metadata`, + ); + } + } + + // Handle successful levels response from the Fetcher. + private handleLevelsResponse(levelsResponse: RenegadeLevelsResponse): void { + const rawData = levelsResponse.getRawData(); + + this.dexHelper.cache.setex( + this.dexKey, + this.network, + this.levelsCacheKey, + this.levelsCacheTTL, + JSON.stringify(rawData), + ); + } + + private toPairDepthMap( + response: RenegadeMarketDepthsResponse, + ): Record { + const pairDepthMap: Record = {}; + const marketDepths = Array.isArray(response.market_depths) + ? response.market_depths + : []; + + for (const marketDepth of marketDepths) { + const baseAddress = marketDepth.market?.base?.address?.toLowerCase(); + const quoteAddress = marketDepth.market?.quote?.address?.toLowerCase(); + const price = this.parsePositiveDecimal(marketDepth.market?.price?.price); + + if (!baseAddress || !quoteAddress || !price) { + continue; + } + + const bidBaseSize = this.resolveBaseSize(marketDepth.buy, price); + const askBaseSize = this.resolveBaseSize(marketDepth.sell, price); + + const pairIdentifier = `${baseAddress}/${quoteAddress}`; + pairDepthMap[pairIdentifier] = { + bids: bidBaseSize.gt(0) + ? [[price.toFixed(), bidBaseSize.toFixed()]] + : [], + asks: askBaseSize.gt(0) + ? [[price.toFixed(), askBaseSize.toFixed()]] + : [], + }; + } + + return pairDepthMap; + } + + private parsePositiveDecimal(value: unknown): BigNumber | null { + if (value === null || value === undefined) { + return null; + } + + const parsed = new BigNumber(String(value)); + if (!parsed.isFinite() || parsed.lte(0)) { + return null; + } + + return parsed; + } + + private resolveBaseSize( + marketSideDepth: RenegadeMarketSideDepth | undefined, + price: BigNumber, + ): BigNumber { + const quantity = this.parsePositiveDecimal(marketSideDepth?.total_quantity); + if (quantity) { + return quantity; + } + + const quantityUsd = this.parsePositiveDecimal( + marketSideDepth?.total_quantity_usd, + ); + if (!quantityUsd) { + return new BigNumber(0); + } + + const baseSize = quantityUsd.dividedBy(price); + if (!baseSize.isFinite() || baseSize.lte(0)) { + return new BigNumber(0); + } + + return baseSize; + } + + private stringifyHeaders( + headers: RequestConfig['headers'], + ): Record { + const stringHeaders: Record = {}; + for (const [key, value] of Object.entries(headers ?? {})) { + stringHeaders[key] = String(value); + } + + return stringHeaders; + } + + // Handle successful token metadata response from the Fetcher. + private handleTokenMetadataResponse(tokenRemap: RenegadeTokenRemap): void { + const tokensMap: Record = {}; + + for (const tokenInfo of tokenRemap.tokens) { + const address = tokenInfo.address.toLowerCase(); + tokensMap[address] = { + address: tokenInfo.address, + decimals: tokenInfo.decimals, + symbol: tokenInfo.ticker, + }; + } + + this.dexHelper.cache.setex( + this.dexKey, + this.network, + this.tokenMetadataCacheKey, + this.tokenMetadataCacheTTL, + JSON.stringify(tokensMap), + ); + } + + start(): void { + this.levelsFetcher.startPolling(); + this.tokenMetadataFetcher.startPolling(); + } + + async fetchOnce(): Promise { + await this.levelsFetcher.fetch(true); + await this.tokenMetadataFetcher.fetch(true); + } + + stop(): void { + this.levelsFetcher.stopPolling(); + this.tokenMetadataFetcher.stopPolling(); + } +} diff --git a/src/dex/renegade/renegade-e2e.test.ts b/src/dex/renegade/renegade-e2e.test.ts new file mode 100644 index 000000000..35f64dfa0 --- /dev/null +++ b/src/dex/renegade/renegade-e2e.test.ts @@ -0,0 +1,122 @@ +import dotenv from 'dotenv'; +dotenv.config(); + +import { testE2E } from '../../../tests/utils-e2e'; +import { Tokens, Holders } from '../../../tests/constants-e2e'; +import { Network, ContractMethod, SwapSide } from '../../constants'; +import { RENEGADE_NAME } from './constants'; + +/** Pause test after `initializePricing` */ +const SLEEP_MS = 1000; + +/** Slippage in BPS */ +const SLIPPAGE = 3; + +function testForNetwork( + network: Network, + dexKey: string, + quoteSymbol: string, + baseSymbol: string, + quoteAmount: string, + baseAmount: string, + nativeTokenAmount: string, +) { + 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(`${quoteSymbol} -> ${baseSymbol}`, async () => { + await testE2E( + tokens[quoteSymbol], + tokens[baseSymbol], + holders[quoteSymbol], + side === SwapSide.SELL ? quoteAmount : baseAmount, + side, + dexKey, + contractMethod, + network, + undefined, // provider + undefined, // poolIdentifiers + undefined, // limitOrderProvider + undefined, // transferFees + SLIPPAGE, + SLEEP_MS, + ); + }); + it(`${baseSymbol} -> ${quoteSymbol}`, async () => { + await testE2E( + tokens[baseSymbol], + tokens[quoteSymbol], + holders[baseSymbol], + side === SwapSide.SELL ? baseAmount : quoteAmount, + side, + dexKey, + contractMethod, + network, + undefined, // provider + undefined, // poolIdentifiers + undefined, // limitOrderProvider + undefined, // transferFees + SLIPPAGE, + SLEEP_MS, + ); + }); + }); + }); + }), + ); + }); +} + +describe('Renegade E2E', () => { + const dexKey = RENEGADE_NAME; + + describe('Arbitrum', () => { + const network = Network.ARBITRUM; + + const quoteSymbol: string = 'USDC'; + const baseSymbol: string = 'WETH'; + + const quoteAmount: string = '10000000'; // 10 USDC + const baseAmount: string = '10000000000000000'; // 0.01 WETH + + testForNetwork( + network, + dexKey, + quoteSymbol, + baseSymbol, + quoteAmount, + baseAmount, + baseAmount, + ); + }); + + describe('Base', () => { + const network = Network.BASE; + + const quoteSymbol: string = 'USDC'; + const baseSymbol: string = 'WETH'; + + const quoteAmount: string = '10000000'; // 10 USDC + const baseAmount: string = '10000000000000000'; // 0.01 WETH + + testForNetwork( + network, + dexKey, + quoteSymbol, + baseSymbol, + quoteAmount, + baseAmount, + baseAmount, + ); + }); +}); diff --git a/src/dex/renegade/renegade-integration.test.ts b/src/dex/renegade/renegade-integration.test.ts new file mode 100644 index 000000000..dbd9b26d9 --- /dev/null +++ b/src/dex/renegade/renegade-integration.test.ts @@ -0,0 +1,384 @@ +import dotenv from 'dotenv'; +dotenv.config(); + +import { Tokens } from '../../../tests/constants-e2e'; +import { + checkConstantPoolPrices, + checkPoolPrices, + checkPoolsLiquidity, + sleep, +} from '../../../tests/utils'; +import { BI_POWS } from '../../bigint-constants'; +import { Network, SwapSide } from '../../constants'; +import { DummyDexHelper } from '../../dex-helper/index'; +import { RENEGADE_NAME } from './constants'; +import { Renegade } from './renegade'; + +async function testPricingOnNetwork( + renegade: Renegade, + network: Network, + dexKey: string, + blockNumber: number, + srcTokenSymbol: string, + destTokenSymbol: string, + side: SwapSide, + amounts: bigint[], +) { + const networkTokens = Tokens[network]; + + const pools = await renegade.getPoolIdentifiers( + networkTokens[srcTokenSymbol], + networkTokens[destTokenSymbol], + side, + blockNumber, + ); + console.log( + `${srcTokenSymbol} <> ${destTokenSymbol} Pool Identifiers: `, + pools, + ); + + expect(pools.length).toBeGreaterThan(0); + + const poolPrices = await renegade.getPricesVolume( + networkTokens[srcTokenSymbol], + networkTokens[destTokenSymbol], + amounts, + side, + blockNumber, + pools, + ); + console.log( + `${srcTokenSymbol} <> ${destTokenSymbol} Pool Prices: `, + poolPrices, + ); + + expect(poolPrices).not.toBeNull(); + if (renegade.hasConstantPriceLargeAmounts) { + checkConstantPoolPrices(poolPrices!, amounts, dexKey); + } else { + checkPoolPrices(poolPrices!, amounts, side, dexKey, false); + } +} + +describe('Renegade', function () { + const dexKey = RENEGADE_NAME; + let blockNumber: number; + let renegade: Renegade; + + describe('Arbitrum', () => { + const network = Network.ARBITRUM; + const dexHelper = new DummyDexHelper(network); + + const tokens = Tokens[network]; + + const srcTokenSymbol = 'WETH'; + const destTokenSymbol = 'USDC'; + + const amountsForSell = [ + 0n, + 1n * BI_POWS[tokens[srcTokenSymbol].decimals], + 2n * BI_POWS[tokens[srcTokenSymbol].decimals], + ]; + + const amountsForBuy = [ + 0n, + 1000n * BI_POWS[tokens[destTokenSymbol].decimals], + 2000n * BI_POWS[tokens[destTokenSymbol].decimals], + 3000n * BI_POWS[tokens[destTokenSymbol].decimals], + 4000n * BI_POWS[tokens[destTokenSymbol].decimals], + 5000n * BI_POWS[tokens[destTokenSymbol].decimals], + 6000n * BI_POWS[tokens[destTokenSymbol].decimals], + 7000n * BI_POWS[tokens[destTokenSymbol].decimals], + 8000n * BI_POWS[tokens[destTokenSymbol].decimals], + 9000n * BI_POWS[tokens[destTokenSymbol].decimals], + 10000n * BI_POWS[tokens[destTokenSymbol].decimals], + ]; + + beforeAll(async () => { + blockNumber = await dexHelper.web3Provider.eth.getBlockNumber(); + renegade = new Renegade(network, dexKey, dexHelper); + await renegade.initializePricing(blockNumber); + await sleep(5000); + }); + + afterAll(async () => { + if (renegade.releaseResources) { + await renegade.releaseResources(); + } + }); + + it('getPoolIdentifiers and getPricesVolume SELL', async function () { + await testPricingOnNetwork( + renegade, + network, + dexKey, + blockNumber, + srcTokenSymbol, + destTokenSymbol, + SwapSide.SELL, + amountsForSell, + ); + }); + + it('getPoolIdentifiers and getPricesVolume BUY', async function () { + await testPricingOnNetwork( + renegade, + 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 newRenegade = new Renegade(network, dexKey, dexHelper); + if (newRenegade.updatePoolState) { + await newRenegade.updatePoolState(); + } + const poolLiquidity = await newRenegade.getTopPoolsForToken( + tokens[srcTokenSymbol].address, + 10, + ); + console.log( + `${srcTokenSymbol} Top Pools:`, + JSON.stringify(poolLiquidity, null, 2), + ); + + if (!newRenegade.hasConstantPriceLargeAmounts) { + checkPoolsLiquidity( + poolLiquidity, + Tokens[network][srcTokenSymbol].address, + dexKey, + ); + } + }); + + it('getTopPoolsForToken for USDC', async function () { + // We have to check without calling initializePricing, because + // pool-tracker is not calling that function + const newRenegade = new Renegade(network, dexKey, dexHelper); + if (newRenegade.updatePoolState) { + await newRenegade.updatePoolState(); + } + const limit = 10; + const poolLiquidity = await newRenegade.getTopPoolsForToken( + tokens[destTokenSymbol].address, + limit, + ); + console.log( + `${destTokenSymbol} Top Pools:`, + JSON.stringify(poolLiquidity, null, 2), + ); + + // Verify limit is respected + expect(poolLiquidity.length).toBeLessThanOrEqual(limit); + + // Verify pools are sorted in descending order of liquidityUSD + for (let i = 0; i < poolLiquidity.length - 1; i++) { + expect(poolLiquidity[i].liquidityUSD).toBeGreaterThanOrEqual( + poolLiquidity[i + 1].liquidityUSD, + ); + } + + if (!newRenegade.hasConstantPriceLargeAmounts) { + checkPoolsLiquidity( + poolLiquidity, + Tokens[network][destTokenSymbol].address, + dexKey, + ); + } + }); + + it('should return null when quote is not USDC', async function () { + // Test with two non-USDC tokens (WETH -> DAI) + const nonUsdcSrcToken = tokens['WETH']; + const nonUsdcDestToken = tokens['DAI']; + const amounts = [BI_POWS[nonUsdcSrcToken.decimals]]; + + const poolPrices = await renegade.getPricesVolume( + nonUsdcSrcToken, + nonUsdcDestToken, + amounts, + SwapSide.SELL, + blockNumber, + ); + + expect(poolPrices).toBeNull(); + }); + + it('should return null when both tokens are unsupported by Renegade API', async function () { + // Test with two tokens that are not supported by Renegade API (BAL -> SUSHI) + const unsupportedSrcToken = tokens['BAL']; + const unsupportedDestToken = tokens['SUSHI']; + const amounts = [BI_POWS[unsupportedSrcToken.decimals]]; + + const poolPrices = await renegade.getPricesVolume( + unsupportedSrcToken, + unsupportedDestToken, + amounts, + SwapSide.SELL, + blockNumber, + ); + + expect(poolPrices).toBeNull(); + }); + }); + + describe('Base', () => { + const network = Network.BASE; + const dexHelper = new DummyDexHelper(network, 'https://mainnet.base.org'); + + const tokens = Tokens[network]; + + const srcTokenSymbol = 'WETH'; + const destTokenSymbol = 'USDC'; + + const amountsForSell = [0n, BI_POWS[tokens[srcTokenSymbol].decimals] / 10n]; + + const amountsForBuy = [ + 0n, + 1000n * BI_POWS[tokens[destTokenSymbol].decimals], + 2000n * BI_POWS[tokens[destTokenSymbol].decimals], + 3000n * BI_POWS[tokens[destTokenSymbol].decimals], + ]; + + beforeAll(async () => { + blockNumber = await dexHelper.web3Provider.eth.getBlockNumber(); + renegade = new Renegade(network, dexKey, dexHelper); + await renegade.initializePricing(blockNumber); + await sleep(5000); + }); + + afterAll(async () => { + if (renegade.releaseResources) { + await renegade.releaseResources(); + } + }); + + it('getPoolIdentifiers and getPricesVolume SELL', async function () { + await testPricingOnNetwork( + renegade, + network, + dexKey, + blockNumber, + srcTokenSymbol, + destTokenSymbol, + SwapSide.SELL, + amountsForSell, + ); + }); + + it('getPoolIdentifiers and getPricesVolume BUY', async function () { + await testPricingOnNetwork( + renegade, + 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 newRenegade = new Renegade(network, dexKey, dexHelper); + if (newRenegade.updatePoolState) { + await newRenegade.updatePoolState(); + } + const poolLiquidity = await newRenegade.getTopPoolsForToken( + tokens[srcTokenSymbol].address, + 10, + ); + console.log( + `${srcTokenSymbol} Top Pools:`, + JSON.stringify(poolLiquidity, null, 2), + ); + + if (!newRenegade.hasConstantPriceLargeAmounts) { + checkPoolsLiquidity( + poolLiquidity, + Tokens[network][srcTokenSymbol].address, + dexKey, + ); + } + }); + + it('getTopPoolsForToken for USDC', async function () { + // We have to check without calling initializePricing, because + // pool-tracker is not calling that function + const newRenegade = new Renegade(network, dexKey, dexHelper); + if (newRenegade.updatePoolState) { + await newRenegade.updatePoolState(); + } + const limit = 10; + const poolLiquidity = await newRenegade.getTopPoolsForToken( + tokens[destTokenSymbol].address, + limit, + ); + console.log( + `${destTokenSymbol} Top Pools:`, + JSON.stringify(poolLiquidity, null, 2), + ); + + // Verify limit is respected + expect(poolLiquidity.length).toBeLessThanOrEqual(limit); + + // Verify pools are sorted in descending order of liquidityUSD + for (let i = 0; i < poolLiquidity.length - 1; i++) { + expect(poolLiquidity[i].liquidityUSD).toBeGreaterThanOrEqual( + poolLiquidity[i + 1].liquidityUSD, + ); + } + + if (!newRenegade.hasConstantPriceLargeAmounts) { + checkPoolsLiquidity( + poolLiquidity, + Tokens[network][destTokenSymbol].address, + dexKey, + ); + } + }); + + it('should return null when quote is not USDC', async function () { + // Test with two non-USDC tokens (WETH -> DAI) + const nonUsdcSrcToken = tokens['WETH']; + const nonUsdcDestToken = tokens['DAI']; + const amounts = [BI_POWS[nonUsdcSrcToken.decimals]]; + + const poolPrices = await renegade.getPricesVolume( + nonUsdcSrcToken, + nonUsdcDestToken, + amounts, + SwapSide.SELL, + blockNumber, + ); + + expect(poolPrices).toBeNull(); + }); + + it('should return null when both tokens are unsupported by Renegade API', async function () { + // Test with two tokens that are not supported by Renegade API (PRIME -> MAV) + const unsupportedSrcToken = tokens['PRIME']; + const unsupportedDestToken = tokens['MAV']; + const amounts = [BI_POWS[unsupportedSrcToken.decimals]]; + + const poolPrices = await renegade.getPricesVolume( + unsupportedSrcToken, + unsupportedDestToken, + amounts, + SwapSide.SELL, + blockNumber, + ); + + expect(poolPrices).toBeNull(); + }); + }); +}); diff --git a/src/dex/renegade/renegade-levels-response.ts b/src/dex/renegade/renegade-levels-response.ts new file mode 100644 index 000000000..c80fc35c8 --- /dev/null +++ b/src/dex/renegade/renegade-levels-response.ts @@ -0,0 +1,106 @@ +import { Address } from '../../types'; +import { RenegadeDepth, RenegadePriceLevel } from './types'; + +// Depth response from Renegade API. +export class RenegadeLevelsResponse { + private readonly levels: { [pairIdentifier: string]: RenegadeDepth }; + + constructor(data: { [pairIdentifier: string]: RenegadeDepth }) { + this.levels = data; + + if (!this.levels || typeof this.levels !== 'object') { + throw new Error('Invalid Renegade levels response: expected object'); + } + } + + // Returns the raw levels data. + public getRawData(): { [pairIdentifier: string]: RenegadeDepth } { + return this.levels; + } + + public getPairDepth( + baseToken: Address, + quoteToken: Address, + ): RenegadeDepth | null { + const expectedPair = `${baseToken.toLowerCase()}/${quoteToken.toLowerCase()}`; + return this.levels[expectedPair] ?? null; + } + + // Returns directional liquidity data for a token across all relevant pairs. + public getDirectionalLiquidityForToken( + tokenAddress: Address, + usdcAddress: Address, + ): Array<{ + baseToken: Address; + quoteToken: Address; + outboundLiquidityUSD: number; + reverseLiquidityUSD: number; + isTokenBase: boolean; + }> { + const normalizedTokenAddress = tokenAddress.toLowerCase(); + const normalizedUsdcAddress = usdcAddress.toLowerCase(); + + const results: Array<{ + baseToken: Address; + quoteToken: Address; + outboundLiquidityUSD: number; + reverseLiquidityUSD: number; + isTokenBase: boolean; + }> = []; + + for (const [pairIdentifier, pairDepth] of Object.entries(this.levels)) { + const parsedPair = this.parsePairIdentifier(pairIdentifier); + if (!parsedPair) continue; + + const [baseToken, quoteToken] = parsedPair; + if (quoteToken !== normalizedUsdcAddress) continue; // quote token should always be USDC + + const isTokenBase = normalizedTokenAddress === baseToken; + const isTokenQuote = normalizedTokenAddress === quoteToken; + + if (!isTokenBase && !isTokenQuote) continue; + + // Calculate directional liquidity + // If token is base: outbound = bids (sell base), reverse = asks (buy base) + // If token is quote: outbound = asks (sell quote), reverse = bids (buy quote) + const outboundLiquidityUSD = isTokenBase + ? this.calculateLiquidityUSD(pairDepth.bids) + : this.calculateLiquidityUSD(pairDepth.asks); + + const reverseLiquidityUSD = isTokenBase + ? this.calculateLiquidityUSD(pairDepth.asks) + : this.calculateLiquidityUSD(pairDepth.bids); + + if (outboundLiquidityUSD <= 0) continue; + + results.push({ + baseToken, + quoteToken, + outboundLiquidityUSD, + reverseLiquidityUSD, + isTokenBase, + }); + } + + return results; + } + + // Internal helper: parse pair identifier into [base, quote] tuple. + // Pair identifiers are formatted as [base]/[quote], where quote is always USDC. + private parsePairIdentifier(pairIdentifier: string): [string, string] | null { + const parts = pairIdentifier.split('/').map(s => s.toLowerCase()); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return [parts[0], parts[1]]; + } + + // Internal helper: calculate USD liquidity from price levels. + // Sums price * size across all levels (both are strings). + private calculateLiquidityUSD(levels: RenegadePriceLevel[]): number { + return levels.reduce( + (acc, [price, size]) => acc + parseFloat(size) * parseFloat(price), + 0, + ); + } +} diff --git a/src/dex/renegade/renegade.ts b/src/dex/renegade/renegade.ts new file mode 100644 index 000000000..4e834747a --- /dev/null +++ b/src/dex/renegade/renegade.ts @@ -0,0 +1,775 @@ +import BigNumber from 'bignumber.js'; +import { assert } from 'ts-essentials'; +import * as CALLDATA_GAS_COST from '../../calldata-gas-cost'; +import { Network, SwapSide } from '../../constants'; +import { IDexHelper } from '../../dex-helper/idex-helper'; +import { IDex } from '../../dex/idex'; +import { + AdapterExchangeParam, + Address, + DexExchangeParam, + ExchangePrices, + ExchangeTxInfo, + Logger, + NumberAsString, + OptimalSwapExchange, + PoolLiquidity, + PoolPrices, + PreprocessTransactionOptions, + Token, + TransferFeeParams, +} from '../../types'; +import { getBigIntPow } from '../../utils'; +import { BlacklistError } from '../generic-rfq/types'; +import { SimpleExchangeWithRestrictions } from '../simple-exchange-with-restrictions'; +import { RenegadeClient } from './api/renegade-client'; +import { ExternalOrder, SponsoredMatchResponse } from './api/types'; +import { RenegadeConfig } from './config'; +import { + RENEGADE_GAS_COST, + RENEGADE_LEVELS_CACHE_KEY, + RENEGADE_LEVELS_CACHE_TTL_SECONDS, + RENEGADE_NAME, + RENEGADE_SETTLEMENT_BUNDLE_DATA_WORDS, + RENEGADE_SETTLE_EXTERNAL_MATCH_AMOUNT_IN_POS, + RENEGADE_TOKEN_METADATA_CACHE_KEY, + RENEGADE_TOKEN_METADATA_CACHE_TTL_SECONDS, +} from './constants'; +import { RateFetcher } from './rate-fetcher'; +import { RenegadeLevelsResponse } from './renegade-levels-response'; +import { + RenegadeData, + RenegadeDepth, + RenegadeMidpointDepth, + RenegadePriceLevel, + RenegadeRateFetcherConfig, + RenegadeTx, +} from './types'; + +export class Renegade + extends SimpleExchangeWithRestrictions + implements IDex +{ + readonly hasConstantPriceLargeAmounts = false; + readonly needWrapNative = true; + readonly isFeeOnTransferSupported = false; + readonly isStatePollingDex = true; + readonly needsSequentialPreprocessing = true; + + public static dexKeysWithNetwork: { key: string; networks: Network[] }[] = [ + { key: RENEGADE_NAME, networks: [Network.ARBITRUM, Network.BASE] }, + ]; + + private rateFetcher: RateFetcher; + private renegadeClient: RenegadeClient; + private tokensMap: Record = {}; + + private usdcAddress: string; + + logger: Logger; + + constructor( + readonly network: Network, + readonly dexKey: string, + readonly dexHelper: IDexHelper, + ) { + super(dexHelper, dexKey, { enablePairRestriction: true }); + this.logger = dexHelper.getLogger(dexKey); + + const apiKey = this.dexHelper.config.data.renegadeAuthApiKey; + const apiSecret = this.dexHelper.config.data.renegadeAuthApiSecret; + + assert( + apiKey !== undefined, + 'Renegade API key is not specified with env variable API_KEY_RENEGADE_AUTH_API_KEY', + ); + + assert( + apiSecret !== undefined, + 'Renegade API secret is not specified with env variable API_KEY_RENEGADE_AUTH_API_SECRET', + ); + + const rateFetcherConfig: RenegadeRateFetcherConfig = { + apiKey, + apiSecret, + levelsCacheKey: RENEGADE_LEVELS_CACHE_KEY, + levelsCacheTTL: RENEGADE_LEVELS_CACHE_TTL_SECONDS, + tokenMetadataCacheKey: RENEGADE_TOKEN_METADATA_CACHE_KEY, + tokenMetadataCacheTTL: RENEGADE_TOKEN_METADATA_CACHE_TTL_SECONDS, + }; + + this.rateFetcher = new RateFetcher( + this.dexHelper, + this.dexKey, + this.network, + this.logger, + rateFetcherConfig, + ); + + this.renegadeClient = new RenegadeClient( + this.dexHelper, + this.network, + apiKey, + apiSecret, + this.logger, + ); + + this.usdcAddress = RenegadeConfig[RENEGADE_NAME][this.network].usdcAddress; + } + + async initializePricing(_blockNumber: number): Promise { + if (!this.dexHelper.config.isSlave) { + this.rateFetcher.start(); + await this.rateFetcher.fetchOnce(); + } + + await this.setTokensMap(); + } + + getAdapters(_side: SwapSide): { name: string; index: number }[] | null { + return null; + } + + async getPoolIdentifiers( + srcToken: Token, + destToken: Token, + _side: SwapSide, + _blockNumber: number, + ): Promise { + if (!this.areTokensSupported(srcToken.address, destToken.address)) { + return []; + } + return [this.getPoolIdentifier(srcToken.address, destToken.address)]; + } + + async getPricesVolume( + srcToken: Token, + destToken: Token, + amounts: bigint[], + side: SwapSide, + _blockNumber: number, + limitPools?: string[], + _transferFees?: TransferFeeParams, + _isFirstSwap?: boolean, + ): Promise | null> { + try { + if (amounts.length === 0) { + return null; + } + + if (!this.areTokensSupported(srcToken.address, destToken.address)) { + return null; + } + + const poolIdentifier = this.getPoolIdentifier( + srcToken.address, + destToken.address, + ); + if (limitPools && !limitPools.includes(poolIdentifier)) { + return null; + } + + const levels = await this.getCachedLevels(); + if (!levels) { + return null; + } + + const prices = this.computePricesFromCachedLevels( + levels, + srcToken, + destToken, + amounts, + side, + ); + + if (!prices) { + return null; + } + + const outputDecimals = + side === SwapSide.SELL ? destToken.decimals : srcToken.decimals; + + return [ + { + prices, + unit: getBigIntPow(outputDecimals), + data: {}, + poolIdentifiers: [poolIdentifier], + exchange: this.dexKey, + gasCost: RENEGADE_GAS_COST, + }, + ]; + } catch (e: unknown) { + this.logger.error( + `Error_getPricesVolume ${srcToken.address || srcToken.symbol}, ${ + destToken.address || destToken.symbol + }, ${side}:`, + e, + ); + return null; + } + } + + getCalldataGasCost(_poolPrices: PoolPrices): number | number[] { + return ( + CALLDATA_GAS_COST.DEX_OVERHEAD + + CALLDATA_GAS_COST.FUNCTION_SELECTOR + + CALLDATA_GAS_COST.AMOUNT + // externalPartyAmountIn + CALLDATA_GAS_COST.ADDRESS + // recipient + CALLDATA_GAS_COST.ADDRESS * 2 + // internalPartyInputToken, internalPartyOutputToken + CALLDATA_GAS_COST.FULL_WORD + // price.repr + CALLDATA_GAS_COST.AMOUNT * 2 + // minInternalPartyAmountIn, maxInternalPartyAmountIn + CALLDATA_GAS_COST.TIMESTAMP + // blockDeadline + CALLDATA_GAS_COST.OFFSET_LARGE + // SettlementBundle top-level offset + CALLDATA_GAS_COST.BOOL + // isFirstFill + CALLDATA_GAS_COST.INDEX + // bundleType + CALLDATA_GAS_COST.OFFSET_SMALL + // SettlementBundle.data offset + CALLDATA_GAS_COST.LENGTH_LARGE + // representative bytes length (~1056 bytes) + RENEGADE_SETTLEMENT_BUNDLE_DATA_WORDS * CALLDATA_GAS_COST.FULL_WORD + ); + } + + getAdapterParam( + _srcToken: string, + _destToken: string, + _srcAmount: string, + _destAmount: string, + data: RenegadeData, + _side: SwapSide, + ): AdapterExchangeParam { + const settlementTx = data?.settlementTx; + + if (!settlementTx) { + throw new Error( + `${this.dexKey}-${this.network}: settlementTx missing from data`, + ); + } + + return { + targetExchange: settlementTx.to, + payload: settlementTx.data, + networkFee: settlementTx.value, + }; + } + + async updatePoolState(): Promise { + await this.setTokensMap(); + } + + async getTopPoolsForToken( + tokenAddress: Address, + limit: number, + ): Promise { + const levels = await this.getCachedLevels(); + if (!levels) return []; + await this.setTokensMap(); + + const normalizedTokenAddress = tokenAddress.toLowerCase(); + const normalizedUsdcAddress = this.usdcAddress.toLowerCase(); + + const directionalLiquidity = levels.getDirectionalLiquidityForToken( + normalizedTokenAddress, + normalizedUsdcAddress, + ); + + const results: PoolLiquidity[] = []; + + for (const liquidityData of directionalLiquidity) { + const { + baseToken, + outboundLiquidityUSD, + reverseLiquidityUSD, + isTokenBase, + } = liquidityData; + + const connectorAddress = isTokenBase ? normalizedUsdcAddress : baseToken; + const connectorMeta = this.tokensMap[connectorAddress]; + + if (!connectorMeta) { + continue; + } + + results.push({ + exchange: this.dexKey, + address: baseToken, + connectorTokens: [ + { + address: connectorAddress, + decimals: connectorMeta.decimals, + symbol: connectorMeta.symbol, + liquidityUSD: reverseLiquidityUSD, + }, + ], + liquidityUSD: outboundLiquidityUSD, + }); + } + + return results + .sort((a, b) => b.liquidityUSD - a.liquidityUSD) + .slice(0, limit); + } + + getDexParam( + _srcToken: Address, + _destToken: Address, + _srcAmount: NumberAsString, + _destAmount: NumberAsString, + _recipient: Address, + data: RenegadeData, + side: SwapSide, + ): DexExchangeParam { + const settlementTx = data?.settlementTx; + + if (!settlementTx) { + throw new Error( + `${this.dexKey}-${this.network}: settlementTx missing from data`, + ); + } + + // BUY path should preserve assembled calldata amount to avoid forcing execution at + // Augustus max-in (`fromAmount`) instead of quoted amount. + const disableRuntimeAmountInsertion = side === SwapSide.BUY; + const insertFromAmountPos = !disableRuntimeAmountInsertion + ? RENEGADE_SETTLE_EXTERNAL_MATCH_AMOUNT_IN_POS + : undefined; + + return { + needWrapNative: this.needWrapNative, + dexFuncHasRecipient: false, + exchangeData: settlementTx.data, + targetExchange: settlementTx.to, + returnAmountPos: undefined, + // settleExternalMatch(uint256,address,...) has externalPartyAmountIn as the + // first argument after the selector, so Augustus can patch at byte offset 4. + insertFromAmountPos, + swappedAmountNotPresentInExchangeData: disableRuntimeAmountInsertion, + }; + } + + async preProcessTransaction( + optimalSwapExchange: OptimalSwapExchange, + srcToken: Token, + destToken: Token, + side: SwapSide, + options: PreprocessTransactionOptions, + ): Promise<[OptimalSwapExchange, ExchangeTxInfo]> { + try { + await this.assertAddressesNotBlacklisted(options); + this.assertPairSupported(srcToken.address, destToken.address); + + const externalOrder = this.createExternalOrder( + srcToken, + destToken, + side, + optimalSwapExchange.srcAmount, + optimalSwapExchange.destAmount, + ); + const matchResponse = await this.renegadeClient.requestExternalMatch( + externalOrder, + ); + const { settlementTx, deadline } = this.parseMatchBundle(matchResponse); + const executionSrcAmount = + side === SwapSide.BUY + ? this.getExternalPartyAmountInFromCalldata( + settlementTx.data, + optimalSwapExchange.srcAmount, + ) + : optimalSwapExchange.srcAmount; + + return [ + { + ...optimalSwapExchange, + srcAmount: executionSrcAmount, + data: { + settlementTx, + rawResponse: matchResponse, + }, + }, + { deadline }, + ]; + } catch (e: any) { + if (this.isPairRejection(e)) { + this.logger.warn( + `${this.dexKey}-${this.network}: protocol is restricted for pair ${srcToken.address} -> ${destToken.address}`, + ); + await this.restrictPair(srcToken.address, destToken.address); + } + + this.logger.error(`${this.dexKey}-${this.network}: ${e}`); + throw e; + } + } + + async releaseResources(): Promise { + if (!this.dexHelper.config.isSlave) { + this.rateFetcher.stop(); + } + } + + getTokenFromAddress(address: Address): Token { + return this.tokensMap[address.toLowerCase()]; + } + + private async assertAddressesNotBlacklisted( + options: PreprocessTransactionOptions, + ): Promise { + if (await this.isBlacklisted(options.txOrigin)) { + this.logger.warn( + `${this.dexKey}-${this.network}: blacklisted TX Origin address '${options.txOrigin}' trying to build a transaction. Bailing...`, + ); + throw new BlacklistError(this.dexKey, this.network, options.txOrigin); + } + + if ( + options.userAddress !== options.txOrigin && + (await this.isBlacklisted(options.userAddress)) + ) { + this.logger.warn( + `${this.dexKey}-${this.network}: blacklisted user address '${options.userAddress}' trying to build a transaction. Bailing...`, + ); + throw new BlacklistError(this.dexKey, this.network, options.userAddress); + } + } + + // Returns true only for errors that indicate the pair itself is invalid + // (4xx responses). Transient failures (timeouts, 5xx) must not restrict pairs. + private isPairRejection(e: any): boolean { + if (e?.isSlippageError || e?.isBlacklistError || e?.isNoMatchError) { + return false; + } + const status = e?.response?.status; + return status >= 400 && status < 500; + } + + private assertPairSupported(srcToken: Address, destToken: Address): void { + if (!this.areTokensSupported(srcToken, destToken)) { + throw new Error( + `${this.dexKey}-${this.network}: Tokens not supported by Renegade API: ${srcToken}, ${destToken}`, + ); + } + } + + private parseMatchBundle(response: SponsoredMatchResponse): { + settlementTx: RenegadeTx; + deadline?: bigint; + } { + const bundle = response?.match_bundle; + const tx = bundle?.settlement_tx; + const txData = tx?.data || tx?.input; + if (!tx?.to || !txData) { + const err: any = new Error( + `${this.dexKey}-${this.network}: Invalid match response`, + ); + err.isNoMatchError = true; + throw err; + } + + return { + settlementTx: { + to: tx.to, + data: txData, + value: tx.value || '0', + }, + deadline: + bundle.deadline != null + ? BigInt(bundle.deadline.toString()) + : undefined, + }; + } + + private createExternalOrder( + srcToken: Token, + destToken: Token, + side: SwapSide, + srcAmount: string, + destAmount: string, + ): ExternalOrder { + const isBuy = side === SwapSide.BUY; + + return { + input_mint: srcToken.address, + output_mint: destToken.address, + input_amount: isBuy ? '0' : srcAmount, + output_amount: isBuy ? destAmount : '0', + use_exact_output_amount: isBuy, + min_fill_size: isBuy ? '0' : srcAmount, + }; + } + + private getExternalPartyAmountInFromCalldata( + calldata: string, + fallbackAmount: string, + ): string { + const data = calldata.startsWith('0x') ? calldata.slice(2) : calldata; + const selectorAndFirstWordHexLength = 8 + 64; + + if (data.length < selectorAndFirstWordHexLength) { + return fallbackAmount; + } + + try { + return BigInt( + `0x${data.slice(8, selectorAndFirstWordHexLength)}`, + ).toString(); + } catch { + return fallbackAmount; + } + } + + async setTokensMap(): Promise { + const metadata = await this.getCachedTokens(); + if (metadata) { + this.tokensMap = metadata; + } + } + + private getPoolIdentifier(tokenA: Address, tokenB: Address): string { + const sorted = this._sortTokens(tokenA, tokenB); + return `${ + this.dexKey + }_${sorted[0].toLowerCase()}_${sorted[1].toLowerCase()}`; + } + + private computePricesFromCachedLevels( + levels: RenegadeLevelsResponse, + srcToken: Token, + destToken: Token, + amounts: bigint[], + side: SwapSide, + ): bigint[] | null { + const baseToken = this.isUSDC(srcToken.address) ? destToken : srcToken; + const quoteToken = this.isUSDC(srcToken.address) ? srcToken : destToken; + const depth = levels.getPairDepth(baseToken.address, quoteToken.address); + if (!depth) { + return null; + } + + const midpointDepth = this.getMidpointDepth(depth); + if (!midpointDepth) { + return null; + } + + return amounts.map(amount => { + if (amount === 0n) { + return 0n; + } + + let rawAmount: bigint; + if (side === SwapSide.SELL) { + rawAmount = this.computeSellQuote( + midpointDepth, + srcToken, + destToken, + amount.toString(), + ); + } else { + rawAmount = this.computeBuyQuote( + midpointDepth, + srcToken, + destToken, + amount.toString(), + ); + } + + return rawAmount; + }); + } + + private computeSellQuote( + midpointDepth: RenegadeMidpointDepth, + srcToken: Token, + destToken: Token, + srcAmountAtomic: string, + ): bigint { + const srcAmount = this.toNominal(srcAmountAtomic, srcToken.decimals); + const srcIsQuote = this.isUSDC(srcToken.address); + + if (srcIsQuote) { + const sellQuoteCapacity = midpointDepth.sellBaseCapacity.multipliedBy( + midpointDepth.price, + ); + if (srcAmount.gt(sellQuoteCapacity)) { + return 0n; + } + + return this.toAtomicFloor( + srcAmount.dividedBy(midpointDepth.price), + destToken.decimals, + ); + } + + if (srcAmount.gt(midpointDepth.buyBaseCapacity)) { + return 0n; + } + + return this.toAtomicFloor( + srcAmount.multipliedBy(midpointDepth.price), + destToken.decimals, + ); + } + + private computeBuyQuote( + midpointDepth: RenegadeMidpointDepth, + srcToken: Token, + destToken: Token, + destAmountAtomic: string, + ): bigint { + const destAmount = this.toNominal(destAmountAtomic, destToken.decimals); + const srcIsQuote = this.isUSDC(srcToken.address); + + if (srcIsQuote) { + if (destAmount.gt(midpointDepth.sellBaseCapacity)) { + return 0n; + } + + return this.toAtomicCeil( + destAmount.multipliedBy(midpointDepth.price), + srcToken.decimals, + ); + } + + const buyQuoteCapacity = midpointDepth.buyBaseCapacity.multipliedBy( + midpointDepth.price, + ); + if (destAmount.gt(buyQuoteCapacity)) { + return 0n; + } + + return this.toAtomicCeil( + destAmount.dividedBy(midpointDepth.price), + srcToken.decimals, + ); + } + + private getMidpointDepth(depth: RenegadeDepth): RenegadeMidpointDepth | null { + const bidLevel = this.parsePriceLevel(depth.bids[0]); + const askLevel = this.parsePriceLevel(depth.asks[0]); + const price = askLevel?.price ?? bidLevel?.price; + + if (!price || price.lte(0)) { + return null; + } + + if (bidLevel && askLevel && !bidLevel.price.eq(askLevel.price)) { + this.logger.warn( + `${this.dexKey}-${this.network}: midpoint depth sides disagree on price`, + { + bidPrice: bidLevel.price.toString(), + askPrice: askLevel.price.toString(), + }, + ); + } + + return { + price, + buyBaseCapacity: bidLevel?.size ?? new BigNumber(0), + sellBaseCapacity: askLevel?.size ?? new BigNumber(0), + }; + } + + private parsePriceLevel( + level: RenegadePriceLevel | undefined, + ): { price: BigNumber; size: BigNumber } | null { + if (!level) { + return null; + } + + const [priceStr, sizeStr] = level; + const price = new BigNumber(priceStr); + const size = new BigNumber(sizeStr); + + if (!price.isFinite() || !size.isFinite() || price.lte(0) || size.lte(0)) { + return null; + } + + return { price, size }; + } + + private toNominal(amountAtomic: string, decimals: number): BigNumber { + return new BigNumber(amountAtomic).dividedBy(this.pow10(decimals)); + } + + private toAtomicFloor(amountNominal: BigNumber, decimals: number): bigint { + return BigInt( + amountNominal + .multipliedBy(this.pow10(decimals)) + .decimalPlaces(0, BigNumber.ROUND_FLOOR) + .toFixed(0), + ); + } + + private toAtomicCeil(amountNominal: BigNumber, decimals: number): bigint { + return BigInt( + amountNominal + .multipliedBy(this.pow10(decimals)) + .decimalPlaces(0, BigNumber.ROUND_CEIL) + .toFixed(0), + ); + } + + private pow10(decimals: number): BigNumber { + return new BigNumber(10).pow(decimals); + } + + async getCachedLevels(): Promise { + const cachedLevels = await this.dexHelper.cache.getAndCacheLocally( + this.dexKey, + this.network, + RENEGADE_LEVELS_CACHE_KEY, + RENEGADE_LEVELS_CACHE_TTL_SECONDS, + ); + + if (cachedLevels) { + const rawData = JSON.parse(cachedLevels) as { + [pairIdentifier: string]: RenegadeDepth; + }; + return new RenegadeLevelsResponse(rawData); + } + + return null; + } + + async getCachedTokens(): Promise | null> { + const cachedTokens = await this.dexHelper.cache.getAndCacheLocally( + this.dexKey, + this.network, + RENEGADE_TOKEN_METADATA_CACHE_KEY, + RENEGADE_TOKEN_METADATA_CACHE_TTL_SECONDS, + ); + + if (cachedTokens) { + return JSON.parse(cachedTokens) as Record; + } + + return null; + } + + private areTokensSupported( + srcTokenAddress: Address, + destTokenAddress: Address, + ): boolean { + const srcTokenLower = srcTokenAddress.toLowerCase(); + const destTokenLower = destTokenAddress.toLowerCase(); + + const srcTokenExists = this.tokensMap[srcTokenLower] !== undefined; + const destTokenExists = this.tokensMap[destTokenLower] !== undefined; + + if (!srcTokenExists || !destTokenExists) { + return false; + } + + const srcIsUSDC = this.isUSDC(srcTokenAddress); + const destIsUSDC = this.isUSDC(destTokenAddress); + return srcIsUSDC !== destIsUSDC; + } + + isUSDC(tokenAddress: Address): boolean { + return tokenAddress.toLowerCase() === this.usdcAddress.toLowerCase(); + } + + private _sortTokens(srcAddress: Address, destAddress: Address) { + return [srcAddress, destAddress].sort((a, b) => + a.toLowerCase() < b.toLowerCase() ? -1 : 1, + ); + } +} diff --git a/src/dex/renegade/types.ts b/src/dex/renegade/types.ts new file mode 100644 index 000000000..d956b2e85 --- /dev/null +++ b/src/dex/renegade/types.ts @@ -0,0 +1,82 @@ +import BigNumber from 'bignumber.js'; +import { SponsoredMatchResponse } from './api/types'; + +export type RenegadePriceLevel = [price: string, size: string]; + +export type RenegadeDepth = { + bids: RenegadePriceLevel[]; + asks: RenegadePriceLevel[]; +}; + +export type RenegadeMarketInfo = { + base: { + address: string; + symbol: string; + }; + quote: { + address: string; + symbol: string; + }; + price: { + price: string; + timestamp: number; + }; +}; + +export type RenegadeMarketDepth = { + market: RenegadeMarketInfo; + buy: RenegadeMarketSideDepth; + sell: RenegadeMarketSideDepth; +}; + +export type RenegadeMarketDepthsResponse = { + market_depths: RenegadeMarketDepth[]; +}; + +export type RenegadeMarketSideDepth = { + total_quantity: string; + total_quantity_usd: string; +}; + +export type RenegadeRateFetcherConfig = { + apiKey: string; + apiSecret: string; + levelsCacheKey: string; + levelsCacheTTL: number; + tokenMetadataCacheKey: string; + tokenMetadataCacheTTL: number; +}; + +type RenegadeTokenInfo = { + name: string; + ticker: string; + address: string; + decimals: number; + supported_exchanges: Record; + canonical_exchange: string; +}; + +export type RenegadeTokenRemap = { + tokens: RenegadeTokenInfo[]; +}; + +export type RenegadeData = { + settlementTx?: RenegadeTx; + rawResponse?: SponsoredMatchResponse; +}; + +export type DexParams = { + usdcAddress: string; +}; + +export type RenegadeTx = { + to: string; + data: string; + value: string; +}; + +export type RenegadeMidpointDepth = { + price: BigNumber; + buyBaseCapacity: BigNumber; + sellBaseCapacity: BigNumber; +}; diff --git a/src/types.ts b/src/types.ts index 51f663a5f..a9aded277 100644 --- a/src/types.ts +++ b/src/types.ts @@ -323,6 +323,8 @@ export type Config = { bebopAuthName?: string; bebopAuthToken?: string; nativeApiKey?: string; + renegadeAuthApiKey?: string; + renegadeAuthApiSecret?: string; forceRpcFallbackDexs: string[]; apiKeyTheGraph: string; lidoReferralAddress?: Address;