diff --git a/README.md b/README.md index bfcaa812..ce610a66 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,8 @@ Other optional arguments are: - `--owner-profile`, Specifies the owner limit, example: --owner-profile 0x123456=12 . Will override the 'OWNER_PROFILE' in env variables - `--public-rpc`, Allows to use public RPCs as fallbacks, default is false. Will override the 'PUBLIC_RPC' in env variables - `--gas-price-multiplier`, Option to multiply the gas price fetched from the rpc as percentage, default is 107, ie +7%. Will override the 'GAS_PRICE_MULTIPLIER' in env variables +- `--gas-limit-multiplier`, Option to multiply the gas limit estimation from the rpc as percentage, default is 105, ie +5%. Will override the 'GAS_LIMIT_MULTIPLIER' in env variables +- `--tx-gas`, Option to set a static gas limit for all submitting txs. Will override the 'TX_GAS' in env variables - `-V` or `--version`, output the version number - `-h` or `--help`, output usage information @@ -259,6 +261,12 @@ ROUTE="single" # Option to multiply the gas price fetched from the rpc as percentage, default is 107, ie +7% GAS_PRICE_MULTIPLIER= + +# Option to multiply the gas limit estimation from the rpc as percentage, default is 105, ie +5% +GAS_LIMIT_MULTIPLIER= + +# Option to set a static gas limit for all submitting txs +TX_GAS= ``` If both env variables and CLI argument are set, the CLI arguments will be prioritized and override the env variables. diff --git a/example.env b/example.env index 99db5906..61d8b14a 100644 --- a/example.env +++ b/example.env @@ -88,6 +88,12 @@ ROUTE="single" # Option to multiply the gas price fetched from the rpc as percentage, default is 107, ie +7% GAS_PRICE_MULTIPLIER= +# Option to multiply the gas limit estimation from the rpc as percentage, default is 105, ie +5% +GAS_LIMIT_MULTIPLIER= + +# Option to set a static gas limit for all submitting txs +TX_GAS= + # test rpcs vars TEST_POLYGON_RPC= diff --git a/src/account.ts b/src/account.ts index 04566841..0185e385 100644 --- a/src/account.ts +++ b/src/account.ts @@ -1,10 +1,10 @@ +import { ChainId, RPParams } from "sushi"; import { BigNumber, ethers } from "ethers"; import { ErrorSeverity, errorSnapshot } from "./error"; import { Native, Token, WNATIVE } from "sushi/currency"; import { ROUTE_PROCESSOR_4_ADDRESS } from "sushi/config"; import { getRpSwap, PoolBlackList, sleep } from "./utils"; import { createViemClient, getDataFetcher } from "./config"; -import { ChainId, LiquidityProviders, RPParams } from "sushi"; import { mnemonicToAccount, privateKeyToAccount } from "viem/accounts"; import { erc20Abi, multicall3Abi, orderbookAbi, routeProcessor3Abi } from "./abis"; import { context, Context, SpanStatusCode, trace, Tracer } from "@opentelemetry/api"; @@ -157,10 +157,10 @@ export async function manageAccounts( ) { const removedWallets: ViemClient[] = []; let accountsToAdd = 0; - const gasPrice = await config.viemClient.getGasPrice(); for (let i = config.accounts.length - 1; i >= 0; i--) { if (config.accounts[i].BALANCE.lt(avgGasCost.mul(4))) { try { + const gasPrice = await config.viemClient.getGasPrice(); await sweepToMainWallet( config.accounts[i], config.mainAccount, @@ -533,16 +533,20 @@ export async function sweepToMainWallet( } } - if (cumulativeGasLimit.mul(gasPrice).gt(fromWallet.BALANCE)) { + if (cumulativeGasLimit.mul(gasPrice).mul(125).div(100).gt(fromWallet.BALANCE)) { const span = tracer?.startSpan("fund-wallet-to-sweep", undefined, mainCtx); span?.setAttribute("details.wallet", fromWallet.account.address); try { - const transferAmount = cumulativeGasLimit.mul(gasPrice).sub(fromWallet.BALANCE); + const transferAmount = cumulativeGasLimit + .mul(gasPrice) + .mul(125) + .div(100) + .sub(fromWallet.BALANCE); span?.setAttribute("details.amount", ethers.utils.formatUnits(transferAmount)); const hash = await toWallet.sendTransaction({ to: fromWallet.account.address, value: transferAmount.toBigInt(), - nonce: await getNonce(fromWallet), + nonce: await getNonce(toWallet), }); const receipt = await toWallet.waitForTransactionReceipt({ hash, @@ -640,6 +644,7 @@ export async function sweepToMainWallet( if (transferAmount.gt(0)) { span?.setAttribute("details.amount", ethers.utils.formatUnits(transferAmount)); const hash = await fromWallet.sendTransaction({ + gasPrice, to: toWallet.account.address, value: transferAmount.toBigInt(), gas: gasLimit.toBigInt(), @@ -746,7 +751,7 @@ export async function sweepToEth(config: BotConfig, tracer?: Tracer, ctx?: Conte rp4Address, config.dataFetcher, gasPrice, - Object.values(LiquidityProviders).filter((v) => v !== LiquidityProviders.CurveSwap), + config.lps, ); let routeText = ""; route.legs.forEach((v, i) => { @@ -803,7 +808,7 @@ export async function sweepToEth(config: BotConfig, tracer?: Tracer, ctx?: Conte const rawtx = { to: rp4Address, data: "0x" as `0x${string}` }; let gas = 0n; let amountOutMin = ethers.constants.Zero; - for (let j = 50; j > 0; j--) { + for (let j = 50; j > 39; j--) { amountOutMin = ethers.BigNumber.from(rpParams.amountOutMin) .mul(2 * j) .div(100); @@ -819,7 +824,7 @@ export async function sweepToEth(config: BotConfig, tracer?: Tracer, ctx?: Conte gas = await config.mainAccount.estimateGas(rawtx); break; } catch (error) { - if (j === 1) throw error; + if (j === 40) throw error; } } const gasCost = gasPrice.mul(gas).mul(15).div(10); diff --git a/src/cli.ts b/src/cli.ts index 340fecb4..f31d2c2b 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -14,7 +14,13 @@ import { CompressionAlgorithm } from "@opentelemetry/otlp-exporter-base"; import { BotConfig, BundledOrders, CliOptions, ViemClient } from "./types"; import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"; import { SEMRESATTRS_SERVICE_NAME } from "@opentelemetry/semantic-conventions"; -import { manageAccounts, rotateProviders, sweepToMainWallet, sweepToEth } from "./account"; +import { + sweepToEth, + manageAccounts, + rotateProviders, + sweepToMainWallet, + getBatchEthBalance, +} from "./account"; import { prepareOrdersForRound, getOrderbookOwnersProfileMapFromSg, @@ -63,6 +69,8 @@ const ENV_OPTIONS = { botMinBalance: process?.env?.BOT_MIN_BALANCE, selfFundOrders: process?.env?.SELF_FUND_ORDERS, gasPriceMultiplier: process?.env?.GAS_PRICE_MULTIPLIER, + gasLimitMultiplier: process?.env?.GAS_LIMIT_MULTIPLIER, + txGas: process?.env?.TX_GAS, route: process?.env?.ROUTE, ownerProfile: process?.env?.OWNER_PROFILE ? Array.from(process?.env?.OWNER_PROFILE.matchAll(/[^,\s]+/g)).map((v) => v[0]) @@ -184,6 +192,14 @@ const getOptions = async (argv: any, version?: string) => { "--gas-price-multiplier ", "Option to multiply the gas price fetched from the rpc as percentage, default is 107, ie +7%. Will override the 'GAS_PRICE_MULTIPLIER' in env variables", ) + .option( + "--gas-limit-multiplier ", + "Option to multiply the gas limit estimation from the rpc as percentage, default is 105, ie +5%. Will override the 'GAS_LIMIT_MULTIPLIER' in env variables", + ) + .option( + "--tx-gas ", + "Option to set a static gas limit for all submitting txs. Will override the 'TX_GAS' in env variables", + ) .description( [ "A NodeJS app to find and take arbitrage trades for Rain Orderbook orders against some DeFi liquidity providers, requires NodeJS v18 or higher.", @@ -223,6 +239,9 @@ const getOptions = async (argv: any, version?: string) => { cmdOptions.selfFundOrders = cmdOptions.selfFundOrders || getEnv(ENV_OPTIONS.selfFundOrders); cmdOptions.gasPriceMultiplier = cmdOptions.gasPriceMultiplier || getEnv(ENV_OPTIONS.gasPriceMultiplier); + cmdOptions.gasLimitMultiplier = + cmdOptions.gasLimitMultiplier || getEnv(ENV_OPTIONS.gasLimitMultiplier); + cmdOptions.txGas = cmdOptions.txGas || getEnv(ENV_OPTIONS.txGas); cmdOptions.botMinBalance = cmdOptions.botMinBalance || getEnv(ENV_OPTIONS.botMinBalance); cmdOptions.ownerProfile = cmdOptions.ownerProfile || getEnv(ENV_OPTIONS.ownerProfile); cmdOptions.route = cmdOptions.route || getEnv(ENV_OPTIONS.route); @@ -285,9 +304,10 @@ export const arbRound = async ( try { let txs; let foundOpp = false; + let didClear = false; const { reports = [], avgGasCost = undefined } = await clear( config, - bundledOrders, + ordersDetails, tracer, ctx, ); @@ -296,7 +316,6 @@ export const arbRound = async ( if (txs.length) { foundOpp = true; span.setAttribute("txUrls", txs); - span.setAttribute("didClear", true); span.setAttribute("foundOpp", true); } else if ( reports.some((v) => v.status === ProcessPairReportStatus.FoundOpportunity) @@ -304,15 +323,24 @@ export const arbRound = async ( foundOpp = true; span.setAttribute("foundOpp", true); } + if ( + reports.some( + (v) => + v.status === ProcessPairReportStatus.FoundOpportunity && !v.reason, + ) + ) { + didClear = true; + span.setAttribute("didClear", true); + } } else { span.setAttribute("didClear", false); } if (avgGasCost) { - span.setAttribute("avgGasCost", avgGasCost.toString()); + span.setAttribute("avgGasCost", ethers.utils.formatUnits(avgGasCost)); } span.setStatus({ code: SpanStatusCode.OK }); span.end(); - return { txs, foundOpp, avgGasCost }; + return { txs, foundOpp, didClear, avgGasCost }; } catch (e: any) { if (e?.startsWith?.("Failed to batch quote orders")) { span.setAttribute("severity", ErrorSeverity.LOW); @@ -326,7 +354,7 @@ export const arbRound = async ( span.setAttribute("didClear", false); span.setAttribute("foundOpp", false); span.end(); - return { txs: [], foundOpp: false, avgGasCost: undefined }; + return { txs: [], foundOpp: false, didClear: false, avgGasCost: undefined }; } }); }; @@ -406,6 +434,32 @@ export async function startup(argv: any, version?: string, tracer?: Tracer, ctx? } else { options.gasPriceMultiplier = 107; } + if (options.gasLimitMultiplier) { + if (typeof options.gasLimitMultiplier === "number") { + if (options.gasLimitMultiplier <= 0 || !Number.isInteger(options.gasLimitMultiplier)) + throw "invalid gasLimitMultiplier value, must be an integer greater than zero"; + } else if ( + typeof options.gasLimitMultiplier === "string" && + /^[0-9]+$/.test(options.gasLimitMultiplier) + ) { + options.gasLimitMultiplier = Number(options.gasLimitMultiplier); + if (options.gasLimitMultiplier <= 0) + throw "invalid gasLimitMultiplier value, must be an integer greater than zero"; + } else throw "invalid gasLimitMultiplier value, must be an integer greater than zero"; + } else { + options.gasLimitMultiplier = 105; + } + if (options.txGas) { + if (typeof options.txGas === "number") { + if (options.txGas <= 0 || !Number.isInteger(options.txGas)) + throw "invalid txGas value, must be an integer greater than zero"; + else options.txGas = BigInt(options.txGas); + } else if (typeof options.txGas === "string" && /^[0-9]+$/.test(options.txGas)) { + options.txGas = BigInt(options.txGas); + if (options.txGas <= 0n) + throw "invalid txGas value, must be an integer greater than zero"; + } else throw "invalid txGas value, must be an integer greater than zero"; + } const poolUpdateInterval = _poolUpdateInterval * 60 * 1000; let ordersDetails: SgOrder[] = []; if (!process?.env?.CLI_STARTUP_TEST) { @@ -607,16 +661,19 @@ export const main = async (argv: any, version?: string) => { config, bundledOrders, ); - let txs, foundOpp, roundAvgGasCost; + let txs, foundOpp, didClear, roundAvgGasCost; if (roundResult) { txs = roundResult.txs; foundOpp = roundResult.foundOpp; + didClear = roundResult.didClear; roundAvgGasCost = roundResult.avgGasCost; } if (txs && txs.length) { roundSpan.setAttribute("txUrls", txs); - roundSpan.setAttribute("didClear", true); roundSpan.setAttribute("foundOpp", true); + } else if (didClear) { + roundSpan.setAttribute("foundOpp", true); + roundSpan.setAttribute("didClear", true); } else if (foundOpp) { roundSpan.setAttribute("foundOpp", true); roundSpan.setAttribute("didClear", false); @@ -625,6 +682,19 @@ export const main = async (argv: any, version?: string) => { roundSpan.setAttribute("didClear", false); } + // fecth account's balances + if (foundOpp && config.accounts.length) { + try { + const balances = await getBatchEthBalance( + config.accounts.map((v) => v.account.address), + config.viemClient as any as ViemClient, + ); + config.accounts.forEach((v, i) => (v.BALANCE = balances[i])); + } catch { + /**/ + } + } + // keep avg gas cost if (roundAvgGasCost) { const _now = Date.now(); @@ -711,10 +781,15 @@ export const main = async (argv: any, version?: string) => { roundSpan.setStatus({ code: SpanStatusCode.ERROR, message: snapshot }); } if (config.accounts.length) { - roundSpan.setAttribute( - "circulatingAccounts", - config.accounts.map((v) => v.account.address), + const accountsWithBalance: Record = {}; + config.accounts.forEach( + (v) => + (accountsWithBalance[v.account.address] = ethers.utils.formatUnits( + v.BALANCE, + )), ); + roundSpan.setAttribute("circulatingAccounts", JSON.stringify(accountsWithBalance)); + roundSpan.setAttribute("lastAccountIndex", lastUsedAccountIndex); } if (avgGasCost) { roundSpan.setAttribute("avgGasCost", ethers.utils.formatUnits(avgGasCost)); diff --git a/src/error.ts b/src/error.ts index 7ae4bc30..00f5152b 100644 --- a/src/error.ts +++ b/src/error.ts @@ -1,6 +1,20 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +import { ViemClient } from "./types"; +// @ts-ignore +import { abi as obAbi } from "../test/abis/OrderBook.json"; +// @ts-ignore +import { abi as rp4Abi } from "../test/abis/RouteProcessor4.json"; +// @ts-ignore +import { abi as arbRp4Abi } from "../test/abis/RouteProcessorOrderBookV4ArbOrderTaker.json"; +// @ts-ignore +import { abi as genericArbAbi } from "../test/abis/GenericPoolOrderBookV4ArbOrderTaker.json"; import { + isHex, BaseError, + TimeoutError, RpcRequestError, + FeeCapTooLowError, + decodeErrorResult, ExecutionRevertedError, InsufficientFundsError, // InvalidInputRpcError, @@ -16,6 +30,31 @@ export enum ErrorSeverity { HIGH = "HIGH", } +/** + * Specifies a decoded contract error + */ +export type DecodedError = { + name: string; + args: string[]; +}; + +/** + * Raw error returned from rpc call + */ +export type RawError = { + code: number; + message: string; + data?: string; +}; + +/** + * Represents a revert error that happened for a transaction + */ +export type TxRevertError = { + raw: RawError; + decoded?: DecodedError; +}; + /** * Get error with snapshot */ @@ -24,7 +63,20 @@ export function errorSnapshot(header: string, err: any): string { if (err instanceof BaseError) { if (err.shortMessage) message.push("Reason: " + err.shortMessage); if (err.name) message.push("Error: " + err.name); - if (err.details) message.push("Details: " + err.details); + if (err.details) { + message.push("Details: " + err.details); + if (message.some((v) => v.includes("unknown reason"))) { + const { raw, decoded } = parseRevertError(err); + if (decoded) { + message.push("Error Name: " + decoded.name); + if (decoded.args.length) { + message.push("Error Args: " + JSON.stringify(decoded.args)); + } + } else { + if (raw.data) message.push("Error Raw Data: " + raw.data); + } + } + } } else if (err instanceof Error) { if ("reason" in err) message.push("Reason: " + err.reason); else message.push("Reason: " + err.message); @@ -46,15 +98,130 @@ export function errorSnapshot(header: string, err: any): string { */ export function containsNodeError(err: BaseError): boolean { try { + const snapshot = errorSnapshot("", err); return ( // err instanceof TransactionRejectedRpcError || // err instanceof InvalidInputRpcError || + err instanceof FeeCapTooLowError || err instanceof ExecutionRevertedError || err instanceof InsufficientFundsError || (err instanceof RpcRequestError && err.code === ExecutionRevertedError.code) || + (snapshot.includes("exceeds allowance") && !snapshot.includes("out of gas")) || ("cause" in err && containsNodeError(err.cause as any)) ); } catch (error) { return false; } } + +/** + * Checks if a viem BaseError is timeout error + */ +export function isTimeout(err: BaseError): boolean { + try { + return err instanceof TimeoutError || ("cause" in err && isTimeout(err.cause as any)); + } catch (error) { + return false; + } +} + +/** + * Handles a reverted transaction by simulating it and returning the revert error + */ +export async function handleRevert( + viemClient: ViemClient, + hash: `0x${string}`, +): Promise<{ err: any; nodeError: boolean } | undefined> { + try { + const tx = await viemClient.getTransaction({ hash }); + await viemClient.call({ + account: tx.from, + to: tx.to, + data: tx.input, + gas: tx.gas, + gasPrice: tx.gasPrice, + blockNumber: tx.blockNumber, + }); + return undefined; + } catch (err) { + if (err instanceof BaseError) { + const { raw, decoded } = parseRevertError(err); + if (decoded || raw.data) return { err, nodeError: true }; + } + return { err, nodeError: false }; + } +} + +/** + * Parses a revert error to TxRevertError type + */ +export function parseRevertError(error: BaseError): TxRevertError { + if ("cause" in error) { + return parseRevertError(error.cause as any); + } else { + let decoded: DecodedError | undefined; + const raw: RawError = { + code: (error as any).code ?? NaN, + message: error.message, + data: (error as any).data ?? undefined, + }; + if ("data" in error && isHex(error.data)) { + decoded = tryDecodeError(error.data); + } + return { raw, decoded }; + } +} + +/** + * Tries to decode an error data with known contract error selectors + */ +export function tryDecodeError(data: `0x${string}`): DecodedError | undefined { + const handleArgs = (args: readonly unknown[]): string[] => { + return ( + args?.map((arg) => { + if (typeof arg === "string") { + return arg; + } else { + try { + return arg!.toString(); + } catch (error) { + return ""; + } + } + }) ?? [] + ); + }; + try { + const result = decodeErrorResult({ data, abi: rp4Abi }); + return { + name: result.errorName, + args: handleArgs(result.args ?? []), + }; + } catch { + try { + const result = decodeErrorResult({ data, abi: obAbi }); + return { + name: result.errorName, + args: handleArgs(result.args ?? []), + }; + } catch { + try { + const result = decodeErrorResult({ data, abi: arbRp4Abi }); + return { + name: result.errorName, + args: handleArgs(result.args ?? []), + }; + } catch { + try { + const result = decodeErrorResult({ data, abi: genericArbAbi }); + return { + name: result.errorName, + args: handleArgs(result.args ?? []), + }; + } catch { + return undefined; + } + } + } + } +} diff --git a/src/index.ts b/src/index.ts index 3942c338..b533d20d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -207,6 +207,8 @@ export async function getConfig( config.route = route; config.rpcRecords = rpcRecords; config.gasPriceMultiplier = options.gasPriceMultiplier; + config.gasLimitMultiplier = options.gasLimitMultiplier; + config.txGas = options.txGas; // init accounts const { mainAccount, accounts } = await initAccounts(walletKey, config, options, tracer, ctx); diff --git a/src/modes/index.ts b/src/modes/index.ts index a1c61b95..92cb2b82 100644 --- a/src/modes/index.ts +++ b/src/modes/index.ts @@ -1,4 +1,4 @@ -import { Contract } from "ethers"; +import { BigNumber, Contract } from "ethers"; import { PublicClient } from "viem"; import { DataFetcher } from "sushi"; import { Token } from "sushi/currency"; @@ -43,6 +43,14 @@ export async function findOpp({ toToken: Token; fromToken: Token; }): Promise { + try { + gasPrice = BigNumber.from(await viemClient.getGasPrice()) + .mul(config.gasPriceMultiplier) + .div("100") + .toBigInt(); + } catch { + /**/ + } const promises = [ findRpOpp({ orderPairObject, @@ -82,6 +90,12 @@ export async function findOpp({ if (allResults.some((v) => v.status === "fulfilled")) { // pick and return the highest profit + allResults.forEach((v, i) => { + if (v.status === "fulfilled") { + v.value.spanAttributes["clearModePick"] = + i === 0 ? "rp4" : i === 1 ? "intra" : "inter"; + } + }); const res = allResults.filter( (v) => v.status === "fulfilled", ) as PromiseFulfilledResult[]; diff --git a/src/modes/interOrderbook.ts b/src/modes/interOrderbook.ts index 602567da..1577f21e 100644 --- a/src/modes/interOrderbook.ts +++ b/src/modes/interOrderbook.ts @@ -82,7 +82,14 @@ export async function dryrun({ evaluable: { interpreter: orderPairObject.takeOrders[0].takeOrder.order.evaluable.interpreter, store: orderPairObject.takeOrders[0].takeOrder.order.evaluable.store, - bytecode: "0x", + bytecode: + config.gasCoveragePercentage === "0" + ? "0x" + : getBountyEnsureBytecode( + ethers.utils.parseUnits(inputToEthPrice), + ethers.utils.parseUnits(outputToEthPrice), + ethers.constants.Zero, + ), }, signedContext: [], }; @@ -102,10 +109,13 @@ export async function dryrun({ try { blockNumber = Number(await viemClient.getBlockNumber()); spanAttributes["blockNumber"] = blockNumber; - gasLimit = ethers.BigNumber.from(await signer.estimateGas(rawtx)); + gasLimit = ethers.BigNumber.from(await signer.estimateGas(rawtx)) + .mul(config.gasLimitMultiplier) + .div(100); } catch (e) { const isNodeError = containsNodeError(e as BaseError); const errMsg = errorSnapshot("", e); + spanAttributes["stage"] = 1; spanAttributes["isNodeError"] = isNodeError; spanAttributes["error"] = errMsg; spanAttributes["rawtx"] = JSON.stringify( @@ -144,7 +154,9 @@ export async function dryrun({ try { blockNumber = Number(await viemClient.getBlockNumber()); spanAttributes["blockNumber"] = blockNumber; - gasLimit = ethers.BigNumber.from(await signer.estimateGas(rawtx)); + gasLimit = ethers.BigNumber.from(await signer.estimateGas(rawtx)) + .mul(config.gasLimitMultiplier) + .div(100); rawtx.gas = gasLimit.toBigInt(); gasCost = gasLimit.mul(gasPrice); task.evaluable.bytecode = getBountyEnsureBytecode( @@ -160,6 +172,7 @@ export async function dryrun({ } catch (e) { const isNodeError = containsNodeError(e as BaseError); const errMsg = errorSnapshot("", e); + spanAttributes["stage"] = 2; spanAttributes["isNodeError"] = isNodeError; spanAttributes["error"] = errMsg; spanAttributes["rawtx"] = JSON.stringify( @@ -179,6 +192,9 @@ export async function dryrun({ } } rawtx.gas = gasLimit.toBigInt(); + if (typeof config.txGas === "bigint") { + rawtx.gas = config.txGas; + } // if reached here, it means there was a success and found opp spanAttributes["oppBlockNumber"] = blockNumber; @@ -253,7 +269,7 @@ export async function findOpp({ .filter((v) => v !== undefined) as BundledOrders[]; if (!opposingOrderbookOrders || !opposingOrderbookOrders.length) throw undefined; - let maximumInput = orderPairObject.takeOrders.reduce( + const maximumInput = orderPairObject.takeOrders.reduce( (a, b) => a.add(b.quote!.maxOutput), ethers.constants.Zero, ); @@ -288,37 +304,37 @@ export async function findOpp({ for (const err of (e as AggregateError).errors) { allNoneNodeErrors.push(err?.value?.noneNodeError); } - maximumInput = maximumInput.div(2); - try { - // try to find the first resolving binary search - return await Promise.any( - opposingOrderbookOrders.map((v) => { - // filter out the same owner orders - const opposingOrders = { - ...v, - takeOrders: v.takeOrders.filter( - (e) => - e.takeOrder.order.owner.toLowerCase() !== - orderPairObject.takeOrders[0].takeOrder.order.owner.toLowerCase(), - ), - }; - return binarySearch({ - orderPairObject, - opposingOrders, - signer, - maximumInput, - gasPrice, - arb, - inputToEthPrice, - outputToEthPrice, - config, - viemClient, - }); - }), - ); - } catch { - /**/ - } + // maximumInput = maximumInput.div(2); + // try { + // // try to find the first resolving binary search + // return await Promise.any( + // opposingOrderbookOrders.map((v) => { + // // filter out the same owner orders + // const opposingOrders = { + // ...v, + // takeOrders: v.takeOrders.filter( + // (e) => + // e.takeOrder.order.owner.toLowerCase() !== + // orderPairObject.takeOrders[0].takeOrder.order.owner.toLowerCase(), + // ), + // }; + // return binarySearch({ + // orderPairObject, + // opposingOrders, + // signer, + // maximumInput, + // gasPrice, + // arb, + // inputToEthPrice, + // outputToEthPrice, + // config, + // viemClient, + // }); + // }), + // ); + // } catch { + // /**/ + // } const allOrderbooksAttributes: any = {}; for (let i = 0; i < e.errors.length; i++) { allOrderbooksAttributes[opposingOrderbookOrders[i].orderbook] = diff --git a/src/modes/intraOrderbook.ts b/src/modes/intraOrderbook.ts index d70a7349..acf56665 100644 --- a/src/modes/intraOrderbook.ts +++ b/src/modes/intraOrderbook.ts @@ -49,6 +49,23 @@ export async function dryrun({ const inputBountyVaultId = "1"; const outputBountyVaultId = "1"; const obInterface = new ethers.utils.Interface(orderbookAbi); + const task = { + evaluable: { + interpreter: orderPairObject.takeOrders[0].takeOrder.order.evaluable.interpreter, + store: orderPairObject.takeOrders[0].takeOrder.order.evaluable.store, + bytecode: getWithdrawEnsureBytecode( + signer.account.address, + orderPairObject.buyToken, + orderPairObject.sellToken, + inputBalance, + outputBalance, + ethers.utils.parseUnits(inputToEthPrice), + ethers.utils.parseUnits(outputToEthPrice), + ethers.constants.Zero, + ), + }, + signedContext: [], + }; const withdrawInputCalldata = obInterface.encodeFunctionData("withdraw2", [ orderPairObject.buyToken, inputBountyVaultId, @@ -59,7 +76,7 @@ export async function dryrun({ orderPairObject.sellToken, outputBountyVaultId, ethers.constants.MaxUint256, - [], + config.gasCoveragePercentage === "0" ? [] : [task], ]); const clear2Calldata = obInterface.encodeFunctionData("clear2", [ orderPairObject.takeOrders[0].takeOrder.order, @@ -89,11 +106,14 @@ export async function dryrun({ try { blockNumber = Number(await viemClient.getBlockNumber()); spanAttributes["blockNumber"] = blockNumber; - gasLimit = ethers.BigNumber.from(await signer.estimateGas(rawtx)); + gasLimit = ethers.BigNumber.from(await signer.estimateGas(rawtx)) + .mul(config.gasLimitMultiplier) + .div(100); } catch (e) { // reason, code, method, transaction, error, stack, message const isNodeError = containsNodeError(e as BaseError); const errMsg = errorSnapshot("", e); + spanAttributes["stage"] = 1; spanAttributes["isNodeError"] = isNodeError; spanAttributes["error"] = errMsg; spanAttributes["rawtx"] = JSON.stringify( @@ -118,23 +138,16 @@ export async function dryrun({ // sender output which is already called above if (config.gasCoveragePercentage !== "0") { const headroom = (Number(config.gasCoveragePercentage) * 1.03).toFixed(); - const task = { - evaluable: { - interpreter: orderPairObject.takeOrders[0].takeOrder.order.evaluable.interpreter, - store: orderPairObject.takeOrders[0].takeOrder.order.evaluable.store, - bytecode: getWithdrawEnsureBytecode( - signer.account.address, - orderPairObject.buyToken, - orderPairObject.sellToken, - inputBalance, - outputBalance, - ethers.utils.parseUnits(inputToEthPrice), - ethers.utils.parseUnits(outputToEthPrice), - gasCost.mul(headroom).div("100"), - ), - }, - signedContext: [], - }; + task.evaluable.bytecode = getWithdrawEnsureBytecode( + signer.account.address, + orderPairObject.buyToken, + orderPairObject.sellToken, + inputBalance, + outputBalance, + ethers.utils.parseUnits(inputToEthPrice), + ethers.utils.parseUnits(outputToEthPrice), + gasCost.mul(headroom).div("100"), + ); withdrawOutputCalldata = obInterface.encodeFunctionData("withdraw2", [ orderPairObject.sellToken, outputBountyVaultId, @@ -148,7 +161,9 @@ export async function dryrun({ try { blockNumber = Number(await viemClient.getBlockNumber()); spanAttributes["blockNumber"] = blockNumber; - gasLimit = ethers.BigNumber.from(await signer.estimateGas(rawtx)); + gasLimit = ethers.BigNumber.from(await signer.estimateGas(rawtx)) + .mul(config.gasLimitMultiplier) + .div(100); rawtx.gas = gasLimit.toBigInt(); gasCost = gasLimit.mul(gasPrice); task.evaluable.bytecode = getWithdrawEnsureBytecode( @@ -173,6 +188,7 @@ export async function dryrun({ } catch (e) { const isNodeError = containsNodeError(e as BaseError); const errMsg = errorSnapshot("", e); + spanAttributes["stage"] = 2; spanAttributes["isNodeError"] = isNodeError; spanAttributes["error"] = errMsg; spanAttributes["rawtx"] = JSON.stringify( @@ -192,6 +208,9 @@ export async function dryrun({ } } rawtx.gas = gasLimit.toBigInt(); + if (typeof config.txGas === "bigint") { + rawtx.gas = config.txGas; + } // if reached here, it means there was a success and found opp spanAttributes["oppBlockNumber"] = blockNumber; diff --git a/src/modes/routeProcessor.ts b/src/modes/routeProcessor.ts index a4419f0a..20cd8935 100644 --- a/src/modes/routeProcessor.ts +++ b/src/modes/routeProcessor.ts @@ -5,7 +5,14 @@ import { ChainId, DataFetcher, Router } from "sushi"; import { BigNumber, Contract, ethers } from "ethers"; import { containsNodeError, errorSnapshot } from "../error"; import { BotConfig, BundledOrders, ViemClient, DryrunResult, SpanAttrs } from "../types"; -import { estimateProfit, RPoolFilter, visualizeRoute, withBigintSerializer } from "../utils"; +import { + scale18, + scale18To, + RPoolFilter, + estimateProfit, + visualizeRoute, + withBigintSerializer, +} from "../utils"; /** * Specifies the reason that dryrun failed @@ -41,6 +48,7 @@ export async function dryrun({ ethPrice, config, viemClient, + hasPriceMatch, }: { mode: number; config: BotConfig; @@ -54,6 +62,7 @@ export async function dryrun({ toToken: Token; fromToken: Token; maximumInput: BigNumber; + hasPriceMatch?: { value: boolean }; }) { const spanAttributes: SpanAttrs = {}; const result: DryrunResult = { @@ -105,6 +114,7 @@ export async function dryrun({ // exit early if market price is lower than order quote ratio if (price.lt(orderPairObject.takeOrders[0].quote!.ratio)) { + if (hasPriceMatch) hasPriceMatch.value = false; result.reason = RouteProcessorDryrunHaltReason.NoOpportunity; spanAttributes["error"] = "Order's ratio greater than market price"; return Promise.reject(result); @@ -147,7 +157,14 @@ export async function dryrun({ evaluable: { interpreter: orderPairObject.takeOrders[0].takeOrder.order.evaluable.interpreter, store: orderPairObject.takeOrders[0].takeOrder.order.evaluable.store, - bytecode: "0x", + bytecode: + config.gasCoveragePercentage === "0" + ? "0x" + : getBountyEnsureBytecode( + ethers.utils.parseUnits(ethPrice), + ethers.constants.Zero, + ethers.constants.Zero, + ), }, signedContext: [], }; @@ -167,11 +184,14 @@ export async function dryrun({ try { blockNumber = Number(await viemClient.getBlockNumber()); spanAttributes["blockNumber"] = blockNumber; - gasLimit = ethers.BigNumber.from(await signer.estimateGas(rawtx)); + gasLimit = ethers.BigNumber.from(await signer.estimateGas(rawtx)) + .mul(config.gasLimitMultiplier) + .div(100); } catch (e) { // reason, code, method, transaction, error, stack, message const isNodeError = containsNodeError(e as BaseError); const errMsg = errorSnapshot("", e); + spanAttributes["stage"] = 1; spanAttributes["isNodeError"] = isNodeError; spanAttributes["error"] = errMsg; spanAttributes["rawtx"] = JSON.stringify( @@ -211,7 +231,9 @@ export async function dryrun({ try { blockNumber = Number(await viemClient.getBlockNumber()); spanAttributes["blockNumber"] = blockNumber; - gasLimit = ethers.BigNumber.from(await signer.estimateGas(rawtx)); + gasLimit = ethers.BigNumber.from(await signer.estimateGas(rawtx)) + .mul(config.gasLimitMultiplier) + .div(100); rawtx.gas = gasLimit.toBigInt(); gasCost = gasLimit.mul(gasPrice); task.evaluable.bytecode = getBountyEnsureBytecode( @@ -227,6 +249,7 @@ export async function dryrun({ } catch (e) { const isNodeError = containsNodeError(e as BaseError); const errMsg = errorSnapshot("", e); + spanAttributes["stage"] = 2; spanAttributes["isNodeError"] = isNodeError; spanAttributes["error"] = errMsg; spanAttributes["rawtx"] = JSON.stringify( @@ -247,6 +270,9 @@ export async function dryrun({ } } rawtx.gas = gasLimit.toBigInt(); + if (typeof config.txGas === "bigint") { + rawtx.gas = config.txGas; + } // if reached here, it means there was a success and found opp // rest of span attr are not needed since they are present in the result.data @@ -310,83 +336,90 @@ export async function findOpp({ }; let noRoute = true; + const hasPriceMatch = { + value: true, + }; const initAmount = orderPairObject.takeOrders.reduce( (a, b) => a.add(b.quote!.maxOutput), ethers.constants.Zero, ); - let maximumInput = BigNumber.from(initAmount.toString()); + const maximumInput = BigNumber.from(initAmount.toString()); - const allSuccessHops: DryrunResult[] = []; const allHopsAttributes: string[] = []; const allNoneNodeErrors: (string | undefined)[] = []; - for (let i = 1; i < config.hops + 1; i++) { - try { - const dryrunResult = await dryrun({ - mode, - orderPairObject, - dataFetcher, - fromToken, - toToken, - signer, - maximumInput, - gasPrice, - arb, - ethPrice, - config, - viemClient, - }); - - // return early if there was success on first attempt (ie full vault balance) - // else record the success result - if (i == 1) { - return dryrunResult; - } else { - allSuccessHops.push(dryrunResult); - } - // set the maxInput for next hop by increasing - maximumInput = maximumInput.add(initAmount.div(2 ** i)); - } catch (e: any) { - // the fail reason can only be no route in case all hops fail reasons are no route - if (e.reason !== RouteProcessorDryrunHaltReason.NoRoute) noRoute = false; - - // record this hop attributes - // error attr is only recorded for first hop, - // since it is repeated and consumes lots of data - if (i !== 1) { - delete e.spanAttributes["error"]; + try { + return await dryrun({ + mode, + orderPairObject, + dataFetcher, + fromToken, + toToken, + signer, + maximumInput, + gasPrice, + arb, + ethPrice, + config, + viemClient, + hasPriceMatch, + }); + } catch (e: any) { + // the fail reason can only be no route in case all hops fail reasons are no route + if (e.reason !== RouteProcessorDryrunHaltReason.NoRoute) noRoute = false; + allNoneNodeErrors.push(e?.value?.noneNodeError); + allHopsAttributes.push(JSON.stringify(e.spanAttributes)); + } + if (!hasPriceMatch.value) { + const maxTradeSize = findMaxInput({ + orderPairObject, + dataFetcher, + fromToken, + toToken, + maximumInput, + gasPrice, + config, + }); + if (maxTradeSize) { + try { + return await dryrun({ + mode, + orderPairObject, + dataFetcher, + fromToken, + toToken, + signer, + maximumInput: maxTradeSize, + gasPrice, + arb, + ethPrice, + config, + viemClient, + }); + } catch (e: any) { + // the fail reason can only be no route in case all hops fail reasons are no route + if (e.reason !== RouteProcessorDryrunHaltReason.NoRoute) noRoute = false; delete e.spanAttributes["rawtx"]; + allNoneNodeErrors.push(e?.value?.noneNodeError); + allHopsAttributes.push(JSON.stringify(e.spanAttributes)); } - allNoneNodeErrors.push(e?.value?.noneNodeError); - allHopsAttributes.push(JSON.stringify(e.spanAttributes)); - - // set the maxInput for next hop by decreasing - maximumInput = maximumInput.sub(initAmount.div(2 ** i)); } } + // in case of no successfull hop, allHopsAttributes will be included + spanAttributes["hops"] = allHopsAttributes; - if (allSuccessHops.length) { - return allSuccessHops[allSuccessHops.length - 1]; - } else { - // in case of no successfull hop, allHopsAttributes will be included - spanAttributes["hops"] = allHopsAttributes; - - if (noRoute) result.reason = RouteProcessorDryrunHaltReason.NoRoute; - else { - const noneNodeErrors = allNoneNodeErrors.filter((v) => !!v); - if ( - allNoneNodeErrors.length && - noneNodeErrors.length / allNoneNodeErrors.length > 0.5 - ) { - result.value = { - noneNodeError: noneNodeErrors[0], - estimatedProfit: ethers.constants.Zero, - }; - } - result.reason = RouteProcessorDryrunHaltReason.NoOpportunity; + if (noRoute) result.reason = RouteProcessorDryrunHaltReason.NoRoute; + else { + const noneNodeErrors = allNoneNodeErrors.filter((v) => !!v); + if (allNoneNodeErrors.length && noneNodeErrors.length / allNoneNodeErrors.length > 0.5) { + result.value = { + noneNodeError: noneNodeErrors[0], + estimatedProfit: ethers.constants.Zero, + }; } - - return Promise.reject(result); + result.reason = RouteProcessorDryrunHaltReason.NoOpportunity; } + + return Promise.reject(result); } /** @@ -481,3 +514,66 @@ export async function findOppWithRetries({ throw result; } } + +/** + * Calculates the largest possible trade size, returns undefined if not possible, + * because price difference is larger to be covered by reducing the trade size + */ +export function findMaxInput({ + orderPairObject, + dataFetcher, + fromToken, + toToken, + maximumInput: maximumInputFixed, + gasPrice, + config, +}: { + config: BotConfig; + orderPairObject: BundledOrders; + dataFetcher: DataFetcher; + gasPrice: bigint; + toToken: Token; + fromToken: Token; + maximumInput: BigNumber; +}): BigNumber | undefined { + const result: BigNumber[] = []; + const ratio = orderPairObject.takeOrders[0].quote!.ratio; + const pcMap = dataFetcher.getCurrentPoolCodeMap(fromToken, toToken); + const initAmount = scale18To(maximumInputFixed, fromToken.decimals).div(2); + let maximumInput = BigNumber.from(initAmount.toString()); + for (let i = 1; i < 26; i++) { + const maxInput18 = scale18(maximumInput, fromToken.decimals); + const route = Router.findBestRoute( + pcMap, + config.chain.id as ChainId, + fromToken, + maximumInput.toBigInt(), + toToken, + Number(gasPrice), + undefined, + RPoolFilter, + undefined, + config.route, + ); + + if (route.status == "NoWay") { + maximumInput = maximumInput.sub(initAmount.div(2 ** i)); + } else { + const amountOut = scale18(route.amountOutBI, toToken.decimals); + const price = amountOut.mul("1" + "0".repeat(18)).div(maxInput18); + + if (price.lt(ratio)) { + maximumInput = maximumInput.sub(initAmount.div(2 ** i)); + } else { + result.unshift(maxInput18); + maximumInput = maximumInput.add(initAmount.div(2 ** i)); + } + } + } + + if (result.length) { + return result[0]; + } else { + return undefined; + } +} diff --git a/src/processOrders.ts b/src/processOrders.ts index be4be8d5..d9eac755 100644 --- a/src/processOrders.ts +++ b/src/processOrders.ts @@ -9,7 +9,7 @@ import { BigNumber, Contract, ethers } from "ethers"; import { Tracer } from "@opentelemetry/sdk-trace-base"; import { Context, SpanStatusCode } from "@opentelemetry/api"; import { fundOwnedOrders, getNonce, rotateAccounts } from "./account"; -import { containsNodeError, ErrorSeverity, errorSnapshot } from "./error"; +import { containsNodeError, ErrorSeverity, errorSnapshot, handleRevert, isTimeout } from "./error"; import { Report, BotConfig, @@ -45,7 +45,8 @@ export enum ProcessPairHaltReason { FailedToGetPools = 4, TxFailed = 5, TxMineFailed = 6, - UnexpectedError = 7, + TxReverted = 7, + UnexpectedError = 8, } /** @@ -305,25 +306,69 @@ export const processOrders = async ( span.setAttribute("errorDetails", message); } span.setStatus({ code: SpanStatusCode.OK, message }); - } else { - // set the otel span status as OK as an unsuccessfull clear, this can happen for example + } else if (e.reason === ProcessPairHaltReason.TxFailed) { + // failed to submit the tx to mempool, this can happen for example when rpc rejects + // the tx for example because of low gas or invalid parameters, etc + let message = "failed to submit the transaction"; + if (e.error) { + message = errorSnapshot(message, e.error); + span.setAttribute("errorDetails", message); + if (isTimeout(e.error)) { + span.setAttribute("severity", ErrorSeverity.LOW); + } else { + span.setAttribute("severity", ErrorSeverity.HIGH); + } + } else { + span.setAttribute("severity", ErrorSeverity.HIGH); + } + span.setStatus({ code: SpanStatusCode.ERROR, message }); + span.setAttribute("unsuccessfulClear", true); + span.setAttribute("txSendFailed", true); + } else if (e.reason === ProcessPairHaltReason.TxReverted) { + // Tx reverted onchain, this can happen for example // because of mev front running or false positive opportunities, etc - let code = SpanStatusCode.OK; - let message = "transaction failed"; + let message = "transaction reverted onchain"; if (e.error) { message = errorSnapshot(message, e.error); span.setAttribute("errorDetails", message); } if (e.spanAttributes["txNoneNodeError"]) { - code = SpanStatusCode.ERROR; - span.setAttribute("severity", ErrorSeverity.MEDIUM); + span.setAttribute("severity", ErrorSeverity.HIGH); + } + span.setStatus({ code: SpanStatusCode.ERROR, message }); + span.setAttribute("unsuccessfulClear", true); + span.setAttribute("txReverted", true); + } else if (e.reason === ProcessPairHaltReason.TxMineFailed) { + // tx failed to get included onchain, this can happen as result of timeout, rpc dropping the tx, etc + let message = "transaction failed"; + if (e.error) { + message = errorSnapshot(message, e.error); + span.setAttribute("errorDetails", message); + if (isTimeout(e.error)) { + span.setAttribute("severity", ErrorSeverity.LOW); + } else { + span.setAttribute("severity", ErrorSeverity.HIGH); + } + } else { + span.setAttribute("severity", ErrorSeverity.HIGH); + } + span.setStatus({ code: SpanStatusCode.ERROR, message }); + span.setAttribute("unsuccessfulClear", true); + span.setAttribute("txMineFailed", true); + } else { + // record the error for the span + let message = "unexpected error"; + if (e.error) { + message = errorSnapshot(message, e.error); + span.recordException(e.error); } - span.setStatus({ code, message }); - span.setAttribute("unsuccessfullClear", true); + // set the span status to unexpected error + span.setAttribute("severity", ErrorSeverity.HIGH); + span.setStatus({ code: SpanStatusCode.ERROR, message }); } } else { // record the error for the span - let message = pair + ": unexpected error"; + let message = "unexpected error"; if (e.error) { message = errorSnapshot(message, e.error); span.recordException(e.error); @@ -742,6 +787,11 @@ export async function processPair(args: { return result; } else { // keep track of gas consumption of the account + const simulation = await handleRevert(viemClient as any, txhash); + if (simulation) { + result.error = simulation.err; + spanAttributes["txNoneNodeError"] = !simulation.nodeError; + } result.report = { status: ProcessPairReportStatus.FoundOpportunity, txUrl, @@ -750,7 +800,7 @@ export async function processPair(args: { sellToken: orderPairObject.sellToken, actualGasCost: ethers.utils.formatUnits(actualGasCost), }; - result.reason = ProcessPairHaltReason.TxMineFailed; + result.reason = ProcessPairHaltReason.TxReverted; return Promise.reject(result); } } catch (e: any) { @@ -775,6 +825,13 @@ export async function processPair(args: { result.report.actualGasCost = ethers.utils.formatUnits(actualGasCost); } result.error = e; + spanAttributes["details.rawTx"] = JSON.stringify( + { + ...rawtx, + from: signer.account.address, + }, + withBigintSerializer, + ); spanAttributes["txNoneNodeError"] = !containsNodeError(e); result.reason = ProcessPairHaltReason.TxMineFailed; throw result; diff --git a/src/types.ts b/src/types.ts index b633ce50..0db97980 100644 --- a/src/types.ts +++ b/src/types.ts @@ -47,6 +47,8 @@ export type CliOptions = { publicRpc: boolean; route?: string; gasPriceMultiplier: number; + gasLimitMultiplier: number; + txGas?: bigint; }; export type TokenDetails = { @@ -170,6 +172,8 @@ export type BotConfig = { route?: "multi" | "single"; rpcRecords: Record; gasPriceMultiplier: number; + gasLimitMultiplier: number; + txGas?: bigint; onFetchRequest?: (request: Request) => void; onFetchResponse?: (request: Response) => void; }; @@ -187,6 +191,8 @@ export type Report = { clearedOrders?: string[]; income?: BigNumber; netProfit?: BigNumber; + reason?: ProcessPairHaltReason; + error?: any; }; export type RoundReport = { diff --git a/src/utils.ts b/src/utils.ts index 537a4920..e55991f7 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1367,3 +1367,27 @@ export function memory(msg: string) { // eslint-disable-next-line no-console console.log("\n---\n"); } + +/** + * Scales a given value and its decimals to 18 fixed point decimals + */ +export function scale18(value: BigNumberish, decimals: BigNumberish): BigNumber { + const d = BigNumber.from(decimals).toNumber(); + if (d > 18) { + return BigNumber.from(value).div("1" + "0".repeat(d - 18)); + } else { + return BigNumber.from(value).mul("1" + "0".repeat(18 - d)); + } +} + +/** + * Scales a given 18 fixed point decimals value to the given decimals point value + */ +export function scale18To(value: BigNumberish, targetDecimals: BigNumberish): BigNumber { + const decimals = BigNumber.from(targetDecimals).toNumber(); + if (decimals > 18) { + return BigNumber.from(value).mul("1" + "0".repeat(decimals - 18)); + } else { + return BigNumber.from(value).div("1" + "0".repeat(18 - decimals)); + } +} diff --git a/test/abis/RouteProcessor4.json b/test/abis/RouteProcessor4.json new file mode 100644 index 00000000..68ecdc7f --- /dev/null +++ b/test/abis/RouteProcessor4.json @@ -0,0 +1 @@ +{"abi":[{"inputs":[{"internalType":"address","name":"_bentoBox","type":"address"},{"internalType":"address[]","name":"priviledgedUserList","type":"address[]"}],"stateMutability":"nonpayable","type":"constructor"},{"inputs":[{"internalType":"uint256","name":"amountOut","type":"uint256"}],"name":"MinimalOutputBalanceViolation","type":"error"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"previousOwner","type":"address"},{"indexed":true,"internalType":"address","name":"newOwner","type":"address"}],"name":"OwnershipTransferred","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"from","type":"address"},{"indexed":false,"internalType":"address","name":"to","type":"address"},{"indexed":true,"internalType":"address","name":"tokenIn","type":"address"},{"indexed":true,"internalType":"address","name":"tokenOut","type":"address"},{"indexed":false,"internalType":"uint256","name":"amountIn","type":"uint256"},{"indexed":false,"internalType":"uint256","name":"amountOutMin","type":"uint256"},{"indexed":false,"internalType":"uint256","name":"amountOut","type":"uint256"}],"name":"Route","type":"event"},{"inputs":[{"internalType":"int256","name":"amount0Delta","type":"int256"},{"internalType":"int256","name":"amount1Delta","type":"int256"},{"internalType":"bytes","name":"data","type":"bytes"}],"name":"algebraSwapCallback","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"bentoBox","outputs":[{"internalType":"contract IBentoBoxMinimal","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"owner","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"int256","name":"amount0Delta","type":"int256"},{"internalType":"int256","name":"amount1Delta","type":"int256"},{"internalType":"bytes","name":"data","type":"bytes"}],"name":"pancakeV3SwapCallback","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"pause","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"","type":"address"}],"name":"priviledgedUsers","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"tokenIn","type":"address"},{"internalType":"uint256","name":"amountIn","type":"uint256"},{"internalType":"address","name":"tokenOut","type":"address"},{"internalType":"uint256","name":"amountOutMin","type":"uint256"},{"internalType":"address","name":"to","type":"address"},{"internalType":"bytes","name":"route","type":"bytes"}],"name":"processRoute","outputs":[{"internalType":"uint256","name":"amountOut","type":"uint256"}],"stateMutability":"payable","type":"function"},{"inputs":[],"name":"renounceOwnership","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"resume","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"user","type":"address"},{"internalType":"bool","name":"priviledge","type":"bool"}],"name":"setPriviledge","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"newOwner","type":"address"}],"name":"transferOwnership","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address payable","name":"transferValueTo","type":"address"},{"internalType":"uint256","name":"amountValueTransfer","type":"uint256"},{"internalType":"address","name":"tokenIn","type":"address"},{"internalType":"uint256","name":"amountIn","type":"uint256"},{"internalType":"address","name":"tokenOut","type":"address"},{"internalType":"uint256","name":"amountOutMin","type":"uint256"},{"internalType":"address","name":"to","type":"address"},{"internalType":"bytes","name":"route","type":"bytes"}],"name":"transferValueAndprocessRoute","outputs":[{"internalType":"uint256","name":"amountOut","type":"uint256"}],"stateMutability":"payable","type":"function"},{"inputs":[{"internalType":"int256","name":"amount0Delta","type":"int256"},{"internalType":"int256","name":"amount1Delta","type":"int256"},{"internalType":"bytes","name":"data","type":"bytes"}],"name":"uniswapV3SwapCallback","outputs":[],"stateMutability":"nonpayable","type":"function"},{"stateMutability":"payable","type":"receive"}]} \ No newline at end of file diff --git a/test/account.test.js b/test/account.test.js index c2a1817e..af22f5a6 100644 --- a/test/account.test.js +++ b/test/account.test.js @@ -105,7 +105,7 @@ describe("Test accounts", async function () { it("should manage accounts successfully", async function () { const viemClient = { chain: { id: 137 }, - multicall: async () => [10000n, 0n, 0n], + multicall: async () => [10n, 0n], getGasPrice: async () => 3000000n, }; const mnemonic = "test test test test test test test test test test test junk"; diff --git a/test/cli.test.js b/test/cli.test.js index e3f06508..228c15ac 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -48,7 +48,7 @@ describe("Test cli", async function () { }; const response = await arbRound(tracer, ctx, options, { mainAccount: {} }); - const expected = { txs: [], foundOpp: false, avgGasCost: undefined }; + const expected = { txs: [], foundOpp: false, didClear: false, avgGasCost: undefined }; assert.deepEqual(response, expected); testSpan.end(); @@ -185,6 +185,10 @@ describe("Test cli", async function () { "0.123", "--gas-price-multiplier", "120", + "--gas-limit-multiplier", + "110", + "--tx-gas", + "123456789", ]); const expected = { roundGap: 10000, @@ -204,10 +208,14 @@ describe("Test cli", async function () { }, }, gasPriceMultiplier: 120, + gasLimitMultiplier: 110, + txGas: 123456789n, }, options: { botMinBalance: "0.123", gasPriceMultiplier: 120, + gasLimitMultiplier: 110, + txGas: 123456789n, }, }; await sleep(1000); @@ -221,5 +229,9 @@ describe("Test cli", async function () { assert.equal(result.options.botMinBalance, expected.options.botMinBalance); assert.equal(result.options.gasPriceMultiplier, expected.options.gasPriceMultiplier); assert.equal(result.config.gasPriceMultiplier, expected.config.gasPriceMultiplier); + assert.equal(result.options.gasLimitMultiplier, expected.options.gasLimitMultiplier); + assert.equal(result.config.gasLimitMultiplier, expected.config.gasLimitMultiplier); + assert.equal(result.options.txGas, expected.options.txGas); + assert.equal(result.config.txGas, expected.config.txGas); }); }); diff --git a/test/data.js b/test/data.js index 851a88db..aa692c14 100644 --- a/test/data.js +++ b/test/data.js @@ -66,6 +66,7 @@ const config = { symbol: token2.symbol, }, gasPriceMultiplier: 107, + gasLimitMultiplier: 100, }; const vaultBalance1 = BigNumber.from("10000000000000000000"); diff --git a/test/e2e/e2e.test.js b/test/e2e/e2e.test.js index 1f40d6e1..c67d3b7d 100644 --- a/test/e2e/e2e.test.js +++ b/test/e2e/e2e.test.js @@ -250,6 +250,7 @@ for (let i = 0; i < testData.length; i++) { config.mainAccount = bot; config.quoteRpc = [mockServer.url + "/rpc"]; config.gasPriceMultiplier = 107; + config.gasLimitMultiplier = 100; orders = prepareOrdersForRound( await getOrderbookOwnersProfileMapFromSg(orders, viemClient, []), false, @@ -584,6 +585,7 @@ for (let i = 0; i < testData.length; i++) { config.mainAccount = bot; config.quoteRpc = [mockServer.url + "/rpc"]; config.gasPriceMultiplier = 107; + config.gasLimitMultiplier = 100; orders = prepareOrdersForRound( await getOrderbookOwnersProfileMapFromSg(orders, viemClient, []), false, @@ -936,6 +938,7 @@ for (let i = 0; i < testData.length; i++) { config.mainAccount = bot; config.quoteRpc = [mockServer.url + "/rpc"]; config.gasPriceMultiplier = 107; + config.gasLimitMultiplier = 100; orders = prepareOrdersForRound( await getOrderbookOwnersProfileMapFromSg(orders, viemClient, []), false, diff --git a/test/error.test.js b/test/error.test.js new file mode 100644 index 00000000..633c0c34 --- /dev/null +++ b/test/error.test.js @@ -0,0 +1,34 @@ +const { assert } = require("chai"); +const { BaseError } = require("viem"); +const { tryDecodeError, parseRevertError } = require("../src/error"); + +describe("Test error", async function () { + const data = "0x963b34a500000000000000000000000000000000000000000000000340bda9d7e155feb0"; + + it("should decode the error data", async function () { + const result = tryDecodeError(data); + const expected = { + name: "MinimalOutputBalanceViolation", + args: ["60005303754817928880"], + }; + assert.deepEqual(result, expected); + }); + + it("should parse viem revert error", async function () { + const rawError = { + code: -3, + message: "some msg", + data, + }; + const error = new BaseError("some msg", { cause: rawError }); + const result = parseRevertError(error); + const expected = { + raw: rawError, + decoded: { + name: "MinimalOutputBalanceViolation", + args: ["60005303754817928880"], + }, + }; + assert.deepEqual(result, expected); + }); +}); diff --git a/test/findOpp.test.js b/test/findOpp.test.js index efea40c7..5d15c616 100644 --- a/test/findOpp.test.js +++ b/test/findOpp.test.js @@ -124,6 +124,7 @@ describe("Test find opp", async function () { amountOut: formatUnits(getAmountOut(vaultBalance), 6), marketPrice: formatUnits(getCurrentPrice(vaultBalance)), route: expectedRouteVisual, + clearModePick: "rp4", }, }; assert.deepEqual(result, expected); @@ -214,6 +215,7 @@ describe("Test find opp", async function () { oppBlockNumber, foundOpp: true, maxInput: vaultBalance.toString(), + clearModePick: "inter", }, }; assert.deepEqual(result, expected); @@ -309,6 +311,7 @@ describe("Test find opp", async function () { spanAttributes: { oppBlockNumber, foundOpp: true, + clearModePick: "intra", }, }; assert.deepEqual(result, expected); diff --git a/test/mode-interOrderbook.test.js b/test/mode-interOrderbook.test.js index 884bd652..17d6c5f3 100644 --- a/test/mode-interOrderbook.test.js +++ b/test/mode-interOrderbook.test.js @@ -256,100 +256,6 @@ describe("Test inter-orderbook find opp", async function () { assert.deepEqual(result, expected); }); - it("should find opp with binary search", async function () { - // mock the signer to reject the first attempt on gas estimation - // so the dryrun goes into binary search - let rejectFirst = true; - signer.estimateGas = async () => { - if (rejectFirst) { - rejectFirst = false; - return Promise.reject(ethers.errors.UNPREDICTABLE_GAS_LIMIT); - } else return gasLimitEstimation; - }; - const result = await findOpp({ - orderPairObject, - signer, - gasPrice, - arb, - inputToEthPrice, - outputToEthPrice, - config, - viemClient, - orderbooksOrders, - }); - const opposingMaxInput = vaultBalance - .mul(3) - .div(4) - .mul(orderPairObject.takeOrders[0].quote.ratio) - .div(`1${"0".repeat(36 - orderPairObject.buyTokenDecimals)}`); - const opposingMaxIORatio = ethers.BigNumber.from(`1${"0".repeat(36)}`).div( - orderPairObject.takeOrders[0].quote.ratio, - ); - const obInterface = new ethers.utils.Interface(orderbookAbi); - const encodedFN = obInterface.encodeFunctionData("takeOrders2", [ - { - minimumInput: ethers.constants.One, - maximumInput: opposingMaxInput, - maximumIORatio: opposingMaxIORatio, - orders: opposingOrderPairObject.takeOrders.map((v) => v.takeOrder), - data: "0x", - }, - ]); - const expectedTakeOrdersConfigStruct = { - minimumInput: ethers.constants.One, - maximumInput: vaultBalance.mul(3).div(4), - maximumIORatio: ethers.constants.MaxUint256, - orders: [orderPairObject.takeOrders[0].takeOrder], - data: ethers.utils.defaultAbiCoder.encode( - ["address", "address", "bytes"], - [opposingOrderPairObject.orderbook, opposingOrderPairObject.orderbook, encodedFN], - ), - }; - const task = { - evaluable: { - interpreter: orderPairObject.takeOrders[0].takeOrder.order.evaluable.interpreter, - store: orderPairObject.takeOrders[0].takeOrder.order.evaluable.store, - bytecode: getBountyEnsureBytecode( - ethers.utils.parseUnits(inputToEthPrice), - ethers.utils.parseUnits(outputToEthPrice), - gasLimitEstimation.mul(gasPrice), - ), - }, - signedContext: [], - }; - const expected = { - value: { - rawtx: { - data: arb.interface.encodeFunctionData("arb3", [ - orderPairObject.orderbook, - expectedTakeOrdersConfigStruct, - task, - ]), - to: arb.address, - gasPrice, - gas: gasLimitEstimation.toBigInt(), - }, - maximumInput: vaultBalance.mul(3).div(4), - oppBlockNumber, - estimatedProfit: estimateProfit( - orderPairObject, - ethers.utils.parseUnits(inputToEthPrice), - ethers.utils.parseUnits(outputToEthPrice), - opposingOrderPairObject, - undefined, - vaultBalance.mul(3).div(4), - ), - }, - reason: undefined, - spanAttributes: { - oppBlockNumber, - foundOpp: true, - maxInput: vaultBalance.mul(3).div(4).toString(), - }, - }; - assert.deepEqual(result, expected); - }); - it("should NOT find opp", async function () { const err = ethers.errors.UNPREDICTABLE_GAS_LIMIT; signer.estimateGas = async () => { @@ -404,7 +310,11 @@ describe("Test inter-orderbook find opp", async function () { interpreter: orderPairObject.takeOrders[0].takeOrder.order.evaluable.interpreter, store: orderPairObject.takeOrders[0].takeOrder.order.evaluable.store, - bytecode: "0x", + bytecode: getBountyEnsureBytecode( + ethers.utils.parseUnits(inputToEthPrice), + ethers.utils.parseUnits(outputToEthPrice), + ethers.constants.Zero, + ), }, signedContext: [], }; @@ -429,6 +339,7 @@ describe("Test inter-orderbook find opp", async function () { [opposingOrderbookAddress]: { maxInput: vaultBalance.toString(), blockNumber: oppBlockNumber, + stage: 1, isNodeError: false, error: errorSnapshot("", err), rawtx: JSON.stringify(rawtx), diff --git a/test/mode-intraOrderbook.test.js b/test/mode-intraOrderbook.test.js index d34f6387..e2647c24 100644 --- a/test/mode-intraOrderbook.test.js +++ b/test/mode-intraOrderbook.test.js @@ -270,6 +270,7 @@ describe("Test intra-orderbook find opp", async function () { }); assert.fail("expected to reject, but resolved"); } catch (error) { + const balance = ethers.BigNumber.from("1000000000000000000"); const withdrawInputCalldata = orderbook.interface.encodeFunctionData("withdraw2", [ orderPairObject.buyToken, "1", @@ -280,7 +281,26 @@ describe("Test intra-orderbook find opp", async function () { orderPairObject.sellToken, "1", ethers.constants.MaxUint256, - [], + [ + { + evaluable: { + interpreter: + orderPairObject.takeOrders[0].takeOrder.order.evaluable.interpreter, + store: orderPairObject.takeOrders[0].takeOrder.order.evaluable.store, + bytecode: getWithdrawEnsureBytecode( + signer.account.address, + orderPairObject.buyToken, + orderPairObject.sellToken, + balance, + balance, + ethers.utils.parseUnits(inputToEthPrice), + ethers.utils.parseUnits(outputToEthPrice), + ethers.constants.Zero, + ), + }, + signedContext: [], + }, + ], ]); const clear2Calldata = orderbook.interface.encodeFunctionData("clear2", [ orderPairObject.takeOrders[0].takeOrder.order, @@ -314,6 +334,7 @@ describe("Test intra-orderbook find opp", async function () { intraOrderbook: [ JSON.stringify({ blockNumber: oppBlockNumber, + stage: 1, isNodeError: false, error: errorSnapshot("", err), rawtx: JSON.stringify(rawtx), diff --git a/test/mode-routeProcessor.test.js b/test/mode-routeProcessor.test.js index 3a671319..8040e646 100644 --- a/test/mode-routeProcessor.test.js +++ b/test/mode-routeProcessor.test.js @@ -1,7 +1,7 @@ const { assert } = require("chai"); const testData = require("./data"); const { errorSnapshot } = require("../src/error"); -const { estimateProfit } = require("../src/utils"); +const { estimateProfit, clone } = require("../src/utils"); const { ethers, utils: { formatUnits }, @@ -199,7 +199,11 @@ describe("Test route processor dryrun", async function () { interpreter: orderPairObject.takeOrders[0].takeOrder.order.evaluable.interpreter, store: orderPairObject.takeOrders[0].takeOrder.order.evaluable.store, - bytecode: "0x", + bytecode: getBountyEnsureBytecode( + ethers.utils.parseUnits(ethPrice), + ethers.constants.Zero, + ethers.constants.Zero, + ), }, signedContext: [], }; @@ -226,6 +230,7 @@ describe("Test route processor dryrun", async function () { blockNumber: oppBlockNumber, error: errorSnapshot("", ethers.errors.UNPREDICTABLE_GAS_LIMIT), route: expectedRouteVisual, + stage: 1, rawtx: JSON.stringify(rawtx), isNodeError: false, }, @@ -326,18 +331,17 @@ describe("Test route processor find opp", async function () { dataFetcher.getCurrentPoolCodeMap = () => { return poolCodeMap; }; - // mock the signer to reject the first attempt on gas estimation - // so the dryrun goes into binary search - let rejectFirst = true; signer.estimateGas = async () => { - if (rejectFirst) { - rejectFirst = false; - return Promise.reject(ethers.errors.UNPREDICTABLE_GAS_LIMIT); - } else return gasLimitEstimation; + return gasLimitEstimation; }; + const orderPairObjectCopy = clone(orderPairObject); + orderPairObjectCopy.takeOrders[0].quote.ratio = ethers.utils.parseUnits("0.009900695135"); + orderPairObjectCopy.takeOrders[0].quote.maxOutput = ethers.BigNumber.from( + "1" + "0".repeat(25), + ); const result = await findOpp({ mode: 0, - orderPairObject, + orderPairObject: orderPairObjectCopy, dataFetcher, fromToken, toToken, @@ -350,7 +354,7 @@ describe("Test route processor find opp", async function () { }); const expectedTakeOrdersConfigStruct = { minimumInput: ethers.constants.One, - maximumInput: vaultBalance.mul(3).div(4), + maximumInput: ethers.utils.parseUnits("9999999.701976776123046875"), maximumIORatio: ethers.constants.MaxUint256, orders: [orderPairObject.takeOrders[0].takeOrder], data: expectedRouteData, @@ -379,26 +383,28 @@ describe("Test route processor find opp", async function () { gasPrice, gas: gasLimitEstimation.toBigInt(), }, - maximumInput: vaultBalance.mul(3).div(4), - price: getCurrentPrice(vaultBalance.sub(vaultBalance.div(4))), + maximumInput: ethers.utils.parseUnits("9999999.701976776123046875"), + price: getCurrentPrice(ethers.utils.parseUnits("9999999.701976776123046875")), routeVisual: expectedRouteVisual, oppBlockNumber, estimatedProfit: estimateProfit( - orderPairObject, + orderPairObjectCopy, ethers.utils.parseUnits(ethPrice), undefined, undefined, - getCurrentPrice(vaultBalance.mul(3).div(4)), - vaultBalance.mul(3).div(4), + ethers.utils.parseUnits("0.009900695426163716"), + ethers.utils.parseUnits("9999999.701976776123046875"), ), }, reason: undefined, spanAttributes: { oppBlockNumber, foundOpp: true, - amountIn: formatUnits(vaultBalance.mul(3).div(4)), - amountOut: formatUnits(getAmountOut(vaultBalance.mul(3).div(4)), 6), - marketPrice: formatUnits(getCurrentPrice(vaultBalance.sub(vaultBalance.div(4)))), + amountIn: "9999999.701976776123046875", + amountOut: "99006.951311", + marketPrice: ethers.utils.formatUnits( + getCurrentPrice(ethers.utils.parseUnits("9999999.701976776123046875")), + ), route: expectedRouteVisual, }, }; @@ -440,7 +446,11 @@ describe("Test route processor find opp", async function () { interpreter: orderPairObject.takeOrders[0].takeOrder.order.evaluable.interpreter, store: orderPairObject.takeOrders[0].takeOrder.order.evaluable.store, - bytecode: "0x", + bytecode: getBountyEnsureBytecode( + ethers.utils.parseUnits(ethPrice), + ethers.constants.Zero, + ethers.constants.Zero, + ), }, signedContext: [], }; @@ -462,9 +472,7 @@ describe("Test route processor find opp", async function () { reason: RouteProcessorDryrunHaltReason.NoOpportunity, spanAttributes: { hops: [ - `{"amountIn":"${formatUnits(vaultBalance)}","amountOut":"${formatUnits(getAmountOut(vaultBalance), 6)}","marketPrice":"${formatUnits(getCurrentPrice(vaultBalance))}","route":${JSON.stringify(expectedRouteVisual)},"blockNumber":${oppBlockNumber},"isNodeError":false,"error":${JSON.stringify(errorSnapshot("", ethers.errors.UNPREDICTABLE_GAS_LIMIT))},"rawtx":${JSON.stringify(rawtx)}}`, - `{"amountIn":"${formatUnits(vaultBalance.div(2))}","amountOut":"${formatUnits(getAmountOut(vaultBalance.div(2)), 6)}","marketPrice":"${formatUnits(getCurrentPrice(vaultBalance.div(2)))}","route":${JSON.stringify(expectedRouteVisual)},"blockNumber":${oppBlockNumber},"isNodeError":false}`, - `{"amountIn":"${formatUnits(vaultBalance.div(4))}","amountOut":"${formatUnits(getAmountOut(vaultBalance.div(4)), 6)}","marketPrice":"${formatUnits(getCurrentPrice(vaultBalance.div(4)))}","route":${JSON.stringify(expectedRouteVisual)},"blockNumber":${oppBlockNumber},"isNodeError":false}`, + `{"amountIn":"${formatUnits(vaultBalance)}","amountOut":"${formatUnits(getAmountOut(vaultBalance), 6)}","marketPrice":"${formatUnits(getCurrentPrice(vaultBalance))}","route":${JSON.stringify(expectedRouteVisual)},"blockNumber":${oppBlockNumber},"stage":1,"isNodeError":false,"error":${JSON.stringify(errorSnapshot("", ethers.errors.UNPREDICTABLE_GAS_LIMIT))},"rawtx":${JSON.stringify(rawtx)}}`, ], }, }; @@ -496,11 +504,7 @@ describe("Test route processor find opp", async function () { value: undefined, reason: RouteProcessorDryrunHaltReason.NoRoute, spanAttributes: { - hops: [ - `{"amountIn":"${formatUnits(vaultBalance)}","route":"no-way"}`, - `{"amountIn":"${formatUnits(vaultBalance.div(2))}","route":"no-way"}`, - `{"amountIn":"${formatUnits(vaultBalance.div(4))}","route":"no-way"}`, - ], + hops: [`{"amountIn":"${formatUnits(vaultBalance)}","route":"no-way"}`], }, }; assert.deepEqual(error, expected); @@ -638,7 +642,11 @@ describe("Test find opp with retries", async function () { interpreter: orderPairObject.takeOrders[0].takeOrder.order.evaluable.interpreter, store: orderPairObject.takeOrders[0].takeOrder.order.evaluable.store, - bytecode: "0x", + bytecode: getBountyEnsureBytecode( + ethers.utils.parseUnits(ethPrice), + ethers.constants.Zero, + ethers.constants.Zero, + ), }, signedContext: [], }; @@ -660,9 +668,7 @@ describe("Test find opp with retries", async function () { reason: RouteProcessorDryrunHaltReason.NoOpportunity, spanAttributes: { hops: [ - `{"amountIn":"${formatUnits(vaultBalance)}","amountOut":"${formatUnits(getAmountOut(vaultBalance), 6)}","marketPrice":"${formatUnits(getCurrentPrice(vaultBalance))}","route":${JSON.stringify(expectedRouteVisual)},"blockNumber":${oppBlockNumber},"isNodeError":false,"error":${JSON.stringify(errorSnapshot("", ethers.errors.UNPREDICTABLE_GAS_LIMIT))},"rawtx":${JSON.stringify(rawtx)}}`, - `{"amountIn":"${formatUnits(vaultBalance.div(2))}","amountOut":"${formatUnits(getAmountOut(vaultBalance.div(2)), 6)}","marketPrice":"${formatUnits(getCurrentPrice(vaultBalance.div(2)))}","route":${JSON.stringify(expectedRouteVisual)},"blockNumber":${oppBlockNumber},"isNodeError":false}`, - `{"amountIn":"${formatUnits(vaultBalance.div(4))}","amountOut":"${formatUnits(getAmountOut(vaultBalance.div(4)), 6)}","marketPrice":"${formatUnits(getCurrentPrice(vaultBalance.div(4)))}","route":${JSON.stringify(expectedRouteVisual)},"blockNumber":${oppBlockNumber},"isNodeError":false}`, + `{"amountIn":"${formatUnits(vaultBalance)}","amountOut":"${formatUnits(getAmountOut(vaultBalance), 6)}","marketPrice":"${formatUnits(getCurrentPrice(vaultBalance))}","route":${JSON.stringify(expectedRouteVisual)},"blockNumber":${oppBlockNumber},"stage":1,"isNodeError":false,"error":${JSON.stringify(errorSnapshot("", ethers.errors.UNPREDICTABLE_GAS_LIMIT))},"rawtx":${JSON.stringify(rawtx)}}`, ], }, }; diff --git a/test/processPair.test.js b/test/processPair.test.js index fa0833b9..9a22c100 100644 --- a/test/processPair.test.js +++ b/test/processPair.test.js @@ -150,6 +150,7 @@ describe("Test process pair", async function () { didClear: true, "details.inputToEthPrice": formatUnits(getCurrentInputToEthPrice()), "details.outputToEthPrice": "1", + "details.clearModePick": "rp4", "details.quote": JSON.stringify({ maxOutput: formatUnits(vaultBalance), ratio: formatUnits(ethers.constants.Zero), @@ -226,6 +227,7 @@ describe("Test process pair", async function () { "details.marketQuote.str": "0.99699", "details.inputToEthPrice": formatUnits(getCurrentInputToEthPrice()), "details.outputToEthPrice": "1", + "details.clearModePick": "inter", "details.quote": JSON.stringify({ maxOutput: formatUnits(vaultBalance), ratio: formatUnits(ethers.constants.Zero), @@ -546,6 +548,7 @@ describe("Test process pair", async function () { "details.outputToEthPrice": "1", "details.marketQuote.num": 0.99699, "details.marketQuote.str": "0.99699", + "details.clearModePick": "rp4", txNoneNodeError: true, "details.quote": JSON.stringify({ maxOutput: formatUnits(vaultBalance), @@ -567,7 +570,7 @@ describe("Test process pair", async function () { } }); - it("should fail to mine tx", async function () { + it("should revert tx", async function () { await mockServer.forPost("/rpc").thenSendJsonRpcResult(quoteResponse); const errorReceipt = { status: "reverted", @@ -580,6 +583,8 @@ describe("Test process pair", async function () { }; signer.sendTransaction = async () => txHash; viemClient.waitForTransactionReceipt = async () => errorReceipt; + viemClient.getTransaction = async () => ({}); + viemClient.call = async () => Promise.reject("out of gas"); try { await processPair({ config, @@ -606,8 +611,111 @@ describe("Test process pair", async function () { txUrl: scannerUrl + "/tx/" + txHash, actualGasCost: formatUnits(effectiveGasPrice.mul(gasUsed)), }, + reason: ProcessPairHaltReason.TxReverted, + error: "out of gas", + gasCost: undefined, + spanAttributes: { + "details.pair": pair, + "details.orders": [orderPairObject.takeOrders[0].id], + "details.gasPrice": gasPrice.mul(107).div(100).toString(), + "details.blockNumber": 123456, + "details.blockNumberDiff": 0, + "details.marketPrice": formatUnits(getCurrentPrice(vaultBalance)), + "details.amountIn": formatUnits(vaultBalance), + "details.amountOut": formatUnits(getAmountOut(vaultBalance), 6), + oppBlockNumber: 123456, + "details.route": expectedRouteVisual, + foundOpp: true, + "details.txUrl": scannerUrl + "/tx/" + txHash, + "details.inputToEthPrice": formatUnits(getCurrentInputToEthPrice()), + "details.outputToEthPrice": "1", + "details.marketQuote.num": 0.99699, + "details.marketQuote.str": "0.99699", + "details.clearModePick": "rp4", + txNoneNodeError: true, + "details.quote": JSON.stringify({ + maxOutput: formatUnits(vaultBalance), + ratio: formatUnits(ethers.constants.Zero), + }), + "details.estimatedProfit": formatUnits( + estimateProfit( + orderPairObject, + getCurrentInputToEthPrice(), + ethers.utils.parseUnits("1"), + undefined, + getCurrentPrice(vaultBalance), + vaultBalance, + ), + ), + }, + }; + assert.deepEqual(error, expected); + } + }); + + it("should fail to mine tx", async function () { + await mockServer.forPost("/rpc").thenSendJsonRpcResult(quoteResponse); + const errorRejection = new Error("timeout"); + dataFetcher.getCurrentPoolCodeMap = () => { + return poolCodeMap; + }; + signer.sendTransaction = async () => txHash; + viemClient.waitForTransactionReceipt = async () => Promise.reject(errorRejection); + try { + await processPair({ + config, + orderPairObject, + viemClient, + dataFetcher, + signer, + flashbotSigner: undefined, + arb, + orderbook, + pair, + mainAccount: signer, + accounts: [signer], + fetchedPairPools: [], + }); + assert.fail("expected to reject, but resolved"); + } catch (error) { + const expectedTakeOrdersConfigStruct = { + minimumInput: ethers.constants.One, + maximumInput: vaultBalance, + maximumIORatio: ethers.constants.MaxUint256, + orders: [orderPairObject.takeOrders[0].takeOrder], + data: expectedRouteData, + }; + const task = { + evaluable: { + interpreter: + orderPairObject.takeOrders[0].takeOrder.order.evaluable.interpreter, + store: orderPairObject.takeOrders[0].takeOrder.order.evaluable.store, + bytecode: "0x", + }, + signedContext: [], + }; + const rawtx = { + data: arb.interface.encodeFunctionData("arb3", [ + orderPairObject.orderbook, + expectedTakeOrdersConfigStruct, + task, + ]), + to: arb.address, + gasPrice: gasPrice.mul(107).div(100).toString(), + gas: gasLimitEstimation.toString(), + nonce: 0, + from: signer.account.address, + }; + const expected = { + report: { + status: ProcessPairReportStatus.FoundOpportunity, + tokenPair: pair, + buyToken: orderPairObject.buyToken, + sellToken: orderPairObject.sellToken, + txUrl: scannerUrl + "/tx/" + txHash, + }, reason: ProcessPairHaltReason.TxMineFailed, - error: undefined, + error: errorRejection, gasCost: undefined, spanAttributes: { "details.pair": pair, @@ -621,11 +729,14 @@ describe("Test process pair", async function () { oppBlockNumber: 123456, "details.route": expectedRouteVisual, foundOpp: true, + "details.rawTx": JSON.stringify(rawtx), "details.txUrl": scannerUrl + "/tx/" + txHash, "details.inputToEthPrice": formatUnits(getCurrentInputToEthPrice()), "details.outputToEthPrice": "1", "details.marketQuote.num": 0.99699, "details.marketQuote.str": "0.99699", + "details.clearModePick": "rp4", + txNoneNodeError: true, "details.quote": JSON.stringify({ maxOutput: formatUnits(vaultBalance), ratio: formatUnits(ethers.constants.Zero), diff --git a/test/utils.test.js b/test/utils.test.js index 388b49b3..008d560b 100644 --- a/test/utils.test.js +++ b/test/utils.test.js @@ -1,9 +1,10 @@ const { assert } = require("chai"); const testData = require("./data"); -const { clone, getTotalIncome, checkOwnedOrders } = require("../src/utils"); +const { clone, getTotalIncome, checkOwnedOrders, scale18, scale18To } = require("../src/utils"); const { ethers, utils: { hexlify, randomBytes }, + BigNumber, } = require("ethers"); describe("Test utils functions", async function () { @@ -85,4 +86,36 @@ describe("Test utils functions", async function () { })); assert.deepEqual(result, expected); }); + + it("should scale to 18", async function () { + // down + const value1 = "123456789"; + const decimals1 = 3; + const result1 = scale18(value1, decimals1); + const expected1 = BigNumber.from("123456789000000000000000"); + assert.deepEqual(result1, expected1); + + // up + const value2 = "123456789"; + const decimals2 = 23; + const result2 = scale18(value2, decimals2); + const expected2 = BigNumber.from("1234"); + assert.deepEqual(result2, expected2); + }); + + it("should scale from 18", async function () { + // down + const value1 = "123456789"; + const decimals1 = 12; + const result1 = scale18To(value1, decimals1); + const expected1 = BigNumber.from("123"); + assert.deepEqual(result1, expected1); + + // up + const value2 = "123456789"; + const decimals2 = 23; + const result2 = scale18To(value2, decimals2); + const expected2 = BigNumber.from("12345678900000"); + assert.deepEqual(result2, expected2); + }); });